# engine/product/enforcer.py
"""Enforcement engine — decision lockfile export and violation checking.

Three responsibilities:
1. Export: serialize accepted decisions from SurrealDB to .ace/decisions.yml
2. Check: given a file path + optional diff, return violations against lockfile
3. Install: write git pre-commit hook that calls `ace_enforce check-staged`

The lockfile (.ace/decisions.yml) is a materialized view of SurrealDB.
Source of truth stays in DB — lockfile is generated on demand and gitignored.
Git hooks and CI read the lockfile because they cannot make async DB calls.
"""

from __future__ import annotations

import fnmatch
import logging
import os
import stat
import subprocess

import yaml

logger = logging.getLogger(__name__)

_LOCKFILE_DEFAULT = ".ace/decisions.yml"
_CONFIG_FILE = ".ace/enforce.config.yml"
_GIT_HOOK_PATH = ".git/hooks/pre-commit"

_CONFIG_TEMPLATE = """\
# .ace/enforce.config.yml — Enforcement configuration.
# Edit freely and commit to git.
# Regenerate lockfile: ace_export_decisions

version: "1"

# Global enforcement mode: off | warn | block
# Overrides per-decision mode when set to "block" or "off".
global_mode: "warn"

# Exclude these paths from all checks
exclude_patterns:
  - "venv/**"
  - ".venv/**"
  - "*.min.js"
  - "docs/**"
  - "node_modules/**"

# PR score-delta gates (for D3 integration — disabled by default)
pr_gate:
  enabled: false
  max_testing_drop: -10
  max_security_drop: -5
"""

_GIT_HOOK_SCRIPT = """\
#!/bin/sh
# ACE Decision Guard — pre-commit hook.
# Generated by ace_enforce install-hook. Safe to delete if not using ACE enforcement.
if command -v python3 >/dev/null 2>&1; then
    python3 -m engine.product.enforcer check-staged 2>/dev/null || true
fi
"""


async def export_decisions(
    product_id: str,
    output_path: str = _LOCKFILE_DEFAULT,
    mode: str = "warn",
) -> dict:
    """Export accepted decisions from SurrealDB to .ace/decisions.yml.

    For each accepted decision:
    - Fetches capability slugs via affected edges
    - Derives file glob patterns from graph_file paths via realizes edges
    - Writes structured YAML lockfile

    Args:
        product_id:  Product to export decisions for.
        output_path: Lockfile output path. Default: .ace/decisions.yml
        mode:        Default enforcement mode (warn|block|off) for decisions
                     that don't have an explicit enforcement_mode set.

    Returns: {exported: int, output_path: str, warnings: list[str]}
    """
    from core.engine.core.db import parse_rows, pool

    warnings: list[str] = []
    decisions_data = []

    async with pool.connection() as db:
        raw = parse_rows(
            await db.query(
                "SELECT * FROM decision WHERE product = <record>$product "
                "AND outcome = 'accepted' ORDER BY created_at DESC",
                {"product": product_id},
            )
        )

    for dec in raw:
        dec_id = str(dec["id"])

        # Capability slugs via affected edges
        try:
            async with pool.connection() as db:
                cap_rows = parse_rows(
                    await db.query(
                        "SELECT cap.slug FROM affected WHERE in = <record>$d FETCH out AS cap",
                        {"d": dec_id},
                    )
                )
            cap_slugs = [r.get("cap", {}).get("slug") or r.get("slug", "") for r in cap_rows if r]
            cap_slugs = [s for s in cap_slugs if s]
        except Exception as e:
            logger.debug("Capability slug fetch failed for %s: %s", dec_id, e)
            cap_slugs = []

        # File glob patterns via capability → realizes edges
        file_patterns: list[str] = []
        try:
            async with pool.connection() as db:
                file_rows = parse_rows(
                    await db.query(
                        """SELECT gf.path FROM affected AS a
                        JOIN realizes AS r ON r.out = a.out
                        JOIN graph_file AS gf ON gf.id = r.in
                        WHERE a.in = <record>$d
                        LIMIT 20""",
                        {"d": dec_id},
                    )
                )
            paths = [r.get("path") or r.get("gf", {}).get("path", "") for r in file_rows if r]
            paths = [p for p in paths if p]
            file_patterns = _paths_to_globs(paths) if paths else []
        except Exception as e:
            logger.debug("File pattern fetch failed for %s: %s", dec_id, e)
            file_patterns = []

        # Determine violation check type and pattern from decision content
        violation_check, violation_pattern = _infer_violation_check(dec)

        enforcement_mode = dec.get("enforcement_mode") or mode

        decisions_data.append(
            {
                "id": dec_id,
                "title": dec.get("title", ""),
                "decision_type": dec.get("decision_type", ""),
                "rationale": (dec.get("rationale") or "")[:200],
                "enforcement_mode": enforcement_mode,
                "discipline": dec.get("discipline_hint") or dec.get("discipline", ""),
                "capabilities": cap_slugs,
                "file_patterns": file_patterns,
                "violation_check": violation_check,
                "violation_pattern": violation_pattern,
                "created_at": str(dec.get("created_at", "")),
            }
        )

    if not decisions_data and not raw:
        warnings.append("No accepted decisions found. Run ace_capture_decision first.")

    # Build lockfile content
    import datetime

    lockfile = {
        "version": "1",
        "product": product_id,
        "generated_at": datetime.datetime.now(datetime.UTC).isoformat(),
        "default_mode": mode,
        "decisions": decisions_data,
    }

    # Ensure .ace/ directory exists
    output_dir = os.path.dirname(output_path) or "."
    os.makedirs(output_dir, exist_ok=True)

    with open(output_path, "w") as f:
        yaml.dump(lockfile, f, default_flow_style=False, allow_unicode=True, sort_keys=False)

    # Write default config if not present
    if not os.path.exists(_CONFIG_FILE):
        os.makedirs(os.path.dirname(_CONFIG_FILE), exist_ok=True)
        with open(_CONFIG_FILE, "w") as f:
            f.write(_CONFIG_TEMPLATE)

    return {
        "exported": len(decisions_data),
        "output_path": output_path,
        "warnings": warnings,
    }


