"""memory_drift_check — Memory Governance Protocol drift checker.

Verifies deployed memory files in ``memory/`` against template contracts
and policy rules from ``.abi/memory_policy.yml``.

Check categories (see docs/governance/MEMORY_VERIFIER_ALGORITHM.md):
    unfilled_markers  — Detect unfilled <!-- REQUIRED: ... --> markers
    required_sections — Detect missing ## Section headings per file type
    secret_scan       — Detect high-signal secret patterns (always FAIL)
    template_drift    — Detect deployed files older than their templates

Usage::

    from memory_drift_check import run_check, CheckResult
    result = run_check(repo_root=Path("."))
    print(result.overall)   # "PASS" | "WARN" | "FAIL"

CLI: see ``script/memory-drift-check`` (bash shim).
"""
from __future__ import annotations

import json
import re
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Literal

# ---------------------------------------------------------------------------
# Types
# ---------------------------------------------------------------------------

Severity = Literal["PASS", "WARN", "FAIL"]


@dataclass
class Finding:
    category: str
    severity: Severity
    detail: str


@dataclass
class FileResult:
    path: str
    verdict: Severity
    findings: list[Finding] = field(default_factory=list)


@dataclass
class CheckResult:
    overall: Severity
    enforcement: str
    files: list[FileResult] = field(default_factory=list)

    def exit_code(self) -> int:
        if self.enforcement == "strict":
            return 0 if self.overall == "PASS" else 1
        if self.enforcement == "block":
            return 1 if self.overall == "FAIL" else 0
        return 0  # warn mode: always 0


# ---------------------------------------------------------------------------
# Policy loading
# ---------------------------------------------------------------------------

_DEFAULT_POLICY: dict = {
    "enforcement_mode": "warn",
    "freshness_days": 30,
    "disabled_checks": [],
    "skip_template_drift": False,
    "additional_disallowed_patterns": [],
    "memory_dir": "memory",
    "checked_extensions": [".md"],
}

# Required sections per file stem (mirrors MEMORY_VERIFIER_ALGORITHM.md §3.2)
_REQUIRED_SECTIONS: dict[str, list[str]] = {
    "STATE": ["## Current State", "## Phase", "## Known Issues"],
    "TASKS": ["## Active", "## Backlog", "## Completed"],
    "DECISIONS": ["## Decision Log", "## Pending"],
    "RISKS": ["## Risk Register", "## Mitigations"],
    "INTERFACES": ["## Interface Ledger", "## Contracts"],
    "GLOSSARY": ["## Terms"],
    "RESUME_PROMPT": ["## Context", "## Last Known State", "## Next Steps"],
}

# Built-in high-signal secret patterns (always enforced)
_BUILTIN_SECRET_PATTERNS: list[str] = [
    r"-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----",
    r"AKIA[A-Z0-9]{16}",
    r"ghp_[a-zA-Z0-9]{36}",
    r"gho_[a-zA-Z0-9]{36}",
    r"sk-ant-[a-zA-Z0-9_\-]{20,}",
    r"sk-[a-zA-Z0-9]{20,}",
]

_UNFILLED_MARKER_RE = re.compile(r"<!--\s*REQUIRED:\s*\w+\s*-->")


def _load_policy(repo_root: Path) -> dict:
    policy_path = repo_root / ".abi" / "memory_policy.yml"
    policy = dict(_DEFAULT_POLICY)
    if not policy_path.exists():
        return policy
    try:
        # Use stdlib only — no PyYAML dependency
        content = policy_path.read_text(encoding="utf-8")
        for line in content.splitlines():
            line = line.strip()
            if not line or line.startswith("#"):
                continue
            if ":" in line:
                key, _, val = line.partition(":")
                key = key.strip()
                val = val.strip()
                if key == "enforcement_mode":
                    policy["enforcement_mode"] = val
                elif key == "freshness_days":
                    try:  # noqa: SIM105
                        policy["freshness_days"] = int(val)
                    except ValueError:
                        pass
                elif key == "skip_template_drift":
                    policy["skip_template_drift"] = val.lower() in ("true", "yes", "1")
    except OSError:
        pass
    return policy


# ---------------------------------------------------------------------------
# Check functions
# ---------------------------------------------------------------------------


