#!/usr/bin/env python3
from __future__ import annotations

import argparse
import hashlib
import json
import subprocess
import sys
from collections import defaultdict
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Iterable, Sequence

SPECIALISTS = (
    "api-contract",
    "data-migration",
    "maintainability",
    "performance",
    "red-team",
    "security",
    "testing",
)

INITIAL_SPECIALISTS = tuple(name for name in SPECIALISTS if name != "red-team")

SEVERITY_ALIASES = {
    "critical": "critical",
    "crit": "critical",
    "high": "high",
    "medium": "medium",
    "med": "medium",
    "low": "low",
    "info": "info",
    "informational": "info",
}

SEVERITY_RANK = {
    "critical": 0,
    "high": 1,
    "medium": 2,
    "low": 3,
    "info": 4,
}

REQUIRED_FINDING_FIELDS = {
    "severity",
    "confidence",
    "path",
    "summary",
    "evidence",
    "recommendation",
    "specialist",
}


@dataclass(frozen=True)
class DiffScope:
    diff_lines: int
    changed_files: list[str]


class ReviewSpecialistsError(Exception):
    """User-actionable helper error."""


def run_git(repo: Path, args: Sequence[str]) -> str:
    proc = subprocess.run(
        ["git", "-C", str(repo), *args],
        text=True,
        capture_output=True,
    )
    if proc.returncode != 0:
        message = (proc.stderr or proc.stdout or "").strip()
        raise ReviewSpecialistsError(message or f"git {' '.join(args)} failed")
    return proc.stdout


def find_repo(path: Path) -> Path:
    try:
        root = run_git(path, ["rev-parse", "--show-toplevel"]).strip()
    except ReviewSpecialistsError as exc:
        raise ReviewSpecialistsError(f"not a git repository: {path}") from exc
    return Path(root)


def diff_scope(repo: Path, base: str) -> DiffScope:
    run_git(repo, ["rev-parse", "--verify", "--quiet", base])
    names = run_git(repo, ["diff", "--name-only", "--diff-filter=ACMRTUXB", base, "--"])
    changed_files = sorted(line.strip() for line in names.splitlines() if line.strip())

    numstat = run_git(repo, ["diff", "--numstat", base, "--"])
    diff_lines = 0
    for line in numstat.splitlines():
        parts = line.split("\t")
        if len(parts) < 3:
            continue
        for value in parts[:2]:
            if value.isdigit():
                diff_lines += int(value)
    return DiffScope(diff_lines=diff_lines, changed_files=changed_files)


def has_any(path: str, needles: Iterable[str]) -> bool:
    lowered = path.lower()
    return any(needle in lowered for needle in needles)


def infer_stack(files: Sequence[str]) -> list[str]:
    stack: set[str] = set()
    for file in files:
        lowered = file.lower()
        suffix = Path(lowered).suffix
        if suffix == ".py":
            stack.add("python")
        if suffix in {".js", ".jsx", ".ts", ".tsx", ".vue", ".svelte"}:
            stack.add("javascript")
        if suffix in {".go"}:
            stack.add("go")
        if suffix in {".rs"}:
            stack.add("rust")
        if suffix in {".java", ".kt", ".kts"}:
            stack.add("jvm")
        if suffix in {".rb"}:
            stack.add("ruby")
        if suffix in {".php"}:
            stack.add("php")
        if suffix in {".sql"} or has_any(lowered, ("migration", "migrations", "schema")):
            stack.add("sql")
        if suffix in {".md", ".mdx", ".rst"}:
            stack.add("docs")
    return sorted(stack)


def infer_test_framework(files: Sequence[str]) -> list[str]:
    frameworks: set[str] = set()
    for file in files:
        lowered = file.lower()
        name = Path(lowered).name
        if name.startswith("test_") or lowered.endswith("_test.py") or "/tests/" in lowered:
            if lowered.endswith(".py"):
                frameworks.add("pytest")
        if lowered.endswith((".test.ts", ".test.tsx", ".spec.ts", ".spec.tsx", ".test.js", ".spec.js")):
            frameworks.add("jest/vitest")
        if lowered.endswith("_test.go"):
            frameworks.add("go test")
        if lowered.endswith(("_spec.rb", "_test.rb")):
            frameworks.add("rspec/minitest")
    return sorted(frameworks)


