"""VIP pytest plugin.

Registered via the ``pytest11`` entry point so it activates automatically
when the ``vip`` package is installed.

Responsibilities:
- Register custom markers.
- Add CLI options (``--vip-config``, ``--vip-extensions``, ``--vip-report``,
  ``--interactive-auth``).
- Auto-skip tests whose product is not configured.
- Auto-skip tests whose product version doesn't meet ``min_version``.
- Ensure prerequisites run before other tests.
- Collect extension directories.
- Write a JSON results file for the Quarto report.
- Handle interactive OIDC authentication for external identity providers.
"""

from __future__ import annotations

import json
import re
import sys
import warnings
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

import pytest

from vip.config import VIPConfig, load_config

# ---------------------------------------------------------------------------
# Stash keys
# ---------------------------------------------------------------------------

_vip_config_key = pytest.StashKey[VIPConfig]()
_ext_dirs_key = pytest.StashKey[list[str]]()
_results_key = pytest.StashKey[list[dict[str, Any]]]()
_auth_session_key = pytest.StashKey[Any]()

# Module-level reference to the active pytest.Config, set in pytest_configure.
# Safe because pytester runs in a subprocess (fresh import each time).
_active_config: pytest.Config | None = None

# Mapping from pytest marker name to product config key.
_PRODUCT_MARKERS = {
    "connect": "connect",
    "workbench": "workbench",
    "package_manager": "package_manager",
}

# ---------------------------------------------------------------------------
# Plugin hooks
# ---------------------------------------------------------------------------


def pytest_addoption(parser: pytest.Parser) -> None:
    group = parser.getgroup("vip", "Verified Installation of Posit")
    group.addoption(
        "--vip-config",
        default=None,
        help="Path to vip.toml configuration file.",
    )
    group.addoption(
        "--vip-extensions",
        action="append",
        default=[],
        help="Additional directories containing custom VIP test cases (repeatable).",
    )
    group.addoption(
        "--vip-report",
        default="report/results.json",
        help="Write a JSON results file at this path for Quarto report generation."
        " Set to empty string to disable. (default: report/results.json)",
    )
    group.addoption(
        "--interactive-auth",
        action="store_true",
        default=False,
        help="Launch a browser for manual OIDC login before running tests.",
    )


def pytest_configure(config: pytest.Config) -> None:
    global _active_config
    _active_config = config

    # Register markers
    config.addinivalue_line("markers", "connect: tests for Posit Connect")
    config.addinivalue_line("markers", "workbench: tests for Posit Workbench")
    config.addinivalue_line("markers", "package_manager: tests for Posit Package Manager")
    config.addinivalue_line("markers", "prerequisites: prerequisite checks")
    config.addinivalue_line("markers", "cross_product: cross-product / admin tests")
    config.addinivalue_line("markers", "performance: performance validation tests")
    config.addinivalue_line("markers", "security: security validation tests")
    config.addinivalue_line(
        "markers",
        "min_version(product, version): skip when product is below the specified version",
    )
    config.addinivalue_line(
        "markers",
        "if_applicable: skip when the related feature is not configured",
    )

    # Load VIP config and stash it for fixtures / collection hooks.
    vip_cfg = load_config(config.getoption("--vip-config"))
    config.stash[_vip_config_key] = vip_cfg

    # Initialize per-session results list (avoids module-level global).
    config.stash[_results_key] = []

    # Merge extension dirs from config file and CLI.
    ext_dirs: list[str] = list(vip_cfg.extension_dirs)
    ext_dirs.extend(config.getoption("--vip-extensions") or [])
    config.stash[_ext_dirs_key] = ext_dirs

    # Handle interactive auth — login via browser, then close before tests.
    # With pytest-xdist the browser auth happens once in the controller;
    # credentials are forwarded to workers via pytest_configure_node.
    config.stash[_auth_session_key] = None

    if hasattr(config, "workerinput") and config.workerinput.get("vip_interactive_auth"):
        # xdist worker — restore auth data shared by the controller.
        _restore_worker_auth(config, vip_cfg)
    elif config.getoption("--interactive-auth"):
        connect_url = vip_cfg.connect.url if vip_cfg.connect.is_configured else None
        wb_url = vip_cfg.workbench.url if vip_cfg.workbench.is_configured else None

        if not connect_url and not wb_url:
            raise pytest.UsageError(
                "--interactive-auth requires at least one product URL (Connect or Workbench)"
            )

        from pathlib import Path

        from vip.auth import start_interactive_auth

        cache_path = Path(config.rootpath) / ".vip-auth-cache.json"
        session = start_interactive_auth(
            connect_url=connect_url, workbench_url=wb_url, cache_path=cache_path
        )
        config.stash[_auth_session_key] = session
        if session.api_key:
            vip_cfg.connect.api_key = session.api_key
        elif connect_url:
            warnings.warn(
                "VIP: --interactive-auth could not mint an API key. "
                "API-based tests will likely fail. Set VIP_CONNECT_API_KEY to fix.",
                stacklevel=1,
            )


