"""Tribunal CI Runner — standalone quality gate for CI/CD pipelines.

Runs all Tribunal quality checks WITHOUT Claude Code being present.
Exit codes:
  0 — all checks passed
  1 — one or more failures
  2 — warnings only (no hard failures)
"""

from __future__ import annotations

import json
import subprocess
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Sequence


@dataclass
class FileResult:
    """Quality check result for a single file."""

    file: str
    secrets: str = "pass"
    tests: str = "pass"
    overall: str = "pass"
    details: list[str] = field(default_factory=list)


@dataclass
class CIReport:
    """Aggregated CI report across all checked files."""

    files: list[FileResult] = field(default_factory=list)
    total: int = 0
    passed: int = 0
    failed: int = 0
    warned: int = 0
    coverage_pct: float | None = None
    coverage_passed: bool = True

    def to_dict(self) -> dict:
        """Convert to JSON-serialisable dict."""
        return {
            "total": self.total,
            "passed": self.passed,
            "failed": self.failed,
            "warned": self.warned,
            "coverage_pct": self.coverage_pct,
            "coverage_passed": self.coverage_passed,
            "files": [
                {
                    "file": r.file,
                    "secrets": r.secrets,
                    "tests": r.tests,
                    "overall": r.overall,
                    "details": r.details,
                }
                for r in self.files
            ],
        }


# ---------------------------------------------------------------------------
# File discovery
# ---------------------------------------------------------------------------


def discover_changed_files(repo_root: Path | None = None) -> list[Path]:
    """Return files changed in the last commit, falling back to all tracked files.

    Falls back to listing all Git-tracked files when not inside a Git repo or
    when HEAD~1 is unavailable (e.g. the very first commit).
    """
    cwd = repo_root or Path.cwd()
    try:
        result = subprocess.run(
            ["git", "diff", "--name-only", "HEAD~1", "HEAD"],
            capture_output=True,
            text=True,
            cwd=cwd,
            timeout=15,
        )
        if result.returncode == 0 and result.stdout.strip():
            return [cwd / p for p in result.stdout.strip().splitlines() if p.strip()]

        # Fallback: all tracked files
        result2 = subprocess.run(
            ["git", "ls-files"],
            capture_output=True,
            text=True,
            cwd=cwd,
            timeout=15,
        )
        if result2.returncode == 0 and result2.stdout.strip():
            return [cwd / p for p in result2.stdout.strip().splitlines() if p.strip()]
    except (subprocess.TimeoutExpired, FileNotFoundError):
        pass

    # Non-git project: walk the directory
    return list(cwd.rglob("*.*"))


# ---------------------------------------------------------------------------
# Individual checks
# ---------------------------------------------------------------------------


def _hook_dir() -> Path:
    """Locate the tribunal/hooks directory relative to this file."""
    # When installed from the package, hooks live at:
    #   <repo>/tribunal/hooks/
    # This file lives at:
    #   <repo>/launcher/ci_runner.py
    here = Path(__file__).parent
    candidate = here.parent / "tribunal" / "hooks"
    if candidate.is_dir():
        return candidate
    # Installed as a package — hooks may be beside the launcher package
    return candidate  # best-effort; callers handle missing files


def run_secrets_check(file_path: Path) -> tuple[str, list[str]]:
    """Run the secrets checker on a single file.

    Returns (status, details) where status is 'pass' or 'fail'.
    """
    hook_dir = _hook_dir()
    checker = hook_dir / "_checkers" / "secrets.py"
    if not checker.exists():
        return "skip", ["secrets checker not found"]

    try:
        # Import directly to avoid subprocess overhead where possible
        sys.path.insert(0, str(hook_dir))
        from _checkers.secrets import check_secrets  # type: ignore[import]

        _, reason = check_secrets(file_path)
        if reason:
            return "fail", [reason]
        return "pass", []
    except Exception as exc:  # noqa: BLE001
        return "skip", [f"secrets check error: {exc}"]
    finally:
        # Remove inserted path if it was added by us
        if str(hook_dir) in sys.path:
            sys.path.remove(str(hook_dir))


