"""``Context`` — run-scoped variable + factory bag.

See: ``DESIGN.md`` §3.4 (Context). v0.1 is minimal — a frozen mapping
of variables. Scenarios (v0.2) extend with extracted-value mutation
(``ctx.set(name, value)`` from ``Probe.extract``); factories +
``{{factory:name}}`` template filter land alongside.

The frozen v0.1 shape suffices for single-probe runs where the user
seeds a few values up front.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from types import MappingProxyType
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
    from collections.abc import Mapping


@dataclass(frozen=True, slots=True)
class Context:
    """Run-scoped variable bag.

    ``variables`` is wrapped in :class:`types.MappingProxyType` for
    immutability — checks reading the context can't accidentally
    mutate it. Construct fresh contexts per suite run::

        ctx = Context.with_variables({"user_id": "abc-123", "tenant": "acme"})
    """

    variables: Mapping[str, Any] = field(default_factory=lambda: MappingProxyType({}))

    @classmethod
    def empty(cls) -> Context:
        """Return an empty context. Convenience for v0.1 single-probe runs."""
        return cls()

    @classmethod
    def with_variables(cls, variables: Mapping[str, Any]) -> Context:
        """Return a context seeded with ``variables`` (copied + frozen)."""
        return cls(variables=MappingProxyType(dict(variables)))

    def get(self, name: str, default: Any = None) -> Any:
        """Look up a variable. Returns ``default`` if absent."""
        return self.variables.get(name, default)

    def with_added(self, name: str, value: Any) -> Context:
        """Return a new Context with ``name`` → ``value`` merged in.

        Used by the Suite runner to thread values extracted from one
        probe's response into subsequent probes' template substitutions
        (PAPER-39). The current Context is unchanged — the freeze
        invariant holds; callers thread the returned Context forward.

        If ``name`` already exists, the new value overrides. (This is
        how a probe's :meth:`~mgf.apiprobe.core.probe.Probe.depends_on`
        re-extraction works: re-running a probe in a future scenario
        replay overwrites the prior extract value.)
        """
        merged = dict(self.variables)
        merged[name] = value
        return type(self)(variables=MappingProxyType(merged))


def resolve_template(template: str, context: Context) -> str:
    """Substitute ``{var_name}`` placeholders in ``template`` from ``context.variables``.

    Uses Python's :meth:`str.format_map` semantics — ``{name}`` is
    looked up in the context's variables, surrounding text is
    preserved verbatim, and ``{{`` / ``}}`` are literal braces.

    Raises :class:`KeyError` with a clear message if a referenced
    variable isn't in the context. Callers (Suite runner) translate
    that to a :class:`SuiteLogicError`-shaped finding pointing at
    the probe that referenced the missing variable.

    Templates with no ``{var}`` references return unchanged (a
    fast path). This lets the runner safely call the resolver on
    every probe's path / header value without conditional logic.

    PAPER-39.
    """
    if "{" not in template:
        # Fast path — nothing to substitute. Avoids the format_map
        # call overhead for the common case (most probes have no
        # template references).
        return template
    try:
        return template.format_map(_StrictMapping(context.variables))
    except KeyError as exc:
        # Re-raise with a clearer message naming the missing var
        # and the template. The Suite runner catches this and
        # converts to a SuiteLogicError-shaped finding.
        missing = exc.args[0] if exc.args else "<unknown>"
        raise KeyError(
            f"template references {{{missing}}} but the variable is "
            f"not in the Context. Template: {template!r}; available "
            f"variables: {sorted(context.variables.keys())!r}"
        ) from exc


class _StrictMapping:
    """Mapping wrapper for :meth:`str.format_map` that propagates KeyError.

    Python's :meth:`str.format_map` already raises KeyError on
    missing keys when the underlying mapping does — but a plain
    :class:`dict` raises with just the key name. This wrapper
    re-raises a richer error so the caller (:func:`resolve_template`)
    can extract the missing-var name and build a helpful message.
    """

    __slots__ = ("_inner",)

    def __init__(self, inner: Mapping[str, Any]) -> None:
        self._inner = inner

    def __getitem__(self, key: str) -> Any:
        if key not in self._inner:
            raise KeyError(key)
        return self._inner[key]


__all__ = ["Context", "resolve_template"]