def _restore_worker_auth(config: pytest.Config, vip_cfg: VIPConfig) -> None:
    """Reconstruct an auth session in an xdist worker from controller data."""
    from vip.auth import InteractiveAuthSession

    wi = config.workerinput  # type: ignore[attr-defined]  # xdist injects this
    api_key = wi.get("vip_api_key") or None
    storage_state = wi.get("vip_storage_state", "")

    if api_key:
        vip_cfg.connect.api_key = api_key

    session = InteractiveAuthSession(
        storage_state_path=Path(storage_state) if storage_state else Path("/dev/null"),
        api_key=api_key,
        key_name=wi.get("vip_key_name", ""),
        _connect_url=wi.get("vip_connect_url", ""),
        _tmpdir="",  # Workers don't own the temp dir; controller cleans up.
    )
    config.stash[_auth_session_key] = session


def pytest_configure_node(node) -> None:
    """xdist controller hook: share interactive-auth credentials with workers."""
    auth = node.config.stash.get(_auth_session_key, None)
    if auth is not None:
        node.workerinput["vip_interactive_auth"] = True
        node.workerinput["vip_api_key"] = auth.api_key or ""
        node.workerinput["vip_storage_state"] = str(auth.storage_state_path)
        node.workerinput["vip_key_name"] = auth.key_name
        node.workerinput["vip_connect_url"] = auth._connect_url


def pytest_sessionstart(session: pytest.Session) -> None:
    """Add extension directories to sys.path so their conftest / modules
    are importable, and register them for collection."""
    ext_dirs = session.config.stash.get(_ext_dirs_key, [])
    for d in ext_dirs:
        p = Path(d).resolve()
        if p.is_dir():
            str_p = str(p)
            if str_p not in sys.path:
                sys.path.insert(0, str_p)
            # Tell pytest to collect from this directory as well.
            session.config.args.append(str_p)


def pytest_collection_modifyitems(
    config: pytest.Config,
    items: list[pytest.Item],
) -> None:
    """Skip tests whose product is not configured or whose version
    requirement is not met, and ensure prerequisites run first."""
    vip_cfg: VIPConfig = config.stash[_vip_config_key]
    using_interactive_auth = config.stash.get(_auth_session_key, None) is not None

    # Sort so prerequisites run before everything else, and assign xdist
    # groups so that each product's tests land on a dedicated worker.
    prerequisites: list[pytest.Item] = []
    rest: list[pytest.Item] = []
    for item in items:
        _maybe_skip_for_product(item, vip_cfg)
        _maybe_skip_for_version(item, vip_cfg)
        _maybe_skip_credential_check(item, using_interactive_auth)
        _assign_xdist_group(item)
        _stash_scenario_metadata(item)
        if item.get_closest_marker("prerequisites"):
            prerequisites.append(item)
        else:
            rest.append(item)
    items[:] = prerequisites + rest


# Directories whose tests get a dedicated xdist worker.
_PRODUCT_DIRS = {"connect", "workbench", "package_manager"}


def _assign_xdist_group(item: pytest.Item) -> None:
    """Assign an ``xdist_group`` marker so each product runs on its own worker.

    Tests under ``tests/connect/``, ``tests/workbench/``, or
    ``tests/package_manager/`` are grouped by directory name.  Everything
    else (prerequisites, cross_product, performance, security) lands in a
    shared ``general`` group.
    """
    fspath = getattr(item, "path", None) or Path()
    dir_name = fspath.parent.name
    group = dir_name if dir_name in _PRODUCT_DIRS else "general"
    item.add_marker(pytest.mark.xdist_group(group))


def _maybe_skip_credential_check(item: pytest.Item, using_interactive_auth: bool) -> None:
    """Skip the credential prerequisite test when using interactive auth."""
    if not using_interactive_auth:
        return
    # Match precisely to avoid skipping unrelated tests with similar names.
    fspath = getattr(item, "path", None) or Path()
    if (
        fspath.name == "test_auth_configured.py"
        and fspath.parent.name == "prerequisites"
        and item.name == "test_credentials_provided"
    ):
        item.add_marker(
            pytest.mark.skip(reason="--interactive-auth is active, credential check not needed")
        )


def _maybe_skip_for_product(item: pytest.Item, cfg: VIPConfig) -> None:
    for marker_name, product_key in _PRODUCT_MARKERS.items():
        marker = item.get_closest_marker(marker_name)
        if marker is not None:
            pc = cfg.product_config(product_key)
            if not pc.is_configured:
                item.add_marker(
                    pytest.mark.skip(
                        reason=f"{product_key} is not configured (set url in vip.toml)"
                    )
                )


