"""Pytest plugin for automatic snapshot capture on test failures."""

from __future__ import annotations

import os
import sys

import pytest

from .capture import capture_exception
from .config import OutputFormat, SnapshotConfig
from .redaction_policy import normalize_redaction_profile, resolve_redaction_policy
from .serialize import safe_repr, truncate_str

# Stash key for tracking test outcomes across reruns (flaky detection)
_flaky_key = pytest.StashKey[list[str]]()

# Internal pytest fixtures that are noisy/uninformative in snapshots
_FIXTURE_EXCLUDE = frozenset({
    "request", "pytestconfig", "_session_faker",
    "capsys", "capfd", "capfdbinary", "capsysbinary",
    "cache", "record_property", "record_testsuite_property",
    "record_xml_attribute", "recwarn", "monkeypatch",
    "doctest_namespace",
})

_TRUTHY = {"1", "true", "yes", "on"}


def _env_bool(name: str, default: str = "") -> bool:
    """Read a boolean from an environment variable.

    Treats ``"1"``, ``"true"``, ``"yes"``, ``"on"`` (case-insensitive) as
    True; everything else as False.
    """
    return os.getenv(name, default).lower() in _TRUTHY


def _env_optional_bool(name: str) -> bool | None:
    """Read an optional boolean from an environment variable."""
    raw = os.getenv(name)
    if raw is None:
        return None
    return raw.lower() in _TRUTHY


def _env_int(name: str, default: int) -> int:
    """Read an integer from an environment variable, falling back to *default*."""
    try:
        return int(os.getenv(name, str(default)))
    except ValueError:
        return default


def pytest_configure(config):
    """Register the no_snapshot marker."""
    config.addinivalue_line(
        "markers",
        "no_snapshot: disable llmdebug snapshot capture for this test",
    )


