#!/usr/bin/env python3
"""ENTR Adapter Coding Standards Checker.

Loads rule definitions from rules.yaml and runs automated checks against
adapter Python files. Outputs GitHub Actions annotations for inline PR feedback.

Usage:
    python checks/run_checks.py [--path app/] [--rules checks/rules.yaml]

Exit codes:
    0 = all checks passed
    1 = one or more violations found
"""

import argparse
import ast
import os
import re
import subprocess
import sys
from pathlib import Path

import yaml


def load_rules(rules_path: str) -> list[dict]:
    """Load enabled rules from the YAML config file."""
    with open(rules_path) as f:
        data = yaml.safe_load(f)
    return [r for r in data.get("rules", []) if r.get("enabled", True)]


# ── AST-based checks ────────────────────────────────────────────────────


class AdapterASTChecker(ast.NodeVisitor):
    """AST visitor that checks for common adapter anti-patterns."""

    def __init__(self, filepath: str, enabled_rules: set[str]):
        self.filepath = filepath
        self.enabled_rules = enabled_rules
        self.violations: list[dict] = []
        self._in_handler_class = False
        self._handler_methods: list[str] = []

    def visit_ClassDef(self, node: ast.ClassDef):
        # Check if this class inherits from BaseHandler or similar
        base_names = []
        for base in node.bases:
            if isinstance(base, ast.Name):
                base_names.append(base.id)
            elif isinstance(base, ast.Attribute):
                base_names.append(base.attr)

        is_handler = any(
            name in ("BaseHandler", "RetrievalHandler")
            for name in base_names
        )

        if is_handler:
            self._in_handler_class = True
            self._handler_methods = [
                item.name for item in node.body
                if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef))
            ]

            # ENTR-006: Check for intermediary handler methods
            if "ENTR-006" in self.enabled_rules:
                suspicious = [
                    m for m in self._handler_methods
                    if m.startswith("handle_") and m != "process"
                ]
                for method_name in suspicious:
                    for item in node.body:
                        if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)) and item.name == method_name:
                            self.violations.append({
                                "rule": "ENTR-006",
                                "file": self.filepath,
                                "line": item.lineno,
                                "message": f"Handler method '{method_name}' looks like intermediary logic — move business logic into process().",
                            })

        self.generic_visit(node)
        if is_handler:
            self._in_handler_class = False

    def visit_Call(self, node: ast.Call):
        # ENTR-001: No print() statements
        if "ENTR-001" in self.enabled_rules:
            if isinstance(node.func, ast.Name) and node.func.id == "print":
                self.violations.append({
                    "rule": "ENTR-001",
                    "file": self.filepath,
                    "line": node.lineno,
                    "message": "print() statement found — use logging.getLogger(__name__) instead.",
                })

        self.generic_visit(node)

    def visit_Raise(self, node: ast.Raise):
        # ENTR-002: No raise HTTPException
        if "ENTR-002" in self.enabled_rules and node.exc:
            if isinstance(node.exc, ast.Call):
                func = node.exc.func
                name = None
                if isinstance(func, ast.Name):
                    name = func.id
                elif isinstance(func, ast.Attribute):
                    name = func.attr

                if name == "HTTPException":
                    self.violations.append({
                        "rule": "ENTR-002",
                        "file": self.filepath,
                        "line": node.lineno,
                        "message": "raise HTTPException found — return AdapterResponse(status=StatusEnum.FAILED, ...) instead.",
                    })

        self.generic_visit(node)

    def visit_FunctionDef(self, node):
        self._check_process_try_except(node)
        self.generic_visit(node)

    def visit_AsyncFunctionDef(self, node):
        self._check_process_try_except(node)
        self.generic_visit(node)

    def _check_process_try_except(self, node):
        """ENTR-003: Check if process() is entirely wrapped in a single try/except."""
        if "ENTR-003" not in self.enabled_rules:
            return
        if node.name != "process" or not self._in_handler_class:
            return

        # Check if the body is a single Try node (entire method wrapped)
        body = node.body
        # Skip docstring if present
        if body and isinstance(body[0], ast.Expr) and isinstance(body[0].value, (ast.Constant, ast.Str)):
            body = body[1:]

        if len(body) == 1 and isinstance(body[0], ast.Try):
            self.violations.append({
                "rule": "ENTR-003",
                "file": self.filepath,
                "line": node.lineno,
                "message": "process() method is entirely wrapped in try/except — let the framework handle unhandled exceptions.",
            })


