#!/usr/bin/env python3
"""Report direct SQLAlchemy usage in controller code.

This is the full-inventory companion to ``check_no_new_controller_sqlalchemy.py``.
It scans the current tree instead of a git diff, so migration work can inspect
existing controller usage without turning historical debt into a CI failure.
"""

from __future__ import annotations

import argparse
import json
import re
import sys
from collections import Counter
from collections.abc import Iterable, Sequence
from dataclasses import asdict, dataclass
from pathlib import Path

import check_no_new_controller_sqlalchemy as controller_sqlalchemy_guard
from ast_grep_guard import Match, REPO_ROOT, run_ast_grep


DEFAULT_CONTROLLER_DIR = REPO_ROOT / "api" / "controllers"
MAX_TEXT_LENGTH = 180
SESSION_METHOD_PATTERN = re.compile(r"^(?:db\.session|session)\.(?P<method>[A-Za-z_][A-Za-z0-9_]*)\(")


@dataclass(frozen=True)
class Finding:
    classification: str
    category: str
    file: str
    line: int
    text: str
    source_line: str


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument(
        "paths",
        nargs="*",
        help="Files or directories to scan. Defaults to api/controllers.",
    )
    parser.add_argument("--include-allowed", action="store_true", help="Print allowed flush()/commit() findings.")
    parser.add_argument("--include-suppressed", action="store_true", help="Print reasoned guard suppressions.")
    parser.add_argument("--summary-only", action="store_true", help="Print counts without per-finding details.")
    parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON.")
    parser.add_argument(
        "--fail-on-findings",
        action="store_true",
        help="Treat reportable direct SQLAlchemy usage as a failure. By default this scanner is report-only.",
    )
    return parser.parse_args()


def iter_python_files(paths: Iterable[Path]) -> Iterable[Path]:
    for path in paths:
        if path.is_file() and path.suffix in {".py", ".pyi"}:
            yield path
        elif path.is_dir():
            yield from sorted(child for child in path.rglob("*.py") if child.is_file())


def display_path(path: Path) -> str:
    try:
        return str(path.relative_to(REPO_ROOT))
    except ValueError:
        return str(path)


def compact_text(text: str) -> str:
    compact = " ".join(text.split())
    if len(compact) <= MAX_TEXT_LENGTH:
        return compact
    return f"{compact[: MAX_TEXT_LENGTH - 3]}..."


def category_for_match(match: Match) -> str:
    text = match.text.strip()
    method_match = SESSION_METHOD_PATTERN.match(text)
    method = method_match.group("method") if method_match else None

    if text.startswith("db.session."):
        return f"scoped-session.{method or 'call'}"
    if text.startswith("session."):
        return f"explicit-session.{method or 'call'}"
    if text.startswith(("Session(", "sessionmaker(")):
        return "session-factory"
    if text.startswith(("sa.", "sqlalchemy.", "db.")):
        return "sql-expression-qualified"
    return "sql-expression"


def classification_for_match(match: Match) -> str:
    if controller_sqlalchemy_guard.is_allowed_session_boundary(match):
        return "allowed"
    if controller_sqlalchemy_guard.is_suppressed(match):
        return "suppressed"
    if controller_sqlalchemy_guard.is_flask_session_get(match):
        return "ignored"
    return "reportable"


def findings_for_file(path: Path) -> list[Finding]:
    source = path.read_text(encoding="utf-8")
    findings: list[Finding] = []
    for match in run_ast_grep(source, rule=controller_sqlalchemy_guard.RULE_PATH):
        findings.append(
            Finding(
                classification=classification_for_match(match),
                category=category_for_match(match),
                file=display_path(path),
                line=match.line_number,
                text=compact_text(match.text),
                source_line=match.source_line.strip(),
            )
        )
    return findings


def print_text_report(
    findings: Sequence[Finding],
    *,
    include_allowed: bool,
    include_suppressed: bool,
    summary_only: bool,
) -> None:
    counts = Counter(finding.classification for finding in findings)
    reportable = [finding for finding in findings if finding.classification == "reportable"]
    category_counts = Counter(finding.category for finding in reportable)
    file_counts = Counter(finding.file for finding in reportable)
    sys.stdout.write(
        "Controller SQLAlchemy scan: "
        f"{counts['reportable']} reportable, "
        f"{counts['allowed']} allowed, "
        f"{counts['suppressed']} suppressed, "
        f"{counts['ignored']} ignored\n"
    )

    if category_counts:
        sys.stdout.write("\nREPORTABLE BY CATEGORY:\n")
        for category, count in sorted(category_counts.items()):
            sys.stdout.write(f"- {category}: {count}\n")

    if file_counts:
        sys.stdout.write("\nREPORTABLE BY FILE:\n")
        for file, count in sorted(file_counts.items()):
            sys.stdout.write(f"- {file}: {count}\n")

    if summary_only:
        return

    print_findings("REPORTABLE", reportable)
    if include_allowed:
        print_findings("ALLOWED", [finding for finding in findings if finding.classification == "allowed"])
    if include_suppressed:
        print_findings("SUPPRESSED", [finding for finding in findings if finding.classification == "suppressed"])


def print_findings(title: str, findings: Sequence[Finding]) -> None:
    if not findings:
        return

    sys.stdout.write(f"\n{title}:\n")
    for finding in findings:
        sys.stdout.write(f"- {finding.file}:{finding.line} [{finding.category}] {finding.text}\n")


def jsonable_findings(
    findings: Sequence[Finding],
    *,
    include_allowed: bool,
    include_suppressed: bool,
    summary_only: bool,
) -> dict[str, object]:
    visible = [
        finding
        for finding in findings
        if finding.classification == "reportable"
        or (include_allowed and finding.classification == "allowed")
        or (include_suppressed and finding.classification == "suppressed")
    ]
    counts = Counter(finding.classification for finding in findings)
    return {
        "summary": dict(sorted(counts.items())),
        "findings": [] if summary_only else [asdict(finding) for finding in visible],
    }


def main() -> int:
    args = parse_args()
    raw_paths = args.paths or [str(DEFAULT_CONTROLLER_DIR)]
    paths = [path if path.is_absolute() else REPO_ROOT / path for path in map(Path, raw_paths)]

    findings: list[Finding] = []
    for path in iter_python_files(paths):
        findings.extend(findings_for_file(path.resolve()))
    findings.sort(key=lambda finding: (finding.file, finding.line, finding.category, finding.text))

    if args.json:
        payload = jsonable_findings(
            findings,
            include_allowed=bool(args.include_allowed),
            include_suppressed=bool(args.include_suppressed),
            summary_only=bool(args.summary_only),
        )
        sys.stdout.write(f"{json.dumps(payload, indent=2, sort_keys=True)}\n")
    else:
        print_text_report(
            findings,
            include_allowed=bool(args.include_allowed),
            include_suppressed=bool(args.include_suppressed),
            summary_only=bool(args.summary_only),
        )

    has_reportable = any(finding.classification == "reportable" for finding in findings)
    return int(bool(args.fail_on_findings) and has_reportable)


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