"""pytest plugin for TraceCov API coverage reporting.

Override the ``tracecov_schema`` or ``tracecov_map`` session fixture in your
``conftest.py`` and TraceCov will print a coverage summary after the test run.
"""

from __future__ import annotations

import os
from typing import TypedDict, cast

import pytest

from tracecov import CoverageMap, HttpRequest, HttpResponse, terminal


class _InteractionEntry(TypedDict):
    method: str
    url: str
    body: bytes | None
    headers: dict[str, str]
    status_code: int | None
    elapsed: float | None
    timestamp: float

_TRACECOV_MAP_KEY: pytest.StashKey[CoverageMap] = pytest.StashKey()
_WORKER_SCHEMA_KEY: pytest.StashKey[dict[str, object]] = pytest.StashKey()
_WORKER_DATA_KEY: pytest.StashKey[list[list[_InteractionEntry]]] = pytest.StashKey()
_XDIST_MERGED_KEY: pytest.StashKey[bool] = pytest.StashKey()
_VALID_FORMATS = ("text", "html", "markdown")
_THRESHOLD_DIMENSIONS = ("operations", "parameters", "keywords", "examples", "responses")
_SHOW_MISSING_PARAMETERS = "parameters"
_DEFAULT_HTML_PATH = "tracecov-report.html"
_DEFAULT_MARKDOWN_PATH = "tracecov-report.md"


def pytest_addoption(parser: pytest.Parser) -> None:
    group = parser.getgroup("tracecov", "TraceCov coverage options")

    group.addoption(
        "--tracecov-format",
        default=None,
        metavar="FORMAT",
        help="Comma-separated output formats: text (terminal), html (file), markdown (file). Default: text",
    )
    group.addoption(
        "--tracecov-report-html-path",
        default=None,
        metavar="PATH",
        help=f"Path for the HTML coverage report (default: {_DEFAULT_HTML_PATH})",
    )
    group.addoption(
        "--tracecov-report-markdown-path",
        default=None,
        metavar="PATH",
        help=f"Path for the Markdown coverage report (default: {_DEFAULT_MARKDOWN_PATH})",
    )
    group.addoption(
        "--tracecov-skip-covered",
        action="store_true",
        default=None,
        help="Skip operations with 100%% coverage in the text report",
    )
    group.addoption(
        "--tracecov-skip-empty",
        action="store_true",
        default=None,
        help="Skip operations with no recorded traffic in the text report",
    )
    group.addoption(
        "--tracecov-show-missing",
        default=None,
        choices=[_SHOW_MISSING_PARAMETERS],
        metavar="",
        help=f"Show items with no coverage (choices: {_SHOW_MISSING_PARAMETERS})",
    )
    group.addoption(
        "--tracecov-no-report",
        action="store_true",
        default=None,
        help="Suppress tracecov report output (thresholds are still enforced)",
    )
    group.addoption(
        "--tracecov-width",
        type=int,
        default=None,
        metavar="N",
        help="Terminal width for the text report (default: auto-detect)",
    )
    group.addoption(
        "--tracecov-sub-text",
        default=None,
        metavar="TEXT",
        help="Subtitle shown after the coverage summary and in Markdown report footers",
    )
    group.addoption(
        "--tracecov-markdown-report-url",
        default=None,
        metavar="URL",
        help="URL of the full HTML report to link from the Markdown footer",
    )
    group.addoption(
        "--tracecov-markdown-weak-threshold",
        type=int,
        default=None,
        metavar="N",
        help=(
            "Parameter coverage percentage (0-100) below which an operation is flagged in the "
            "Markdown report (default: 100)"
        ),
    )
    group.addoption(
        "--tracecov-markdown-is-pull-request",
        action="store_true",
        default=None,
        help="Enable PR framing in the Markdown report headline",
    )
    group.addoption(
        "--tracecov-fail-under",
        type=int,
        default=None,
        metavar="N",
        help="Fail if any coverage metric is below this percentage (0-100)",
    )
    for _dim in _THRESHOLD_DIMENSIONS:
        group.addoption(
            f"--tracecov-fail-under-{_dim}",
            type=int,
            default=None,
            metavar="N",
            help=f"Fail if {_dim} coverage is below this percentage (0-100)",
        )

    parser.addini("tracecov_format", default="", help="Comma-separated formats: text, html, markdown")
    parser.addini("tracecov_report_html_path", default="", help="Path for the HTML report")
    parser.addini("tracecov_report_markdown_path", default="", help="Path for the Markdown report")
    parser.addini("tracecov_skip_covered", default="false", help="Skip 100%% covered operations")
    parser.addini("tracecov_skip_empty", default="false", help="Skip operations with no data")
    parser.addini("tracecov_show_missing", default="", help="Show missing coverage (parameters)")
    parser.addini(
        "tracecov_no_report", default="false", help="Suppress tracecov report output (thresholds are still enforced)"
    )
    parser.addini("tracecov_width", default="", help="Text report width override")
    parser.addini("tracecov_sub_text", default="", help="Subtitle for summary and Markdown footers")
    parser.addini("tracecov_markdown_report_url", default="", help="URL to link from Markdown footer")
    parser.addini("tracecov_markdown_weak_threshold", default="", help="Markdown weak threshold (0-100)")
    parser.addini("tracecov_markdown_is_pull_request", default="false", help="PR framing in Markdown")
    parser.addini("tracecov_fail_under", default="", help="Global coverage threshold (0-100)")
    for _dim in _THRESHOLD_DIMENSIONS:
        parser.addini(f"tracecov_fail_under_{_dim}", default="", help=f"{_dim.capitalize()} coverage threshold (0-100)")


