"""Dependency Gate for CrystFlux v1 triadic structure.

Intent:
  Prevent dependency backflow by enforcing triadic import rules (AST-based, no grep).

Non-goals:
  - Do not infer runtime call flow (this is about imports only).
  - Do not validate third-party imports or circular imports within a layer.
  - Do not scan tests; tests may depend on any layer.

This script enforces the one-way dependency rule using real Python imports (AST-based):

- `crystflux.v1.core.*` must not import from `crystflux.v1.boundary.*` or `crystflux.v1.adapters.*`
- `crystflux.v1.boundary.*` must not import from `crystflux.v1.adapters.*`

Scan scope:
  `src/crystflux/v1/{core,boundary,adapters}/**/*.py`

How to run:
  - `make check` (runs this via `deps-gate`)
  - `uv run python scripts/check_deps.py`

Exit codes:
  - 0: no violations
  - 1: violations found
  - 2: parse error or unexpected project layout

See also:
  - `ESSENTIAL_FILES_MAP.md` (repo root)
  - `src/crystflux/v1/STRUCTURE.md` (import rules summary)
"""

from __future__ import annotations

import ast
import sys
from dataclasses import dataclass
from pathlib import Path


V1_ROOT_PARTS = ("crystflux", "v1")


@dataclass(frozen=True)
class Violation:
    """Single import rule violation (file/line + best-effort statement context)."""

    path: Path
    lineno: int
    statement: str
    importer_layer: str
    imported_module: str
    rule: str


def main(argv: list[str]) -> int:
    project_root = _default_project_root()
    src_root = project_root / "src"
    v1_root = src_root / "crystflux" / "v1"

    if not v1_root.exists():
        print(f"DependencyGate: expected v1 root not found: {v1_root}", file=sys.stderr)
        return 2

    violations, parse_errors = scan_v1(v1_root=v1_root, src_root=src_root)
    if parse_errors:
        for path, message in parse_errors:
            print(f"DependencyGate: parse error: {path}: {message}", file=sys.stderr)
        return 2

    if not violations:
        return 0

    for v in violations:
        loc = f"{v.path}:{v.lineno}"
        print(
            "DependencyGate: "
            f"{loc}: {v.statement} "
            f"-> imports {v.imported_module} "
            f"({v.rule})",
            file=sys.stderr,
        )
    print(f"DependencyGate: {len(violations)} violation(s).", file=sys.stderr)
    return 1


def scan_v1(
    *,
    v1_root: Path,
    src_root: Path,
) -> tuple[list[Violation], list[tuple[Path, str]]]:
    """Scan triadic layers under `v1_root` and return (violations, parse_errors)."""

    violations: list[Violation] = []
    parse_errors: list[tuple[Path, str]] = []

    for layer in ("core", "boundary", "adapters"):
        layer_dir = v1_root / layer
        if not layer_dir.exists():
            continue
        for path in sorted(layer_dir.rglob("*.py")):
            file_violations, errors = _scan_file(path=path, src_root=src_root)
            violations.extend(file_violations)
            parse_errors.extend(errors)

    return violations, parse_errors


def _scan_file(*, path: Path, src_root: Path) -> tuple[list[Violation], list[tuple[Path, str]]]:
    """Return violations for a single Python file (AST-based)."""

    text = path.read_text(encoding="utf-8")
    try:
        tree = ast.parse(text, filename=str(path))
    except SyntaxError as exc:
        return [], [(path, f"{exc.msg} (line {exc.lineno})")]

    module = _module_name_from_path(path=path, src_root=src_root)
    importer_layer = _layer_from_module(module)
    if importer_layer not in {"core", "boundary", "adapters"}:
        return [], []

    lines = text.splitlines()
    violations: list[Violation] = []

    for node in ast.walk(tree):
        imported = _extract_imported_modules(node=node, importer_module=module)
        if not imported:
            continue
        for imported_module, lineno in imported:
            rule = _check_rule(importer_layer=importer_layer, imported_module=imported_module)
            if rule is None:
                continue
            statement = _safe_line(lines=lines, lineno=lineno)
            violations.append(
                Violation(
                    path=path,
                    lineno=lineno,
                    statement=statement,
                    importer_layer=importer_layer,
                    imported_module=imported_module,
                    rule=rule,
                )
            )

    return violations, []


def _extract_imported_modules(
    *, node: ast.AST, importer_module: str
) -> list[tuple[str, int]] | None:
    if isinstance(node, ast.Import):
        out: list[tuple[str, int]] = []
        for alias in node.names:
            out.append((alias.name, getattr(node, "lineno", 1)))
        return out

    if isinstance(node, ast.ImportFrom):
        resolved = _resolve_import_from(importer_module=importer_module, module=node.module, level=node.level)
        if resolved is None:
            return None
        return [(resolved, getattr(node, "lineno", 1))]

    return None


def _resolve_import_from(*, importer_module: str, module: str | None, level: int) -> str | None:
    """
    Resolve `from ... import ...` into an absolute module string.

    Notes:
      - This resolves only the import *module path* (not individual imported names).
      - It uses the importer's module path and `level` to interpret relative imports.
    """

    # Absolute import.
    if level == 0:
        return module

    importer_parts = importer_module.split(".")
    importer_pkg_parts = importer_parts[:-1]  # drop module name

    # level=1 means "from .", so we go up 0 packages.
    up = max(level - 1, 0)
    if up > len(importer_pkg_parts):
        return None
    base = importer_pkg_parts[: len(importer_pkg_parts) - up]

    if module:
        return ".".join(base + module.split("."))
    return ".".join(base)


def _check_rule(*, importer_layer: str, imported_module: str) -> str | None:
    """Return a human-readable rule name when the import is forbidden; else None."""

    if importer_layer == "core":
        if imported_module.startswith("crystflux.v1.boundary"):
            return "core must not import boundary"
        if imported_module.startswith("crystflux.v1.adapters"):
            return "core must not import adapters"
        return None

    if importer_layer == "boundary":
        if imported_module.startswith("crystflux.v1.adapters"):
            return "boundary must not import adapters"
        return None

    return None


def _module_name_from_path(*, path: Path, src_root: Path) -> str:
    """Convert a `src/`-relative path to a dotted module name (best-effort)."""

    rel = path.resolve().relative_to(src_root.resolve())
    parts = list(rel.parts)
    if not parts or parts[0] != V1_ROOT_PARTS[0]:
        # Best-effort fallback: return a pseudo-module for non-src paths.
        return ".".join(parts).removesuffix(".py")

    if parts[-1] == "__init__.py":
        parts = parts[:-1]
    else:
        parts[-1] = parts[-1].removesuffix(".py")
    return ".".join(parts)


def _layer_from_module(module: str) -> str:
    parts = module.split(".")
    if len(parts) < 3:
        return ""
    if tuple(parts[:2]) != V1_ROOT_PARTS:
        return ""
    return parts[2]


def _safe_line(*, lines: list[str], lineno: int) -> str:
    if 1 <= lineno <= len(lines):
        return lines[lineno - 1].strip()
    return "<unknown>"


def _default_project_root() -> Path:
    # scripts/check_deps.py -> repo root
    return Path(__file__).resolve().parents[1]


if __name__ == "__main__":  # pragma: no cover
    raise SystemExit(main(sys.argv[1:]))
