"""Run the canonical repository quality gate."""

from __future__ import annotations

import argparse
import os
import shutil
import subprocess
import sys
from pathlib import Path

import tomllib
from packaging.specifiers import SpecifierSet
from packaging.version import Version

ROOT = Path(__file__).resolve().parents[1]
PYTHON_TEXT_PATHS = [
    "mjr_am_backend",
    "mjr_am_shared",
    "tests",
    "scripts",
    "__init__.py",
    "pyproject.toml",
    "mypy.ini",
    "pytest.ini",
    "tox.ini",
    ".pre-commit-config.yaml",
]
DOC_TEXT_PATHS = [
    "README.md",
    "CHANGELOG.md",
    "docs",
    ".github",
]
PYTHON_COMPLEXITY_PATHS = [
    "mjr_am_backend",
    "mjr_am_shared",
    "tests",
    "__init__.py",
]
RUFF_TARGETS = ["mjr_am_backend", "mjr_am_shared", "tests", "scripts", "__init__.py"]
BANDIT_TARGETS = ["mjr_am_backend"]
DEFAULT_PYTEST_ARGS = [
    "-q",
    "--cov=mjr_am_backend",
    "--cov=mjr_am_shared",
    "--cov-branch",
    "--cov-fail-under=68",
    "--cov-report=xml",
    "--cov-report=term-missing:skip-covered",
]
PIP_AUDIT_REQUIREMENTS = ["requirements.txt"]


def _parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Run lint, typecheck, security, complexity, and test gates.")
    parser.add_argument("--skip-tests", action="store_true", help="Skip backend pytest execution.")
    parser.add_argument("--skip-js-tests", action="store_true", help="Skip frontend vitest execution.")
    parser.add_argument("--skip-js-lint", action="store_true", help="Skip ESLint and Prettier checks.")
    parser.add_argument("--skip-js-audit", action="store_true", help="Skip npm audit.")
    parser.add_argument("--skip-pip-audit", action="store_true", help="Skip pip-audit.")
    parser.add_argument("--skip-bandit", action="store_true", help="Skip bandit.")
    parser.add_argument("--skip-mypy", action="store_true", help="Skip mypy.")
    parser.add_argument("--skip-ruff", action="store_true", help="Skip ruff.")
    parser.add_argument("--skip-complexity", action="store_true", help="Skip xenon/radon checks.")
    parser.add_argument(
        "--python-only",
        action="store_true",
        help="Shortcut for skipping JS tests and npm audit.",
    )
    parser.add_argument(
        "--changed-only-complexity",
        action="store_true",
        help="Run the stricter changed-file complexity guard in addition to repo thresholds.",
    )
    parser.add_argument(
        "--pytest-args",
        nargs=argparse.REMAINDER,
        default=None,
        help="Additional args passed through to pytest after '--pytest-args'.",
    )
    return parser.parse_args()


def _git_output(cmd: list[str]) -> str:
    return subprocess.check_output(cmd, cwd=ROOT, text=True, stderr=subprocess.DEVNULL).strip()


def _changed_python_files() -> list[str]:
    diff = ""
    base_ref = os.environ.get("GITHUB_BASE_REF", "").strip()
    try:
        if base_ref:
            diff = _git_output(["git", "diff", "--name-only", f"origin/{base_ref}...HEAD"])
        else:
            diff = _git_output(["git", "diff", "--name-only", "HEAD~1...HEAD"])
    except Exception:
        diff = ""

    changed: list[str] = []
    for raw in diff.splitlines():
        candidate = raw.strip()
        if not candidate:
            continue
        if " -> " in candidate:
            candidate = candidate.split(" -> ", 1)[1].strip()
        if not candidate.endswith(".py"):
            continue
        path = ROOT / candidate
        if path.exists():
            changed.append(candidate)
    return sorted(dict.fromkeys(changed))


def _run(cmd: list[str], *, label: str, env: dict[str, str] | None = None) -> None:
    print(f"\n==> {label}")
    print(" ".join(cmd))
    subprocess.run(cmd, cwd=ROOT, check=True, env=env)


def _python_cmd(module: str, *args: str) -> list[str]:
    return [sys.executable, "-m", module, *args]


def _npm_cmd(*args: str) -> list[str]:
    candidates = ["npm.cmd", "npm"] if os.name == "nt" else ["npm"]
    for candidate in candidates:
        if shutil.which(candidate):
            return [candidate, *args]
    raise SystemExit("npm is required for JS checks but was not found in PATH.")