def detect_signals(files: Sequence[str]) -> dict[str, bool]:
    scope_api = any(
        has_any(
            file,
            (
                "api",
                "openapi",
                "graphql",
                "proto",
                "schema",
                "route",
                "routes",
                "controller",
                "contract",
                "sdk",
            ),
        )
        for file in files
    )
    scope_auth = any(
        has_any(
            file,
            (
                "auth",
                "oauth",
                "jwt",
                "session",
                "permission",
                "rbac",
                "acl",
                "security",
                "token",
            ),
        )
        for file in files
    )
    scope_migrations = any(
        has_any(file, ("migration", "migrations", "alembic", "db/migrate", "schema", "backfill"))
        or Path(file.lower()).suffix == ".sql"
        for file in files
    )
    scope_frontend = any(
        has_any(file, ("frontend", "web/", "ui/", "components/", "pages/", "app/"))
        or Path(file.lower()).suffix in {".js", ".jsx", ".ts", ".tsx", ".vue", ".svelte", ".css", ".scss", ".html"}
        for file in files
    )
    scope_backend = any(
        has_any(file, ("backend", "server", "service", "worker", "api", "app/", "src/", "lib/"))
        or Path(file.lower()).suffix in {".py", ".go", ".rs", ".rb", ".php", ".java", ".kt", ".cs"}
        for file in files
    )
    return {
        "scope_api": scope_api,
        "scope_auth": scope_auth,
        "scope_backend": scope_backend,
        "scope_frontend": scope_frontend,
        "scope_migrations": scope_migrations,
    }


def forced_specialists(args: argparse.Namespace) -> set[str]:
    if args.all_specialists:
        return set(INITIAL_SPECIALISTS)
    forced: set[str] = set()
    for name in INITIAL_SPECIALISTS:
        attr = name.replace("-", "_")
        if getattr(args, attr):
            forced.add(name)
    if args.red_team:
        forced.add("red-team")
    return forced


def suggest_specialists(scope: DiffScope, signals: dict[str, bool], forced: set[str]) -> list[str]:
    selected: set[str] = set(forced)
    if scope.diff_lines >= 50:
        selected.update({"maintainability", "testing"})
        if signals["scope_api"]:
            selected.add("api-contract")
        if signals["scope_migrations"]:
            selected.add("data-migration")
        if signals["scope_backend"] or signals["scope_frontend"]:
            selected.add("performance")
        if signals["scope_auth"] or (signals["scope_backend"] and scope.diff_lines > 100):
            selected.add("security")
    return [name for name in SPECIALISTS if name in selected]


def command_scope(args: argparse.Namespace) -> int:
    repo = find_repo(Path(args.repo).resolve())
    scope = diff_scope(repo, args.base)
    signals = detect_signals(scope.changed_files)
    forced = forced_specialists(args)
    suggestions = suggest_specialists(scope, signals, forced)
    payload: dict[str, Any] = {
        "schema": "code-review-specialists.scope.v1",
        "base": args.base,
        "repo": str(repo),
        "diff_lines": scope.diff_lines,
        "changed_files": scope.changed_files,
        **signals,
        "stack": infer_stack(scope.changed_files),
        "test_framework": infer_test_framework(scope.changed_files),
        "forced_specialists": sorted(forced),
        "suggested_specialists": suggestions,
        "small_diff_skip": scope.diff_lines < 50 and not forced,
        "red_team_activation": {
            "diff_lines_gt_200": scope.diff_lines > 200,
            "critical_finding": False,
            "forced": "red-team" in forced,
            "recommended": scope.diff_lines > 200 or "red-team" in forced,
        },
    }
    print(json.dumps(payload, indent=2, sort_keys=True))
    return 0


def normalize_severity(value: Any, *, source: str) -> str:
    if not isinstance(value, str):
        raise ReviewSpecialistsError(f"{source}: severity must be a string")
    normalized = SEVERITY_ALIASES.get(value.strip().lower())
    if normalized is None:
        accepted = "critical|high|medium|low|info"
        raise ReviewSpecialistsError(f"{source}: unsupported severity {value!r}; expected {accepted}")
    return normalized


def normalize_confidence(value: Any, *, source: str) -> float:
    if not isinstance(value, (int, float)):
        raise ReviewSpecialistsError(f"{source}: confidence must be a number from 0.0 to 1.0")
    confidence = float(value)
    if confidence < 0.0 or confidence > 1.0:
        raise ReviewSpecialistsError(f"{source}: confidence must be from 0.0 to 1.0")
    return confidence