def _paths_to_globs(paths: list[str]) -> list[str]:
    """Convert concrete file paths to glob patterns.

    Groups paths by common directory prefix + adds ** suffix.
    Single path: returns ["path/to/file.py"]
    Multiple paths in same dir: returns ["path/to/dir/**"]
    """
    if not paths:
        return []
    if len(paths) == 1:
        return [paths[0]]

    # Find common prefix directory
    dirs = list({os.path.dirname(p) for p in paths if p})
    if len(dirs) == 1 and dirs[0]:
        return [f"{dirs[0]}/**"]

    # Multiple directories — return individual file patterns (capped at 5)
    return paths[:5]


def _infer_violation_check(decision: dict) -> tuple[str, str]:
    """Infer violation check type and pattern from decision content.

    Uses decision fields that may have been set via enforcement_mode/violation_check
    columns added in schema migration. Falls back to heuristic inference.

    Returns: (check_type, pattern)
      check_type: "contains" | "regex" | "llm"
      pattern:    string to match, or "" if no simple pattern
    """
    # Explicit fields take precedence (schema additions from E1 spec)
    if decision.get("violation_check"):
        return decision["violation_check"], decision.get("violation_pattern") or ""

    # Heuristic: extract quoted code references from rationale/title
    import re

    text = f"{decision.get('title', '')} {decision.get('rationale', '')}"
    # Look for backtick-quoted identifiers: `ClaudeProvider()` → contains check
    quoted = re.findall(r"`([^`]+)`", text)
    if quoted:
        return "contains", quoted[0]

    return "llm", ""


async def check_file(
    file_path: str,
    diff_content: str | None = None,
    lockfile_path: str = _LOCKFILE_DEFAULT,
) -> dict:
    """Check a file path against the decision lockfile for violations.

    Args:
        file_path:     File path to check (relative or absolute).
        diff_content:  Optional diff content for contains/regex matching.
        lockfile_path: Path to the lockfile. Default: .ace/decisions.yml

    Returns: {violations: list[dict], clean: bool, decisions_checked: int}
    """
    if not os.path.exists(lockfile_path):
        return {"violations": [], "clean": True, "decisions_checked": 0, "lockfile_missing": True}

    try:
        with open(lockfile_path) as f:
            lockfile = yaml.safe_load(f) or {}
    except Exception as e:
        logger.warning("Lockfile parse failed: %s", e)
        return {"violations": [], "clean": True, "decisions_checked": 0, "error": str(e)}

    config = _load_config()
    global_mode = config.get("global_mode", "warn")
    exclude_patterns = config.get("exclude_patterns", [])

    rel_path = os.path.relpath(file_path)

    # Check excludes
    for pattern in exclude_patterns:
        if fnmatch.fnmatch(rel_path, pattern):
            return {"violations": [], "clean": True, "decisions_checked": 0, "excluded": True}

    decisions = lockfile.get("decisions", [])
    violations = []

    for decision in decisions:
        patterns = decision.get("file_patterns", [])
        if not patterns:
            continue  # No file scope — skip

        # Check if file matches any pattern
        if not any(fnmatch.fnmatch(rel_path, p) for p in patterns):
            continue

        # Run violation check
        triggered = _run_violation_check(decision, rel_path, diff_content)
        if not triggered:
            continue

        mode = global_mode if global_mode in ("block", "off") else decision.get("enforcement_mode", "warn")
        if mode == "off":
            continue

        violations.append(
            {
                "decision_id": decision.get("id", ""),
                "title": decision.get("title", ""),
                "rationale": (decision.get("rationale") or "")[:120],
                "mode": mode,
                "file": rel_path,
                "violation_check": decision.get("violation_check", ""),
                "violation_pattern": decision.get("violation_pattern", ""),
            }
        )

    return {
        "violations": violations,
        "clean": len(violations) == 0,
        "decisions_checked": len(decisions),
    }