class _XDistPlugin:
    """Registered only when pytest-xdist is present; holds xdist-specific hooks."""

    def pytest_testnodedown(self, node: object, error: object) -> None:
        workeroutput = getattr(node, "workeroutput", {})
        interactions = workeroutput.get("tracecov_interactions")
        schema = workeroutput.get("tracecov_schema")
        if interactions is not None and schema is not None:
            config = node.config  # type: ignore[attr-defined]
            config.stash[_WORKER_DATA_KEY].append(cast(list[_InteractionEntry], interactions))  # type: ignore[attr-defined]
            config.stash[_WORKER_SCHEMA_KEY] = cast(dict[str, object], schema)  # type: ignore[attr-defined]


def pytest_configure(config: pytest.Config) -> None:
    config.stash[_WORKER_DATA_KEY] = []
    if hasattr(config, "workerinput"):
        os.environ["_TRACECOV_XDIST"] = "1"
    if config.pluginmanager.hasplugin("xdist"):
        config.pluginmanager.register(_XDistPlugin(), "tracecov-xdist")

    raw = _opt(config, "--tracecov-format", "tracecov_format")
    if raw:
        try:
            _parse_formats(raw)
        except ValueError as exc:
            raise pytest.UsageError(str(exc)) from None

    width_raw = _opt(config, "--tracecov-width", "tracecov_width")
    if width_raw is not None:
        try:
            val = int(width_raw)
            if val <= 0:
                raise ValueError
        except ValueError:
            raise pytest.UsageError(f"--tracecov-width must be a positive integer, got: {width_raw!r}") from None

    _int_options: list[tuple[str, str, str]] = [
        (
            "--tracecov-markdown-weak-threshold",
            "tracecov_markdown_weak_threshold",
            "--tracecov-markdown-weak-threshold must be an integer in [0, 100] (changed from 0.0-1.0 to 0-100)",
        ),
        ("--tracecov-fail-under", "tracecov_fail_under", "--tracecov-fail-under must be an integer in [0, 100]"),
    ]
    for _dim in _THRESHOLD_DIMENSIONS:
        _int_options.append(
            (
                f"--tracecov-fail-under-{_dim}",
                f"tracecov_fail_under_{_dim}",
                f"--tracecov-fail-under-{_dim} must be an integer in [0, 100]",
            )
        )
    for _cli, _ini, _msg in _int_options:
        _raw = _opt(config, _cli, _ini)
        if _raw is not None:
            try:
                _val = int(_raw)
                if not (0 <= _val <= 100):
                    raise ValueError
            except ValueError:
                raise pytest.UsageError(f"{_msg}, got: {_raw!r}") from None


def _opt(config: pytest.Config, cli: str, ini: str) -> str | None:
    val = config.getoption(cli, default=None)
    if val is not None:
        return str(val)
    ini_val = config.getini(ini)
    return str(ini_val) if ini_val else None


