"""Audit script: ensure no production code in src/ calls the models I/O primitives directly.

Architecture rule: AppController owns the config I/O. The
models._load_config_from_disk and models._save_config_to_disk
functions are private file I/O primitives. Direct callers in src/
are an architectural smell (bypassing the controller state owner).

The only allowed call sites are inside AppController itself.

Usage:
    python scripts/audit_no_models_config_io.py            # human-readable report
    python scripts/audit_no_models_config_io.py --json     # JSON output for CI
    python scripts/audit_no_models_config_io.py --strict   # exit 1 on violations
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
from pathlib import Path

# Patterns that are architectural smells in production code.
# These are the I/O primitives; only AppController should call them.
FORBIDDEN_PATTERNS = [
    (re.compile(r"\bmodels\._load_config_from_disk\s*\("), "models._load_config_from_disk"),
    (re.compile(r"\bmodels\._save_config_to_disk\s*\("), "models._save_config_to_disk"),
]

# The OLD public names. After the rename these should not exist anywhere.
LEGACY_NAMES = [
    (re.compile(r"\bmodels\.load_config\s*\("), "models.load_config"),
    (re.compile(r"\bmodels\.save_config\s*\("), "models.save_config"),
]

# Files where these calls are LEGITIMATE.
ALLOWED_FILES = {
    "src/app_controller.py",   # the only public owner of the I/O
    "src/models.py",           # the module that defines them
    "tests/test_models_no_top_level_tomli_w.py",  # tests lazy-load behavior
}

# Source roots to scan
SOURCE_ROOTS = ["src"]


def find_violations() -> list[dict[str, object]]:
    """Scan src/ for direct calls to the forbidden config I/O primitives."""
    violations: list[dict[str, object]] = []
    for root in SOURCE_ROOTS:
        if not os.path.isdir(root):
            continue
        for dirpath, _dirs, files in os.walk(root):
            for fname in files:
                if not fname.endswith(".py"):
                    continue
                path = os.path.join(dirpath, fname)
                # Normalize to forward slashes for matching
                norm = path.replace(os.sep, "/")
                if norm in ALLOWED_FILES:
                    continue
                with open(path, encoding="utf-8", errors="replace") as f:
                    src = f.read()
                docstring_lines = _docstring_lines(src)
                for lineno, line in enumerate(src.splitlines(), start=1):
                    if lineno in docstring_lines:
                        continue
                    stripped = line.lstrip()
                    if stripped.startswith("#"):
                        continue
                    for pattern, name in FORBIDDEN_PATTERNS:
                        if pattern.search(line):
                            violations.append({
                                "file": path,
                                "line": lineno,
                                "pattern": name,
                                "text": line.rstrip(),
                                "severity": "error",
                            })
                    for pattern, name in LEGACY_NAMES:
                        if pattern.search(line):
                            violations.append({
                                "file": path,
                                "line": lineno,
                                "pattern": name,
                                "text": line.rstrip(),
                                "severity": "error",
                            })
    return violations


def _docstring_lines(src: str) -> set[int]:
    """Return a set of 1-based line numbers that are inside a docstring.

    Uses the AST to find module/class/function docstrings, then expands
    the string node's line range. Multi-line strings are included in
    full so any code-looking text inside them is ignored.
    """
    import ast
    lines: set[int] = set()
    try:
        tree = ast.parse(src)
    except SyntaxError:
        return lines
    for node in ast.walk(tree):
        if not isinstance(node, (ast.Module, ast.FunctionDef, ast.AsyncFunctionDef,
                                 ast.ClassDef)):
            continue
        doc = ast.get_docstring(node, clean=False)
        if not doc or not node.body:
            continue
        first = node.body[0]
        if not isinstance(first, ast.Expr) or not isinstance(first.value, ast.Constant):
            continue
        if not isinstance(first.value.value, str):
            continue
        start = first.lineno
        end = getattr(first, "end_lineno", None) or start
        for ln in range(start, end + 1):
            lines.add(ln)
    return lines


def main() -> int:
    parser = argparse.ArgumentParser(
        description="Audit for direct calls to models config I/O primitives in src/"
    )
    parser.add_argument("--json", action="store_true", help="JSON output for CI")
    parser.add_argument("--strict", action="store_true", help="Exit 1 on any violation")
    args = parser.parse_args()

    violations = find_violations()

    if args.json:
        print(json.dumps({"violations": violations, "count": len(violations)}, indent=2))
    else:
        print("=" * 70)
        print("Architectural audit: models config I/O usage in src/")
        print("=" * 70)
        print()
        print("Rule: AppController owns config I/O. Direct calls to")
        print("  - models._load_config_from_disk(...)")
        print("  - models._save_config_to_disk(...)")
        print("  - models.load_config(...)  (legacy)")
        print("  - models.save_config(...)  (legacy)")
        print("from outside AppController are architectural smells.")
        print()
        print(f"Allowed call sites: {sorted(ALLOWED_FILES)}")
        print()
        if not violations:
            print("OK - no violations found.")
        else:
            print(f"Found {len(violations)} violation(s):")
            print()
            for v in violations:
                print(f"  {v['file']}:{v['line']}: {v['pattern']}")
                print(f"    {v['text']}")
                print()

    if args.strict and violations:
        return 1
    return 0


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