def run_file_check(file_path: Path) -> tuple[str, list[str]]:
    """Run the file checker (lint/syntax) on a single file.

    Returns (status, details) where status is 'pass', 'fail', or 'skip'.
    """
    hook_dir = _hook_dir()
    checker = hook_dir / "file_checker.py"
    if not checker.exists():
        return "skip", ["file checker not found"]

    # We invoke file_checker as a subprocess so it can use its own sys.path
    # manipulation without polluting ours.
    fake_stdin = json.dumps(
        {
            "tool_name": "Write",
            "tool_input": {"file_path": str(file_path)},
        }
    )
    try:
        result = subprocess.run(
            [sys.executable, str(checker)],
            input=fake_stdin,
            capture_output=True,
            text=True,
            timeout=30,
        )
        stdout = result.stdout.strip()
        if stdout:
            try:
                data = json.loads(stdout)
                if data.get("decision") == "block":
                    reason = data.get("reason", "check failed")
                    # Strip box-drawing chars for plain detail
                    plain = " ".join(
                        line.strip("│┌└─ ").strip()
                        for line in reason.splitlines()
                        if line.strip("│┌└─ ").strip()
                    )
                    return "fail", [plain]
            except json.JSONDecodeError:
                pass
        if result.returncode not in (0, None):
            return "fail", [result.stderr.strip() or "file check failed"]
        return "pass", []
    except subprocess.TimeoutExpired:
        return "skip", ["file check timed out"]
    except Exception as exc:  # noqa: BLE001
        return "skip", [f"file check error: {exc}"]


def run_tdd_check(file_path: Path) -> tuple[str, list[str]]:
    """Run the TDD enforcer in advisory mode on a single file.

    Never returns 'fail' — TDD findings are always 'warn' in CI mode.
    """
    hook_dir = _hook_dir()
    checker = hook_dir / "tdd_enforcer.py"
    if not checker.exists():
        return "skip", ["tdd enforcer not found"]

    fake_stdin = json.dumps(
        {
            "tool_name": "Write",
            "tool_input": {"file_path": str(file_path)},
        }
    )
    try:
        result = subprocess.run(
            [sys.executable, str(checker)],
            input=fake_stdin,
            capture_output=True,
            text=True,
            timeout=30,
        )
        if result.returncode == 2:
            # TDD reminder — advisory only
            msg = result.stderr.strip()
            plain = " ".join(
                line.strip("│┌└─⚠ ").strip()
                for line in msg.splitlines()
                if line.strip("│┌└─⚠ ").strip()
            )
            return "warn", [plain or "TDD reminder: write a failing test first"]
        return "pass", []
    except subprocess.TimeoutExpired:
        return "skip", ["tdd check timed out"]
    except Exception as exc:  # noqa: BLE001
        return "skip", [f"tdd check error: {exc}"]


# ---------------------------------------------------------------------------
# Coverage check
# ---------------------------------------------------------------------------

_COVERABLE_EXTENSIONS = {".py", ".go", ".ts", ".tsx", ".js", ".jsx"}


def measure_coverage(files: Sequence[Path], threshold: int | None) -> tuple[float | None, bool]:
    """Attempt to measure test coverage and compare against *threshold*.

    Currently runs ``pytest --co -q`` to count test items as a proxy.
    Returns (coverage_pct, passed).  Returns (None, True) when coverage
    cannot be measured.
    """
    if threshold is None:
        return None, True

    # Check if pytest is available and there are coverable files
    coverable = [f for f in files if f.suffix in _COVERABLE_EXTENSIONS]
    if not coverable:
        return None, True

    try:
        result = subprocess.run(
            [sys.executable, "-m", "pytest", "--co", "-q", "--tb=no", "-q"],
            capture_output=True,
            text=True,
            timeout=60,
        )
        output = result.stdout + result.stderr
        # Count "X tests collected" or "X items"
        import re

        m = re.search(r"(\d+)\s+(?:test[s]?\s+collected|items?\s+collected)", output, re.IGNORECASE)
        if m:
            collected = int(m.group(1))
            # Use a heuristic: collected tests / coverable files * 100
            # (real coverage requires running tests with --cov, which is expensive)
            pct = min(100.0, (collected / max(len(coverable), 1)) * 100.0)
            return pct, pct >= threshold
    except (subprocess.TimeoutExpired, FileNotFoundError):
        pass

    return None, True