def _flag(config: pytest.Config, cli: str, ini: str) -> bool:
    if config.getoption(cli, default=None) is True:
        return True
    return str(config.getini(ini)).lower() in ("true", "1", "yes")


def _parse_formats(raw: str) -> tuple[str, ...]:
    parts = [p.strip() for p in raw.replace(",", " ").split() if p.strip()]
    invalid = [p for p in parts if p not in _VALID_FORMATS]
    if invalid:
        raise ValueError(f"Invalid tracecov format(s): {invalid!r}. Choose from: {', '.join(_VALID_FORMATS)}")
    deduped = tuple(dict.fromkeys(parts))
    return deduped if deduped else ("text",)


def _get_formats(config: pytest.Config) -> tuple[str, ...]:
    raw = _opt(config, "--tracecov-format", "tracecov_format")
    return _parse_formats(raw) if raw else ("text",)


def _resolve_thresholds(config: pytest.Config) -> dict[str, float | None]:
    global_raw = _opt(config, "--tracecov-fail-under", "tracecov_fail_under")
    global_val = int(global_raw) if global_raw is not None else None
    result: dict[str, float | None] = {}
    for dim in _THRESHOLD_DIMENSIONS:
        per_raw = _opt(config, f"--tracecov-fail-under-{dim}", f"tracecov_fail_under_{dim}")
        per_dim = int(per_raw) if per_raw is not None else None
        combined = per_dim if per_dim is not None else global_val
        result[dim] = float(combined) if combined is not None else None
    return result


def _is_colored(config: pytest.Config) -> bool:
    return getattr(config.option, "color", "auto") != "no"


def _merge_xdist_data(config: pytest.Config) -> None:
    if config.stash.get(_XDIST_MERGED_KEY, False):
        return
    schema = config.stash.get(_WORKER_SCHEMA_KEY, None)
    if schema is None:
        return
    config.stash[_XDIST_MERGED_KEY] = True
    assert config.stash.get(_TRACECOV_MAP_KEY, None) is None, (
        "tracecov: controller already has a coverage map before xdist merge"
    )

    coverage = CoverageMap.from_dict(schema)
    for interactions in config.stash.get(_WORKER_DATA_KEY, []):
        for entry in interactions:
            request = HttpRequest(
                method=entry["method"],
                url=entry["url"],
                body=entry["body"],
                headers=entry["headers"],
            )
            status_code = entry["status_code"]
            elapsed = entry["elapsed"]
            response = (
                HttpResponse(status_code=status_code, elapsed=elapsed)
                if status_code is not None and elapsed is not None
                else None
            )
            coverage.record(request, response, entry["timestamp"])

    config.stash[_TRACECOV_MAP_KEY] = coverage


@pytest.fixture(scope="session")  # type: ignore[untyped-decorator]
def tracecov_schema() -> dict[str, object] | None:
    """Return the OpenAPI schema dict used to build the coverage map.

    Override this fixture in your ``conftest.py`` to activate TraceCov::

        @pytest.fixture(scope="session")
        def tracecov_schema():
            return json.loads(Path("openapi.json").read_text())

    Return ``None`` (default) to keep TraceCov dormant.
    """
    return None


@pytest.fixture(scope="session")  # type: ignore[untyped-decorator]
def tracecov_map(tracecov_schema: dict[str, object] | None) -> CoverageMap | None:
    """Return the session-wide :class:`~tracecov.CoverageMap`, or ``None`` if inactive.

    Built automatically from :func:`tracecov_schema`.  Override this fixture
    directly only when you need a :class:`~tracecov.CoverageMap` constructed
    through a different code path (e.g. from a non-dict source).

    The override **must** use ``scope="session"``; a narrower scope will produce incorrect results.
    """
    if tracecov_schema is None:
        return None
    return CoverageMap.from_dict(tracecov_schema)


@pytest.fixture(autouse=True)  # type: ignore[untyped-decorator]
def _tracecov_bridge(tracecov_map: CoverageMap | None, pytestconfig: pytest.Config) -> None:
    if tracecov_map is not None:
        pytestconfig.stash[_TRACECOV_MAP_KEY] = tracecov_map