def run_ast_checks(filepath: str, enabled_rules: set[str]) -> list[dict]:
    """Run AST-based checks on a Python file."""
    try:
        with open(filepath) as f:
            source = f.read()
        tree = ast.parse(source, filename=filepath)
    except SyntaxError as e:
        return [{
            "rule": "PARSE-ERROR",
            "file": filepath,
            "line": e.lineno or 0,
            "message": f"Syntax error: {e.msg}",
        }]

    checker = AdapterASTChecker(filepath, enabled_rules)
    checker.visit(tree)
    return checker.violations


# ── Grep-based checks ───────────────────────────────────────────────────


def run_grep_check(rule: dict, files: list[str]) -> list[dict]:
    """Run a grep/regex-based check across files."""
    pattern = rule.get("pattern", "")
    if not pattern:
        return []

    violations = []
    regex = re.compile(pattern)

    for filepath in files:
        try:
            with open(filepath) as f:
                for lineno, line in enumerate(f, 1):
                    # Skip comments
                    stripped = line.strip()
                    if stripped.startswith("#"):
                        continue

                    if rule.get("match_required"):
                        # This is an inverted check — pattern MUST be present
                        continue  # Handled separately below

                    if regex.search(line):
                        violations.append({
                            "rule": rule["id"],
                            "file": filepath,
                            "line": lineno,
                            "message": f"{rule['description']} — found: {stripped[:80]}",
                        })
        except (OSError, UnicodeDecodeError):
            continue

    # Handle match_required (pattern must be present in file)
    if rule.get("match_required"):
        check_glob = rule.get("check_files", "")
        for filepath in files:
            if check_glob and not _matches_glob(filepath, check_glob):
                continue
            try:
                with open(filepath) as f:
                    content = f.read()
                if not regex.search(content):
                    violations.append({
                        "rule": rule["id"],
                        "file": filepath,
                        "line": 1,
                        "message": f"{rule['description']} — pattern '{pattern}' not found in file.",
                    })
            except (OSError, UnicodeDecodeError):
                continue

    return violations


def _matches_glob(filepath: str, glob_pattern: str) -> bool:
    """Simple glob matching for check_files patterns."""
    from fnmatch import fnmatch
    return fnmatch(filepath, glob_pattern) or fnmatch(filepath, f"**/{glob_pattern}")


# ── Ruff-based checks ───────────────────────────────────────────────────


def run_ruff_check(rule: dict, files: list[str]) -> list[dict]:
    """Run ruff with specific rule selections."""
    ruff_rules = rule.get("ruff_rules", [])
    if not ruff_rules or not files:
        return []

    select_arg = ",".join(ruff_rules)
    try:
        result = subprocess.run(
            ["ruff", "check", "--select", select_arg, "--output-format", "json", *files],
            capture_output=True,
            text=True,
        )
    except FileNotFoundError:
        # ruff not installed — skip
        return [{
            "rule": rule["id"],
            "file": "",
            "line": 0,
            "message": "ruff not installed — skipping PEP 8 naming checks. Install with: pip install ruff",
        }]

    violations = []
    if result.stdout:
        import json
        try:
            findings = json.loads(result.stdout)
            for f in findings:
                violations.append({
                    "rule": rule["id"],
                    "file": f.get("filename", ""),
                    "line": f.get("location", {}).get("row", 0),
                    "message": f"{f.get('code', '')}: {f.get('message', '')}",
                })
        except json.JSONDecodeError:
            pass

    return violations


# ── Main runner ──────────────────────────────────────────────────────────


def find_python_files(scan_path: str) -> list[str]:
    """Find all Python files under the scan path."""
    files = []
    for root, _, filenames in os.walk(scan_path):
        # Skip common non-source directories
        if any(skip in root for skip in [".venv", "__pycache__", "node_modules", ".git"]):
            continue
        for fname in filenames:
            if fname.endswith(".py"):
                files.append(os.path.join(root, fname))
    return files