def _existing_paths(values: list[str]) -> list[str]:
    existing: list[str] = []
    for value in values:
        if (ROOT / value).exists():
            existing.append(value)
    return existing


def _pip_audit_cmd() -> list[str]:
    requirement_files = _existing_paths(PIP_AUDIT_REQUIREMENTS)
    cmd = _python_cmd("pip_audit", "--strict", "--skip-editable")
    if requirement_files:
        for requirement_file in requirement_files:
            cmd.extend(["-r", requirement_file])
        return cmd
    return cmd


def _npm_available() -> bool:
    return any(shutil.which(candidate) for candidate in (("npm.cmd", "npm") if os.name == "nt" else ("npm",)))


def _current_python_supported_by_project() -> bool:
    pyproject_path = ROOT / "pyproject.toml"
    if not pyproject_path.exists():
        return True
    try:
        data = tomllib.loads(pyproject_path.read_text(encoding="utf-8"))
    except Exception:
        return True

    requires_python = ((data.get("project") or {}).get("requires-python") or "").strip()
    if not requires_python:
        return True

    try:
        supported_versions = SpecifierSet(requires_python)
        current_version = Version(f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}")
    except Exception:
        return True
    return current_version in supported_versions


def _clear_mypy_cache() -> None:
    cache_dir = ROOT / ".mypy_cache"
    if not cache_dir.exists():
        return
    shutil.rmtree(cache_dir, ignore_errors=True)


def main() -> int:
    args = _parse_args()
    if args.python_only:
        args.skip_js_tests = True
        args.skip_js_lint = True
        args.skip_js_audit = True

    text_paths = _existing_paths(PYTHON_TEXT_PATHS + DOC_TEXT_PATHS)
    complexity_paths = _existing_paths(PYTHON_COMPLEXITY_PATHS)
    bandit_targets = _existing_paths(BANDIT_TARGETS)
    changed_python = _changed_python_files()

    _run([sys.executable, "scripts/check_no_bom.py", *text_paths], label="UTF-8 and BOM hygiene")

    if not args.skip_ruff:
        if changed_python:
            changed_python_existing = _existing_paths(changed_python)
            if not changed_python_existing:
                print("\n==> Ruff lint")
                print("No existing changed Python files detected; skipping incremental Ruff gate.")
            else:
                _run(
                    _python_cmd("ruff", "check", *changed_python_existing),
                    label="Ruff lint (changed Python files)",
                )
        else:
            print("\n==> Ruff lint")
            print("No changed Python files detected; skipping incremental Ruff gate.")

    if not args.skip_mypy:
        _clear_mypy_cache()
        _run(_python_cmd("mypy", "--config-file", "mypy.ini"), label="Mypy")

    if not args.skip_bandit:
        _run(_python_cmd("bandit", "-r", *bandit_targets, "-ll", "-ii", "-x", "tests"), label="Bandit")

    if not args.skip_pip_audit:
        if _current_python_supported_by_project():
            _run(
                _pip_audit_cmd(),
                label="pip-audit",
            )
        else:
            print("\n==> pip-audit")
            print(
                "Skipping pip-audit because the active Python interpreter is outside the supported "
                "range declared in pyproject.toml."
            )

    if not args.skip_complexity:
        _run(
            _python_cmd("xenon", "-b", "D", "-m", "D", "-a", "B", *complexity_paths),
            label="Xenon repository complexity gate",
        )
        _run(
            [sys.executable, "scripts/check_cc_changed.py"],
            label="Changed-file complexity gate",
            env={**os.environ, "MJR_COMPLEXITY_MAX_CC": "25"},
        )

    if not args.skip_tests:
        pytest_args = args.pytest_args if args.pytest_args else DEFAULT_PYTEST_ARGS
        _run(_python_cmd("pytest", *pytest_args), label="Pytest")

    if not args.skip_js_lint:
        if not _npm_available():
            raise SystemExit("npm is required for JS lint but was not found in PATH.")
        _run(_npm_cmd("run", "lint:js"), label="ESLint")
        _run(_npm_cmd("run", "format:check"), label="Prettier")

    if not args.skip_js_tests:
        if not _npm_available():
            raise SystemExit("npm is required for JS tests but was not found in PATH.")
        _run(_npm_cmd("run", "test:js"), label="Vitest")

    if not args.skip_js_audit:
        if not _npm_available():
            raise SystemExit("npm is required for npm audit but was not found in PATH.")
        _run(_npm_cmd("audit", "--audit-level=high"), label="npm audit")

    print("\nQuality gate passed.")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