@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_makereport(item, call):
    """Capture snapshot on test failure."""
    outcome = yield
    report = outcome.get_result()

    # Track all call outcomes for flaky detection (before early-return)
    if report.when == "call":
        try:
            outcomes_list = item.stash.setdefault(_flaky_key, [])
            outcomes_list.append(report.outcome)
        except Exception:
            pass  # Best effort — stash may not be available in all pytest versions

    # Only capture on test call failures (not setup/teardown)
    if report.when != "call" or not report.failed:
        return

    # Respect opt-out marker
    if item.get_closest_marker("no_snapshot"):
        return

    # Get exception info
    if call.excinfo is None:
        return

    exc = call.excinfo.value
    tb = call.excinfo.tb

    # Use test node ID as snapshot name
    name = item.nodeid.replace("/", "_").replace("::", "_")

    debug = _env_bool("LLMDEBUG_DEBUG")

    # Parse include_modules from comma-separated env var
    include_modules_env = os.getenv("LLMDEBUG_INCLUDE_MODULES", "")
    include_modules = (
        tuple(m.strip() for m in include_modules_env.split(",") if m.strip())
        if include_modules_env
        else None
    )

    max_exception_depth = _env_int("LLMDEBUG_MAX_EXCEPTION_DEPTH", 5)

    # Feature flags from env vars
    include_git = _env_bool("LLMDEBUG_INCLUDE_GIT", "true")
    include_array_stats = _env_bool("LLMDEBUG_ARRAY_STATS")
    include_args = _env_bool("LLMDEBUG_INCLUDE_ARGS", "true")
    categorize_errors = _env_bool("LLMDEBUG_CATEGORIZE_ERRORS", "true")
    include_async_context = _env_bool("LLMDEBUG_ASYNC_CONTEXT", "true")
    capture_logs = _env_bool("LLMDEBUG_CAPTURE_LOGS")
    log_max_records = _env_int("LLMDEBUG_LOG_MAX_RECORDS", 20)
    profile_raw = os.getenv("LLMDEBUG_REDACTION_PROFILE")
    try:
        redaction_profile = normalize_redaction_profile(profile_raw)
    except ValueError as e:
        redaction_profile = None
        if debug:
            sys.stderr.write(f"llmdebug: {e}\n")
    redact_keys = _env_optional_bool("LLMDEBUG_REDACT_KEYS")
    redact_traceback = _env_optional_bool("LLMDEBUG_REDACT_TRACEBACK")
    redact_exception_strings = _env_optional_bool("LLMDEBUG_REDACT_EXCEPTION_STRINGS")
    policy = resolve_redaction_policy(
        redaction_profile=redaction_profile,
        redact=None,
        redact_keys=redact_keys,
        redact_traceback=redact_traceback,
        redact_exception_strings=redact_exception_strings,
        legacy_redact=(),
        legacy_redact_keys=False,
        legacy_redact_traceback=False,
        legacy_redact_exception_strings=False,
    )

    # Enhanced context env vars
    include_fixtures = _env_bool("LLMDEBUG_INCLUDE_FIXTURES", "true")
    include_markers = _env_bool("LLMDEBUG_INCLUDE_MARKERS", "true")
    include_pytest_meta = _env_bool("LLMDEBUG_INCLUDE_PYTEST_META", "true")

    # Output format: json, json_compact (default), or toon
    output_format_raw = os.getenv("LLMDEBUG_OUTPUT_FORMAT", "json_compact")
    output_format: OutputFormat = (
        output_format_raw  # type: ignore[assignment]
        if output_format_raw in {"json", "json_compact", "toon"}
        else "json_compact"
    )

    cfg = SnapshotConfig(
        debug=debug,
        include_modules=include_modules,
        max_exception_depth=max_exception_depth,
        redaction_profile=policy.profile,
        redact=policy.patterns,
        redact_keys=policy.redact_keys,
        redact_traceback=policy.redact_traceback,
        redact_exception_strings=policy.redact_exception_strings,
        include_git=include_git,
        include_array_stats=include_array_stats,
        include_args=include_args,
        categorize_errors=categorize_errors,
        include_async_context=include_async_context,
        capture_logs=capture_logs,
        log_max_records=log_max_records,
        output_format=output_format,
    )

    # Pytest-specific context to reduce guesswork
    params = None
    callspec = getattr(item, "callspec", None)
    if callspec is not None:
        try:
            params = {k: safe_repr(v, cfg) for k, v in callspec.params.items()}
        except Exception:
            params = None

    pytest_context: dict[str, object] = {
        "nodeid": report.nodeid,
        "outcome": report.outcome,
        "when": report.when,
        "longrepr": _truncate_optional_text(getattr(report, "longreprtext", None), cfg.max_str),
        "capstdout": _truncate_optional_text(getattr(report, "capstdout", None), cfg.max_str),
        "capstderr": _truncate_optional_text(getattr(report, "capstderr", None), cfg.max_str),
        "params": params,
        "repro": [sys.executable, "-m", "pytest", report.nodeid, "-q"],
    }

    # --- Enhanced context blocks (each guarded independently) ---
    _plugin_warnings: list[str] = []

    # 5a. Fixture values
    if include_fixtures:
        try:
            fix_vals: dict[str, str] = {}
            for fname in item.fixturenames:
                if fname in _FIXTURE_EXCLUDE or fname not in item.funcargs:
                    continue
                fix_vals[fname] = safe_repr(item.funcargs[fname], cfg)
            if fix_vals:
                pytest_context["fixtures"] = fix_vals
        except Exception as e:
            _plugin_warnings.append(f"fixture capture failed: {type(e).__name__}: {e}")

    # 5b. Markers
    if include_markers:
        try:
            mk_dict: dict[str, dict[str, object]] = {}
            for marker in item.iter_markers():
                if marker.name in {"parametrize", "usefixtures"}:
                    continue
                entry: dict[str, object] = {}
                if marker.args:
                    entry["args"] = [safe_repr(a, cfg) for a in marker.args]
                if marker.kwargs:
                    entry["kwargs"] = {k: safe_repr(v, cfg) for k, v in marker.kwargs.items()}
                mk_dict[marker.name] = entry
            if mk_dict:
                pytest_context["markers"] = mk_dict
        except Exception as e:
            _plugin_warnings.append(f"marker capture failed: {type(e).__name__}: {e}")

    # 5c. Timeout config (marker-derived, gated by include_markers)
    if include_markers:
        try:
            timeout_marker = item.get_closest_marker("timeout")
            if timeout_marker is not None:
                toc: dict[str, object] = {}
                if timeout_marker.args:
                    toc["seconds"] = timeout_marker.args[0]
                if timeout_marker.kwargs:
                    toc["kwargs"] = {
                        k: safe_repr(v, cfg) for k, v in timeout_marker.kwargs.items()
                    }
                if toc:
                    pytest_context["timeout_config"] = toc
        except Exception as e:
            _plugin_warnings.append(f"timeout config failed: {type(e).__name__}: {e}")

    # 5d. Parameter indices
    if include_pytest_meta and callspec is not None:
        try:
            indices = dict(callspec.indices)
            if indices:
                pytest_context["param_indices"] = indices
        except Exception as e:
            _plugin_warnings.append(f"param indices failed: {type(e).__name__}: {e}")

    # 5e. xdist worker
    if include_pytest_meta:
        try:
            workerinput = getattr(item.config, "workerinput", None)
            if workerinput is not None:
                worker_id = workerinput.get("workerid")
                if worker_id is not None:
                    pytest_context["xdist_worker"] = worker_id
        except Exception as e:
            _plugin_warnings.append(f"xdist worker failed: {type(e).__name__}: {e}")

    # 5f. Flaky detection
    if include_pytest_meta:
        try:
            stash_outcomes = item.stash.get(_flaky_key, [])
            if len(stash_outcomes) > 1:
                pytest_context["rerun_count"] = len(stash_outcomes)
                pytest_context["flaky"] = (
                    "passed" in stash_outcomes and "failed" in stash_outcomes
                )
        except Exception as e:
            _plugin_warnings.append(f"flaky detection failed: {type(e).__name__}: {e}")

    # 5g. Coverage-guided snapshot enrichment
    include_coverage = _env_bool("LLMDEBUG_INCLUDE_COVERAGE", "true")
    if include_coverage:
        try:
            from .coverage_context import extract_coverage_from_plugin

            cov_data = extract_coverage_from_plugin(item, tb, _plugin_warnings)
            if cov_data:
                pytest_context["coverage"] = cov_data
        except Exception as e:
            _plugin_warnings.append(f"coverage enrichment failed: {type(e).__name__}: {e}")

    if _plugin_warnings:
        pytest_context["_plugin_warnings"] = _plugin_warnings

    try:
        capture_exception(name, exc, tb, cfg, extra={"pytest": pytest_context})
    except Exception as capture_exc:
        if cfg.debug:
            sys.stderr.write(
                f"llmdebug: capture failed for {name}: "
                f"{type(capture_exc).__name__}: {capture_exc}\n"
            )


def _truncate_optional_text(value: object, max_len: int) -> str | None:
    if not isinstance(value, str):
        return None
    return truncate_str(value, max_len)