def format_github_annotation(violation: dict, rules_map: dict) -> str:
    """Format a violation as a GitHub Actions annotation."""
    rule_info = rules_map.get(violation["rule"], {})
    severity = rule_info.get("severity", "error")
    fix = rule_info.get("fix", "")

    msg = f"[{violation['rule']}] {violation['message']}"
    if fix:
        msg += f" Fix: {fix}"

    annotation_type = "error" if severity == "error" else "warning"
    file_part = f"file={violation['file']}" if violation["file"] else ""
    line_part = f",line={violation['line']}" if violation["line"] else ""

    return f"::{annotation_type} {file_part}{line_part}::{msg}"


def main():
    parser = argparse.ArgumentParser(description="ENTR Adapter Coding Standards Checker")
    parser.add_argument("--path", default="app/", help="Path to scan for Python files (default: app/)")
    parser.add_argument("--rules", default=None, help="Path to rules.yaml (default: auto-detect)")
    args = parser.parse_args()

    # Auto-detect rules.yaml location
    rules_path = args.rules
    if not rules_path:
        candidates = [
            "checks/rules.yaml",
            os.path.join(os.path.dirname(__file__), "rules.yaml"),
        ]
        for c in candidates:
            if os.path.exists(c):
                rules_path = c
                break
    if not rules_path or not os.path.exists(rules_path):
        print("::error::Could not find rules.yaml — provide --rules path")
        sys.exit(1)

    rules = load_rules(rules_path)
    rules_map = {r["id"]: r for r in rules}
    enabled_ast = {r["id"] for r in rules if r["type"] == "ast"}
    grep_rules = [r for r in rules if r["type"] == "grep"]
    ruff_rules = [r for r in rules if r["type"] == "ruff"]

    files = find_python_files(args.path)
    if not files:
        print(f"No Python files found in {args.path}")
        sys.exit(0)

    print(f"Scanning {len(files)} Python files with {len(rules)} rules...")

    all_violations = []

    # AST checks
    for filepath in files:
        violations = run_ast_checks(filepath, enabled_ast)
        all_violations.extend(violations)

    # Grep checks
    for rule in grep_rules:
        violations = run_grep_check(rule, files)
        all_violations.extend(violations)

    # Ruff checks
    for rule in ruff_rules:
        violations = run_ruff_check(rule, files)
        all_violations.extend(violations)

    # Output results
    if not all_violations:
        print("✅ All coding standard checks passed!")
        sys.exit(0)

    # Group by severity
    errors = [v for v in all_violations if rules_map.get(v["rule"], {}).get("severity") == "error"]
    warnings = [v for v in all_violations if rules_map.get(v["rule"], {}).get("severity") != "error"]

    # GitHub annotations
    for v in all_violations:
        print(format_github_annotation(v, rules_map))

    # Summary
    print()
    print(f"{'='*60}")
    print(f"ENTR Coding Standards Report")
    print(f"{'='*60}")
    print(f"Files scanned: {len(files)}")
    print(f"Total violations: {len(all_violations)}")
    print(f"  Errors: {len(errors)}")
    print(f"  Warnings: {len(warnings)}")
    print()

    # Summary table for GITHUB_STEP_SUMMARY
    summary_file = os.environ.get("GITHUB_STEP_SUMMARY")
    if summary_file:
        with open(summary_file, "a") as f:
            f.write("## Coding Standards Report\n\n")
            f.write(f"| Metric | Count |\n|--------|-------|\n")
            f.write(f"| Files scanned | {len(files)} |\n")
            f.write(f"| Errors | {len(errors)} |\n")
            f.write(f"| Warnings | {len(warnings)} |\n\n")

            if all_violations:
                f.write("### Violations\n\n")
                f.write("| Rule | File | Line | Message |\n|------|------|------|---------|\n")
                for v in all_violations:
                    f.write(f"| {v['rule']} | {v['file']} | {v['line']} | {v['message'][:80]} |\n")

    # Exit with error if any errors found
    if errors:
        sys.exit(1)
    sys.exit(0)


if __name__ == "__main__":
    main()