def _check_unfilled_markers(content: str) -> list[Finding]:
    findings: list[Finding] = []
    for match in _UNFILLED_MARKER_RE.finditer(content):
        findings.append(Finding(
            category="unfilled_markers",
            severity="WARN",
            detail=f"unfilled marker: {match.group().strip()}",
        ))
    return findings


def _check_required_sections(stem: str, content: str) -> list[Finding]:
    required = _REQUIRED_SECTIONS.get(stem.upper(), [])
    if not required:
        return []
    lines = set(content.splitlines())
    findings: list[Finding] = []
    for section in required:
        if not any(line.strip() == section for line in lines):
            findings.append(Finding(
                category="required_sections",
                severity="WARN",
                detail=f"missing section: {section}",
            ))
    return findings


def _check_secrets(content: str, extra_patterns: list[str]) -> list[Finding]:
    findings: list[Finding] = []
    all_patterns = _BUILTIN_SECRET_PATTERNS + extra_patterns
    for pattern in all_patterns:
        try:
            m = re.search(pattern, content)
        except re.error:
            continue
        if m:
            # Redact matched text
            snippet = m.group()[:20] + "..." if len(m.group()) > 20 else m.group()
            findings.append(Finding(
                category="secret_scan",
                severity="FAIL",
                detail=f"secret pattern match: {snippet}",
            ))
    return findings


def _check_template_drift(file_path: Path, template_path: Path | None) -> list[Finding]:
    if template_path is None or not template_path.exists():
        return []
    try:
        file_mtime = file_path.stat().st_mtime
        template_mtime = template_path.stat().st_mtime
    except OSError:
        return []
    if template_mtime > file_mtime:
        return [Finding(
            category="template_drift",
            severity="WARN",
            detail="template is newer than deployed file — re-verify recommended",
        )]
    return []


def _compute_file_verdict(findings: list[Finding]) -> Severity:
    if any(f.severity == "FAIL" for f in findings):
        return "FAIL"
    if any(f.severity == "WARN" for f in findings):
        return "WARN"
    return "PASS"


def _compute_overall(file_results: list[FileResult]) -> Severity:
    if any(r.verdict == "FAIL" for r in file_results):
        return "FAIL"
    if any(r.verdict == "WARN" for r in file_results):
        return "WARN"
    return "PASS"


# ---------------------------------------------------------------------------
# File collection
# ---------------------------------------------------------------------------

_MEMORY_EXCLUDES = {"templates", "archive"}


def _collect_memory_files(memory_dir: Path, extensions: list[str]) -> list[Path]:
    if not memory_dir.is_dir():
        return []
    paths: list[Path] = []
    for p in sorted(memory_dir.rglob("*")):
        if not p.is_file():
            continue
        if p.suffix not in extensions:
            continue
        parts = p.relative_to(memory_dir).parts
        if parts[0] in _MEMORY_EXCLUDES:
            continue
        # Exclude README.md at memory root
        if p.name == "README.md" and p.parent == memory_dir:
            continue
        paths.append(p)
    return sorted(paths)


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------


def run_check(
    repo_root: Path,
    *,
    files: list[Path] | None = None,
    policy: dict | None = None,
    enforcement: str | None = None,
) -> CheckResult:
    """Run the memory drift check.

    Parameters
    ----------
    repo_root:
        Repository root.
    files:
        Explicit list of files to check. Overrides auto-discovery from memory/.
    policy:
        Loaded policy dict (for testing). If None, loaded from .abi/memory_policy.yml.
    enforcement:
        Override enforcement mode ("warn" | "block" | "strict").
    """
    if policy is None:
        policy = _load_policy(repo_root)

    effective_enforcement = enforcement or policy.get("enforcement_mode", "warn")
    disabled = set(policy.get("disabled_checks", []))
    extra_patterns = list(policy.get("additional_disallowed_patterns", []))
    skip_drift = policy.get("skip_template_drift", False)
    extensions = list(policy.get("checked_extensions", [".md"]))

    memory_dir = repo_root / policy.get("memory_dir", "memory")
    templates_dir = memory_dir / "templates"

    if files is None:  # noqa: SIM108
        target_files = _collect_memory_files(memory_dir, extensions)
    else:
        target_files = sorted(files)

    file_results: list[FileResult] = []

    for file_path in target_files:
        try:
            content = file_path.read_text(encoding="utf-8")
        except OSError:
            continue

        findings: list[Finding] = []

        if "unfilled_markers" not in disabled:
            findings.extend(_check_unfilled_markers(content))

        if "required_sections" not in disabled:
            stem = file_path.stem
            findings.extend(_check_required_sections(stem, content))

        if "secret_scan" not in disabled:
            findings.extend(_check_secrets(content, extra_patterns))

        if "template_drift" not in disabled and not skip_drift:
            template_path = templates_dir / file_path.name
            findings.extend(_check_template_drift(file_path, template_path))

        # Relative path for display
        try:
            display_path = file_path.relative_to(repo_root).as_posix()
        except ValueError:
            display_path = str(file_path)

        verdict = _compute_file_verdict(findings)
        file_results.append(FileResult(
            path=display_path,
            verdict=verdict,
            findings=findings,
        ))

    overall = _compute_overall(file_results)

    return CheckResult(
        overall=overall,
        enforcement=effective_enforcement,
        files=file_results,
    )