def computed_fingerprint(finding: dict[str, Any]) -> str:
    source = "|".join(
        [
            str(finding.get("path", "")),
            str(finding.get("line", "")),
            str(finding.get("category", "")),
            str(finding.get("summary", "")).strip().lower(),
        ]
    )
    return hashlib.sha256(source.encode("utf-8")).hexdigest()[:16]


def parse_jsonl(path: Path) -> list[dict[str, Any]]:
    findings: list[dict[str, Any]] = []
    for index, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
        if not line.strip():
            continue
        source = f"{path}:{index}"
        try:
            raw = json.loads(line)
        except json.JSONDecodeError as exc:
            raise ReviewSpecialistsError(f"{source}: malformed JSON: {exc.msg}") from exc
        if not isinstance(raw, dict):
            raise ReviewSpecialistsError(f"{source}: finding must be a JSON object")
        missing = sorted(REQUIRED_FINDING_FIELDS - set(raw))
        if missing:
            raise ReviewSpecialistsError(f"{source}: missing required field(s): {', '.join(missing)}")

        finding = dict(raw)
        finding["severity"] = normalize_severity(finding["severity"], source=source)
        finding["confidence"] = normalize_confidence(finding["confidence"], source=source)
        specialist = str(finding["specialist"])
        if specialist not in SPECIALISTS:
            raise ReviewSpecialistsError(f"{source}: unsupported specialist {specialist!r}")
        if "line" in finding and finding["line"] is not None:
            if not isinstance(finding["line"], int) or finding["line"] < 1:
                raise ReviewSpecialistsError(f"{source}: line must be a positive integer when present")
        finding.setdefault("category", specialist)
        finding["fingerprint"] = str(finding.get("fingerprint") or computed_fingerprint(finding))
        finding["source_file"] = str(path)
        finding["source_line"] = index
        findings.append(finding)
    return findings


def higher_priority(left: dict[str, Any], right: dict[str, Any]) -> dict[str, Any]:
    left_key = (float(left["confidence"]), -SEVERITY_RANK[left["severity"]])
    right_key = (float(right["confidence"]), -SEVERITY_RANK[right["severity"]])
    return left if left_key >= right_key else right


def merge_findings(files: Sequence[Path]) -> list[dict[str, Any]]:
    by_fingerprint: dict[str, dict[str, Any]] = {}
    confirming: dict[str, set[str]] = defaultdict(set)
    for file in files:
        for finding in parse_jsonl(file):
            fingerprint = finding["fingerprint"]
            confirming[fingerprint].add(str(finding["specialist"]))
            if fingerprint not in by_fingerprint:
                by_fingerprint[fingerprint] = finding
            else:
                by_fingerprint[fingerprint] = higher_priority(by_fingerprint[fingerprint], finding)

    merged: list[dict[str, Any]] = []
    for fingerprint, finding in by_fingerprint.items():
        normalized = dict(finding)
        normalized["confirming_specialists"] = sorted(confirming[fingerprint])
        normalized["confirming_count"] = len(confirming[fingerprint])
        merged.append(normalized)

    return sorted(
        merged,
        key=lambda item: (
            SEVERITY_RANK[item["severity"]],
            -float(item["confidence"]),
            str(item["path"]),
            str(item["summary"]),
        ),
    )


def specialist_stats(all_findings: Sequence[dict[str, Any]], displayed: Sequence[dict[str, Any]]) -> dict[str, dict[str, int]]:
    stats: dict[str, dict[str, int]] = {
        name: {"input": 0, "displayed": 0, "suppressed": 0}
        for name in SPECIALISTS
    }
    displayed_fingerprints = {item["fingerprint"] for item in displayed}
    for finding in all_findings:
        for specialist in finding.get("confirming_specialists", [finding["specialist"]]):
            stats.setdefault(specialist, {"input": 0, "displayed": 0, "suppressed": 0})
            stats[specialist]["input"] += 1
            if finding["fingerprint"] in displayed_fingerprints:
                stats[specialist]["displayed"] += 1
            else:
                stats[specialist]["suppressed"] += 1
    return {key: value for key, value in stats.items() if any(value.values())}