def pytest_terminal_summary(
    terminalreporter: pytest.TerminalReporter,
    exitstatus: int,
    config: pytest.Config,
) -> None:
    _merge_xdist_data(config)
    coverage = config.stash.get(_TRACECOV_MAP_KEY, None)
    if coverage is None:
        return

    if not _flag(config, "--tracecov-no-report", "tracecov_no_report"):
        formats = _get_formats(config)
        sub_text = _opt(config, "--tracecov-sub-text", "tracecov_sub_text")

        if "text" in formats:
            width_raw = _opt(config, "--tracecov-width", "tracecov_width")
            width = int(width_raw) if width_raw else terminal.get_width()

            skip_covered = _flag(config, "--tracecov-skip-covered", "tracecov_skip_covered")
            skip_empty = _flag(config, "--tracecov-skip-empty", "tracecov_skip_empty")
            show_missing_raw = _opt(config, "--tracecov-show-missing", "tracecov_show_missing")
            show_missing: list[str] | None = ["parameter"] if show_missing_raw == _SHOW_MISSING_PARAMETERS else None

            report = coverage.generate_text_report(
                width=width,
                colored=_is_colored(config),
                skip_covered=skip_covered,
                skip_empty=skip_empty,
                show_missing=show_missing,
            )
            terminalreporter.write_sep("=", "tracecov summary")
            terminalreporter.write_line(report)
            if sub_text:
                terminalreporter.write_line(sub_text)

    resolved = _resolve_thresholds(config)
    violations = coverage.check_thresholds(
        operations=resolved["operations"],
        parameters=resolved["parameters"],
        keywords=resolved["keywords"],
        examples=resolved["examples"],
        responses=resolved["responses"],
    )
    if violations:
        terminalreporter.write_sep("-", "tracecov threshold failures")
        for v in violations:
            terminalreporter.write_line(
                f"{v['dimension'].capitalize():<16} {v['actual']:.1f}% / {v['minimum']:.0f}% required"
            )


def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None:
    config = session.config

    if hasattr(config, "workerinput"):
        coverage = config.stash.get(_TRACECOV_MAP_KEY, None)
        if coverage is not None:
            # workeroutput is dynamically added by xdist; not in pytest's type stubs.
            config.workeroutput["tracecov_interactions"] = coverage.drain_interactions()  # type: ignore[attr-defined]
            config.workeroutput["tracecov_schema"] = coverage.get_schema()  # type: ignore[attr-defined]
        return

    _merge_xdist_data(config)
    coverage = config.stash.get(_TRACECOV_MAP_KEY, None)
    if coverage is None:
        return

    if not _flag(config, "--tracecov-no-report", "tracecov_no_report"):
        formats = _get_formats(config)
        sub_text = _opt(config, "--tracecov-sub-text", "tracecov_sub_text")

        if "html" in formats:
            path = _opt(config, "--tracecov-report-html-path", "tracecov_report_html_path") or _DEFAULT_HTML_PATH
            coverage.save_report(output_file=path, format="html")

        if "markdown" in formats:
            path = (
                _opt(config, "--tracecov-report-markdown-path", "tracecov_report_markdown_path")
                or _DEFAULT_MARKDOWN_PATH
            )
            report_url = _opt(config, "--tracecov-markdown-report-url", "tracecov_markdown_report_url")
            weak_raw = _opt(config, "--tracecov-markdown-weak-threshold", "tracecov_markdown_weak_threshold")
            weak_threshold = int(weak_raw) / 100.0 if weak_raw else None
            is_pr = _flag(config, "--tracecov-markdown-is-pull-request", "tracecov_markdown_is_pull_request")
            content = coverage.generate_markdown_report(
                report_url=report_url,
                weak_threshold=weak_threshold,
                is_pull_request=is_pr,
                sub_text=sub_text,
            )
            with open(path, "w", encoding="utf-8") as fh:
                fh.write(content)

    resolved = _resolve_thresholds(config)
    violations = coverage.check_thresholds(
        operations=resolved["operations"],
        parameters=resolved["parameters"],
        keywords=resolved["keywords"],
        examples=resolved["examples"],
        responses=resolved["responses"],
    )
    if violations:
        session.exitstatus = pytest.ExitCode.TESTS_FAILED