def _maybe_skip_for_version(item: pytest.Item, cfg: VIPConfig) -> None:
    marker = item.get_closest_marker("min_version")
    if marker is None:
        return

    product = marker.kwargs.get("product") or (marker.args[0] if marker.args else None)
    version = marker.kwargs.get("version") or (marker.args[1] if len(marker.args) > 1 else None)
    if not product or not version:
        return

    try:
        pc = cfg.product_config(product)
    except ValueError:
        return

    if pc.version is None:
        # Version unknown - run the test optimistically.
        return

    if _version_tuple(pc.version) < _version_tuple(version):
        item.add_marker(
            pytest.mark.skip(reason=f"{product} version {pc.version} < required {version}")
        )


def _version_tuple(v: str) -> tuple[int, ...]:
    """Parse a dotted version string into a comparable tuple."""
    return tuple(int(m.group()) if (m := re.match(r"\d+", seg)) else 0 for seg in v.split("."))


# ---------------------------------------------------------------------------
# JSON results for Quarto report
# ---------------------------------------------------------------------------


_scenario_stash_key = pytest.StashKey[dict[str, str | None]]()


def _stash_scenario_metadata(item: pytest.Item) -> None:
    """Extract pytest-bdd scenario metadata and stash it on the item."""
    scenario_title = None
    feature_description = None

    # pytest-bdd stores scenario info as __scenario__ on the wrapper function.
    fn = getattr(item, "obj", None)
    scenario_obj = getattr(fn, "__scenario__", None) if fn else None
    if scenario_obj is not None:
        scenario_title = getattr(scenario_obj, "name", None)
        feature_obj = getattr(scenario_obj, "feature", None)
        if feature_obj is not None:
            feature_description = getattr(feature_obj, "description", None)

    item.stash[_scenario_stash_key] = {
        "scenario_title": scenario_title,
        "feature_description": feature_description,
    }


@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item: pytest.Item, call):  # noqa: ARG001
    outcome = yield
    report: pytest.TestReport = outcome.get_result()
    if report.when == "call" or (report.when == "setup" and report.skipped):
        if _active_config is None:
            return
        results = _active_config.stash.get(_results_key, None)
        if results is None:
            return
        markers: list[str] = []
        scenario_meta: dict[str, str | None] = {}
        try:
            markers = [m.name for m in item.iter_markers()]
        except Exception:
            pass
        item_stash = getattr(item, "stash", None)
        if item_stash is not None:
            scenario_meta = item_stash.get(_scenario_stash_key, {})
        results.append(
            {
                "nodeid": report.nodeid,
                "outcome": report.outcome,
                "duration": report.duration,
                "longrepr": str(report.longrepr) if report.longrepr else None,
                "markers": markers,
                "scenario_title": scenario_meta.get("scenario_title"),
                "feature_description": scenario_meta.get("feature_description"),
            }
        )


def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None:
    # xdist workers skip all session-end cleanup (controller handles it).
    is_worker = hasattr(session.config, "workerinput")

    if not is_worker:
        # Clean up interactive auth session (delete API key, remove temp files)
        auth_session = session.config.stash.get(_auth_session_key, None)
        if auth_session is not None:
            auth_session.cleanup()

    report_path = session.config.getoption("--vip-report")
    if not report_path or is_worker:
        return

    cfg: VIPConfig = session.config.stash[_vip_config_key]
    results = session.config.stash.get(_results_key, [])

    # Include product metadata for the report.
    # Derive product list from _PRODUCT_MARKERS so new products are picked up automatically.
    products: dict[str, dict[str, Any]] = {}
    for name in _PRODUCT_MARKERS.values():
        pc = cfg.product_config(name)
        products[name] = {
            "enabled": pc.enabled,
            "url": pc.url,
            "version": pc.version,
            "configured": pc.is_configured,
        }

    payload = {
        "generated_at": datetime.now(timezone.utc).isoformat(),
        "deployment_name": cfg.deployment_name,
        "exit_status": exitstatus,
        "products": products,
        "results": results,
    }

    try:
        p = Path(report_path)
        p.parent.mkdir(parents=True, exist_ok=True)
        p.write_text(json.dumps(payload, indent=2))
    except OSError as exc:
        warnings.warn(f"VIP: could not write report to {report_path}: {exc}", stacklevel=1)
        return

    # Write failures.json alongside results.json so report rendering is idempotent.
    failures = [r for r in results if r.get("outcome") == "failed"]
    if failures:
        failures_payload = {
            "deployment": cfg.deployment_name,
            "generated_at": payload["generated_at"],
            "failures": [
                {
                    "test": r["nodeid"],
                    "scenario": r.get("scenario_title"),
                    "feature": r.get("feature_description"),
                    "error_summary": (r.get("longrepr") or "")[:500],
                }
                for r in failures
            ],
        }
        failures_path = p.parent / "failures.json"
        try:
            failures_path.write_text(json.dumps(failures_payload, indent=2) + "\n")
        except OSError as exc:
            warnings.warn(
                f"VIP: could not write failures report to {failures_path}: {exc}", stacklevel=1
            )