# ---------------------------------------------------------------------------
# Output formatters
# ---------------------------------------------------------------------------


def format_human(result: CheckResult) -> str:
    lines = ["Memory Drift Check", "=" * 18]
    for fr in result.files:
        if fr.findings:
            for f in fr.findings:
                lines.append(f"  [{fr.verdict}] {fr.path} — {f.detail}")
        else:
            lines.append(f"  [PASS] {fr.path}")

    passed = sum(1 for r in result.files if r.verdict == "PASS")
    warned = sum(1 for r in result.files if r.verdict == "WARN")
    failed = sum(1 for r in result.files if r.verdict == "FAIL")
    lines.append("")
    lines.append(f"Results: {passed} passed, {warned} warned, {failed} failed")
    lines.append(f"Overall: {result.overall}")
    return "\n".join(lines)


def format_json(result: CheckResult) -> str:
    data = {
        "enforcement": result.enforcement,
        "files": [
            {
                "findings": [
                    {
                        "category": f.category,
                        "detail": f.detail,
                        "severity": f.severity,
                    }
                    for f in fr.findings
                ],
                "path": fr.path,
                "verdict": fr.verdict,
            }
            for fr in result.files
        ],
        "overall": result.overall,
    }
    return json.dumps(data, indent=2, sort_keys=True)


# ---------------------------------------------------------------------------
# CLI entry point
# ---------------------------------------------------------------------------


def main(argv: list[str] | None = None) -> int:
    import argparse

    parser = argparse.ArgumentParser(
        description="Check deployed memory files for drift, policy violations, and secrets."
    )
    parser.add_argument(
        "--files",
        nargs="*",
        metavar="PATH",
        help="Specific file(s) or director(y|ies) to check instead of auto-discovery.",
    )
    parser.add_argument(
        "--strict",
        action="store_true",
        help="Exit 1 on any FAIL or WARN finding.",
    )
    parser.add_argument(
        "--block",
        action="store_true",
        help="Exit 1 on any FAIL finding (WARN still exits 0).",
    )
    parser.add_argument(
        "--json",
        dest="json_out",
        action="store_true",
        help="Emit JSON output instead of human-readable text.",
    )
    parser.add_argument(
        "--repo-root",
        metavar="PATH",
        help="Repository root (default: parent of script/_lib/).",
    )
    args = parser.parse_args(argv)

    repo_root = (
        Path(args.repo_root).resolve()
        if args.repo_root
        else Path(__file__).resolve().parent.parent.parent
    )

    enforcement = "warn"
    if args.strict:
        enforcement = "strict"
    elif args.block:
        enforcement = "block"

    # Resolve --files: expand directories and globs
    explicit_files: list[Path] | None = None
    if args.files:
        explicit_files = []
        for f in args.files:
            p = Path(f)
            if not p.is_absolute():
                p = repo_root / p
            if p.is_dir():
                # Apply the same exclusions as auto-discovery
                explicit_files.extend(_collect_memory_files(p, [".md"]))
            elif p.is_file():
                explicit_files.append(p)
            # If neither exists, skip silently (empty result)

    result = run_check(repo_root=repo_root, files=explicit_files, enforcement=enforcement)

    if args.json_out:
        print(format_json(result))
    else:
        print(format_human(result))

    return result.exit_code()


if __name__ == "__main__":
    sys.exit(main())