def _run_violation_check(
    decision: dict,
    file_path: str,
    diff_content: str | None,
) -> bool:
    """Run the violation check for a single decision against a file.

    Returns True if a violation is detected.
    """
    check_type = decision.get("violation_check", "llm")
    pattern = decision.get("violation_pattern", "")

    if check_type == "contains" and pattern:
        # Check file path first, then diff content
        if pattern.lower() in file_path.lower():
            return True
        if diff_content and pattern in diff_content:
            return True
        return False

    if check_type == "regex" and pattern:
        import re

        if re.search(pattern, file_path):
            return True
        if diff_content and re.search(pattern, diff_content):
            return True
        return False

    if check_type == "llm":
        # LLM checks are not run in fast paths (git hooks, PreToolUse).
        # Return True (flag for review) when file matches pattern scope AND
        # check type is LLM — the LLM call happens in ace_enforce check only.
        return True  # Conservative: flag all scope matches for LLM-mode decisions

    # Unknown check type — treat as match (conservative)
    return True


async def check_staged(lockfile_path: str = _LOCKFILE_DEFAULT) -> dict:
    """Check all staged git files against the lockfile.

    Uses `git diff --staged --name-only` to get staged file paths and
    `git diff --staged` for full diff content.

    Returns: {violations: list[dict], blocked: bool, files_checked: int}
    """
    if not os.path.exists(lockfile_path):
        return {"violations": [], "blocked": False, "files_checked": 0, "lockfile_missing": True}

    try:
        staged_files = (
            subprocess.check_output(
                ["git", "diff", "--staged", "--name-only"],
                stderr=subprocess.DEVNULL,
                timeout=5,
            )
            .decode()
            .splitlines()
        )
        diff_content = subprocess.check_output(
            ["git", "diff", "--staged"],
            stderr=subprocess.DEVNULL,
            timeout=10,
        ).decode()
    except Exception as e:
        logger.debug("git diff failed: %s", e)
        return {"violations": [], "blocked": False, "files_checked": 0, "error": str(e)}

    all_violations: list[dict] = []
    for file_path in staged_files:
        result = await check_file(file_path, diff_content, lockfile_path)
        all_violations.extend(result.get("violations", []))

    has_block = any(v.get("mode") == "block" for v in all_violations)

    return {
        "violations": all_violations,
        "blocked": has_block,
        "files_checked": len(staged_files),
    }


async def check_diff(
    base_ref: str = "origin/main",
    lockfile_path: str = _LOCKFILE_DEFAULT,
) -> dict:
    """Check all files changed since base_ref against the lockfile.

    Uses three-dot diff (`git diff <base>...HEAD`) so rebases don't create
    false positives — compares against the merge-base, not the branch tip.

    Returns: {violations: list[dict], blocked: bool, files_checked: int}
    """
    if not os.path.exists(lockfile_path):
        return {"violations": [], "blocked": False, "files_checked": 0, "lockfile_missing": True}

    try:
        changed_files = (
            subprocess.check_output(
                ["git", "diff", "--name-only", f"{base_ref}...HEAD"],
                stderr=subprocess.DEVNULL,
                timeout=10,
            )
            .decode()
            .splitlines()
        )
        diff_content = subprocess.check_output(
            ["git", "diff", f"{base_ref}...HEAD"],
            stderr=subprocess.DEVNULL,
            timeout=30,
        ).decode()
    except Exception as e:
        logger.debug("git diff failed: %s", e)
        return {"violations": [], "blocked": False, "files_checked": 0, "error": str(e)}

    all_violations: list[dict] = []
    for file_path in changed_files:
        result = await check_file(file_path, diff_content, lockfile_path)
        all_violations.extend(result.get("violations", []))

    has_block = any(v.get("mode") == "block" for v in all_violations)
    return {
        "violations": all_violations,
        "blocked": has_block,
        "files_checked": len(changed_files),
    }