def render_markdown(displayed: Sequence[dict[str, Any]], suppressed: Sequence[dict[str, Any]], stats: dict[str, dict[str, int]]) -> str:
    lines = [
        "# Specialist Findings Summary",
        "",
        "## Dispatch Stats",
        "",
        "| Specialist | Input | Displayed | Suppressed |",
        "| --- | ---: | ---: | ---: |",
    ]
    if stats:
        for specialist in sorted(stats):
            item = stats[specialist]
            lines.append(f"| {specialist} | {item['input']} | {item['displayed']} | {item['suppressed']} |")
    else:
        lines.append("| none | 0 | 0 | 0 |")

    lines.extend(["", "## Findings", ""])
    if not displayed:
        lines.append("No displayed specialist findings.")
    else:
        for item in displayed:
            line = item.get("line")
            location = f"{item['path']}:{line}" if line else str(item["path"])
            lines.append(
                f"- **{item['severity']}** ({item['confidence']:.2f}, {item['specialist']}) "
                f"{location}: {item['summary']} Recommendation: {item['recommendation']}"
            )

    lines.extend(["", "## Low-Confidence Appendix", ""])
    if not suppressed:
        lines.append("No suppressed low-confidence findings.")
    else:
        for item in suppressed:
            lines.append(
                f"- **{item['severity']}** ({item['confidence']:.2f}, {item['specialist']}) "
                f"{item['path']}: {item['summary']}"
            )
    return "\n".join(lines) + "\n"


def command_merge_findings(args: argparse.Namespace) -> int:
    files = [Path(file).resolve() for file in args.files]
    for file in files:
        if not file.is_file():
            raise ReviewSpecialistsError(f"finding file not found: {file}")

    merged = merge_findings(files)
    displayed = [item for item in merged if float(item["confidence"]) >= args.display_threshold]
    suppressed = [item for item in merged if float(item["confidence"]) < args.display_threshold]
    stats = specialist_stats(merged, displayed)
    summary = render_markdown(displayed, suppressed, stats)
    if args.summary_out:
        Path(args.summary_out).write_text(summary, encoding="utf-8")

    payload = {
        "schema": "code-review-specialists.merge.v1",
        "display_threshold": args.display_threshold,
        "finding_counts": {
            "total": len(merged),
            "displayed": len(displayed),
            "suppressed": len(suppressed),
        },
        "specialist_stats": stats,
        "findings": displayed,
        "suppressed_findings": suppressed,
        "red_team_required": any(item["severity"] == "critical" for item in displayed),
        "summary_markdown": summary,
    }

    if args.format == "markdown":
        print(summary, end="")
    else:
        print(json.dumps(payload, indent=2, sort_keys=True))
    return 0


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description="Detect code-review specialist scope and merge specialist findings.",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    subparsers = parser.add_subparsers(dest="command", required=True)

    scope = subparsers.add_parser("scope", help="Emit diff scope JSON for specialist selection.")
    scope.add_argument("--repo", default=".", help="Path inside the target git repository.")
    scope.add_argument("--base", required=True, help="Base ref for git diff.")
    scope.add_argument("--testing", action="store_true", help="Force testing specialist.")
    scope.add_argument("--security", action="store_true", help="Force security specialist.")
    scope.add_argument("--performance", action="store_true", help="Force performance specialist.")
    scope.add_argument("--data-migration", action="store_true", help="Force data-migration specialist.")
    scope.add_argument("--api-contract", action="store_true", help="Force api-contract specialist.")
    scope.add_argument("--maintainability", action="store_true", help="Force maintainability specialist.")
    scope.add_argument("--red-team", action="store_true", help="Force red-team specialist.")
    scope.add_argument("--all-specialists", action="store_true", help="Force all initial specialists.")
    scope.set_defaults(func=command_scope)

    merge = subparsers.add_parser("merge-findings", help="Merge specialist JSONL findings.")
    merge.add_argument("files", nargs="+", help="Specialist JSONL files to merge.")
    merge.add_argument(
        "--display-threshold",
        type=float,
        default=0.60,
        help="Minimum confidence for main displayed findings.",
    )
    merge.add_argument("--summary-out", default=None, help="Optional markdown summary output path.")
    merge.add_argument("--format", choices=("json", "markdown"), default="json")
    merge.set_defaults(func=command_merge_findings)

    return parser


def main(argv: Sequence[str] | None = None) -> int:
    parser = build_parser()
    args = parser.parse_args(argv)
    try:
        if getattr(args, "display_threshold", 0.60) < 0 or getattr(args, "display_threshold", 0.60) > 1:
            raise ReviewSpecialistsError("--display-threshold must be from 0.0 to 1.0")
        return args.func(args)
    except ReviewSpecialistsError as exc:
        print(f"error: {exc}", file=sys.stderr)
        return 1


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