# ---------------------------------------------------------------------------
# Report rendering
# ---------------------------------------------------------------------------

_STATUS_ICON = {
    "pass": "✓",
    "fail": "✗",
    "warn": "⚠",
    "skip": "–",
}


def print_table(report: CIReport) -> None:
    """Print a human-readable table to stdout."""
    col_file = max(30, max((len(r.file) for r in report.files), default=30))
    col_status = 8

    header = (
        f"{'File':<{col_file}}  {'Secrets':<{col_status}}  {'Tests':<{col_status}}  Overall"
    )
    sep = "─" * len(header)
    print(sep)
    print(header)
    print(sep)
    for r in report.files:
        sec_icon = _STATUS_ICON.get(r.secrets, r.secrets)
        tst_icon = _STATUS_ICON.get(r.tests, r.tests)
        ov_icon = _STATUS_ICON.get(r.overall, r.overall)
        print(f"{r.file:<{col_file}}  {sec_icon:<{col_status}}  {tst_icon:<{col_status}}  {ov_icon}")
        for detail in r.details:
            print(f"  ↳ {detail}")
    print(sep)
    summary = f"Total: {report.total}  Passed: {report.passed}  Failed: {report.failed}  Warned: {report.warned}"
    print(summary)
    if report.coverage_pct is not None:
        cov_icon = "✓" if report.coverage_passed else "✗"
        print(f"Coverage: {report.coverage_pct:.1f}%  {cov_icon}")
    print(sep)


# ---------------------------------------------------------------------------
# Main runner
# ---------------------------------------------------------------------------

_CHECKABLE_EXTENSIONS = {".py", ".go", ".ts", ".tsx", ".js", ".jsx", ".rb", ".sh"}


def run_ci(
    files: Sequence[str] | None = None,
    *,
    as_json: bool = False,
    coverage_threshold: int | None = None,
    repo_root: Path | None = None,
) -> int:
    """Run all CI checks and return exit code.

    Args:
        files: Explicit list of file paths to check. If None, auto-discovers.
        as_json: If True, output machine-readable JSON instead of a table.
        coverage_threshold: Fail if estimated coverage < threshold.
        repo_root: Override working directory for git commands.

    Returns:
        0 — all pass
        1 — one or more failures
        2 — warnings only
    """
    if files:
        paths = [Path(f) for f in files]
    else:
        paths = discover_changed_files(repo_root)

    # Filter to checkable file types that exist
    checkable = [p for p in paths if p.exists() and p.is_file() and p.suffix in _CHECKABLE_EXTENSIONS]

    report = CIReport()
    report.total = len(checkable)

    for fpath in checkable:
        result = FileResult(file=str(fpath))

        # 1. Secrets
        sec_status, sec_details = run_secrets_check(fpath)
        result.secrets = sec_status
        result.details.extend(sec_details)

        # 2. File check (lint/syntax)
        file_status, file_details = run_file_check(fpath)
        result.details.extend(file_details)

        # 3. TDD check (advisory)
        tdd_status, tdd_details = run_tdd_check(fpath)
        result.tests = tdd_status
        result.details.extend(tdd_details)

        # Determine overall
        if sec_status == "fail" or file_status == "fail":
            result.overall = "fail"
            report.failed += 1
        elif tdd_status == "warn" or sec_status == "warn" or file_status == "warn":
            result.overall = "warn"
            report.warned += 1
        elif sec_status == "skip" or file_status == "skip":
            result.overall = "skip"
            report.warned += 1
        else:
            result.overall = "pass"
            report.passed += 1

        report.files.append(result)

    # Coverage
    cov_pct, cov_ok = measure_coverage(checkable, coverage_threshold)
    report.coverage_pct = cov_pct
    report.coverage_passed = cov_ok

    # Output
    if as_json:
        print(json.dumps(report.to_dict(), indent=2))
    else:
        print_table(report)

    # Determine exit code
    if report.failed > 0 or not cov_ok:
        return 1
    if report.warned > 0:
        return 2
    return 0