def _cli_check_diff() -> None:
    """CLI wrapper for CI use — checks all files changed vs base branch.

    Usage: python3 -m engine.product.enforcer check-diff [base-ref]
    Default base-ref: origin/main
    Exit code 1 on blocking violations, 0 otherwise.
    """
    import asyncio
    import sys

    base_ref = sys.argv[2] if len(sys.argv) > 2 else "origin/main"
    result = asyncio.run(check_diff(base_ref))

    violations = result.get("violations", [])
    if violations:
        for v in violations:
            mode = v.get("mode", "warn")
            prefix = "[BLOCK]" if mode == "block" else "[WARN] "
            print(f"  {prefix} {v['title']} — {v.get('file', '')}", file=sys.stderr)
            if v.get("rationale"):
                print(f"         {v['rationale'][:120]}", file=sys.stderr)

    if result.get("blocked"):
        print(
            f"\nACE Decision Guard: {sum(1 for v in violations if v.get('mode') == 'block')} "
            f"blocking violation(s) in {result.get('files_checked', 0)} changed files.",
            file=sys.stderr,
        )
        sys.exit(1)
    elif violations:
        print(
            f"\nACE Decision Guard: {len(violations)} warning(s) — no blocking violations.",
            file=sys.stderr,
        )


def install_git_hook(project_root: str = ".") -> dict:
    """Write a git pre-commit hook that runs ACE decision checks.

    Creates .git/hooks/pre-commit with the ACE enforcement script.
    Sets executable bit. Safe to re-run (overwrites existing hook).

    Args:
        project_root: Root of the git repo. Default: current directory.

    Returns: {installed: bool, hook_path: str, message: str}
    """
    hook_path = os.path.join(project_root, _GIT_HOOK_PATH)
    hook_dir = os.path.dirname(hook_path)

    if not os.path.isdir(hook_dir):
        return {
            "installed": False,
            "hook_path": hook_path,
            "message": f"Not a git repository (no {hook_dir})",
        }

    with open(hook_path, "w") as f:
        f.write(_GIT_HOOK_SCRIPT)

    # Set executable bit
    current = os.stat(hook_path).st_mode
    os.chmod(hook_path, current | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)

    return {
        "installed": True,
        "hook_path": hook_path,
        "message": "Pre-commit hook installed. Runs ace_enforce check-staged on every commit.",
    }


def _load_config() -> dict:
    """Load enforcement config from .ace/enforce.config.yml.

    Returns empty dict with defaults if file missing.
    """
    if not os.path.exists(_CONFIG_FILE):
        return {"global_mode": "warn", "exclude_patterns": ["venv/**", ".venv/**", "*.min.js"]}
    try:
        with open(_CONFIG_FILE) as f:
            return yaml.safe_load(f) or {}
    except Exception:
        return {"global_mode": "warn", "exclude_patterns": []}


# ── CLI entry point (for git hook subprocess call) ──────────────────────────


def _cli_check_staged() -> None:
    """Synchronous CLI wrapper for git hook use.

    Called by: python3 -m engine.product.enforcer check-staged
    Exit code 1 on blocking violations, 0 otherwise.
    """
    import asyncio
    import sys

    result = asyncio.run(check_staged())
    if result.get("blocked"):
        violations = result.get("violations", [])
        print("ACE Decision Guard — blocking violations detected:", file=sys.stderr)
        for v in violations:
            if v.get("mode") == "block":
                print(f"  [BLOCK] {v['title']}", file=sys.stderr)
                if v.get("rationale"):
                    print(f"         {v['rationale'][:100]}", file=sys.stderr)
        print(
            "\nTo override: change enforcement_mode to 'warn' in .ace/decisions.yml "
            "or supersede the decision via ace_capture_decision.",
            file=sys.stderr,
        )
        sys.exit(1)


if __name__ == "__main__":
    import sys

    if len(sys.argv) > 1 and sys.argv[1] == "check-staged":
        _cli_check_staged()
    elif len(sys.argv) > 1 and sys.argv[1] == "check-diff":
        _cli_check_diff()
