# Familiar - Self-Hosted AI Companion Platform
# Copyright (c) 2026 George Scott Foley
# ORCID: 0009-0006-4957-0540
# Email: Georgescottfoley@proton.me
# Licensed under the MIT License - see LICENSE file for details

"""Code structure graph — AST-based module dependency visualization.

Parses the familiar package to build an interactive dependency graph
suitable for force-directed rendering in the dashboard.

Detection strategies beyond static imports:
- Late imports: AST walks ALL nodes including function bodies
- Entry points: reads pyproject.toml [project.scripts]
- Registry loading: detects `from . import X` in __init__.py files
- Skill scanner: marks familiar/skills/* as scanner-loaded
- Channel config: marks familiar/channels/* as config-loaded
"""

from __future__ import annotations

import ast
import logging
import re
from pathlib import Path
from typing import Any

logger = logging.getLogger(__name__)

# Cache the graph — keyed by (mtime, depth) so different depths get separate results
_graph_cache: dict[tuple[float, int], dict[str, Any]] = {}


def _get_package_root() -> Path:
    """Return the familiar package root directory."""
    return Path(__file__).resolve().parent.parent


def _module_depth(mod_name: str, max_depth: int = 3) -> str:
    """Truncate a module name to max_depth components."""
    parts = mod_name.split(".")
    return ".".join(parts[:max_depth])


def _get_entry_point_modules() -> set[str]:
    """Read pyproject.toml entry points to find modules invoked via CLI."""
    entry_mods: set[str] = set()
    try:
        toml_path = _get_package_root().parent / "pyproject.toml"
        if toml_path.exists():
            text = toml_path.read_text()
            # Match lines like: familiar-eval = "familiar.core.ci_eval:cli_main"
            for match in re.finditer(r'=\s*"(familiar[^":]+)', text):
                entry_mods.add(match.group(1))
    except Exception:
        pass
    # Also mark all __main__.py files
    for p in _get_package_root().rglob("__main__.py"):
        rel = str(p.relative_to(_get_package_root().parent)).replace("/", ".").replace(".py", "")
        entry_mods.add(rel)
    return entry_mods


def _get_registry_loaded_modules(pkg_root: Path, max_depth: int) -> set[str]:
    """Detect modules loaded by __init__.py registry patterns.

    Catches: `from . import artist as _ar` in jobs/__init__.py, etc.
    """
    registry_mods: set[str] = set()
    for init_file in pkg_root.rglob("__init__.py"):
        try:
            tree = ast.parse(init_file.read_text())
        except Exception:
            continue
        parent_mod = str(init_file.parent.relative_to(pkg_root.parent)).replace("/", ".")
        for node in ast.walk(tree):
            if isinstance(node, ast.ImportFrom) and node.level > 0:
                # Relative import: from . import X
                for alias in (node.names or []):
                    child = f"{parent_mod}.{alias.name}"
                    registry_mods.add(_module_depth(child, max_depth))
    return registry_mods


# Security import markers — if a module imports these, it handles sensitive data
_SECURITY_INDICATORS: dict[str, str] = {
    "cryptography": "encryption",
    "keyring": "credentials",
    "familiar.core.encryption": "encryption",
    "familiar.core.credential_store": "credentials",
    "familiar.core.secure_transport": "secure_transport",
    "familiar.core.security": "auth",
    "familiar.core.osint_gate": "authorization",
    "familiar.core.compliance": "compliance",
    "familiar.core.pattern_safety": "safety",
    "familiar.core.guardrails": "guardrails",
    "familiar.core.secrets_detection": "secrets",
    "familiar.services.credentials": "credentials",
}


def _get_scanner_loaded_prefixes() -> list[str]:
    """Directories whose children are loaded by runtime scanners, not static imports."""
    return [
        "familiar.skills.",              # loaded by SkillRegistry.load_skill() via importlib
        "familiar.channels.",            # loaded by ENABLED_CHANNELS config at runtime
        "familiar.dashboard.blueprints.",  # loaded by register_all_blueprints() at startup
    ]


def build_code_graph(max_depth: int = 4, include_tests: bool = False) -> dict[str, Any]:
    """Build the module dependency graph from AST analysis.

    Returns:
        {
            "nodes": [{"id": "familiar.core.agent", "files": 1, "lines": 3400, "group": "core"}, ...],
            "links": [{"source": "familiar.core.agent", "target": "familiar.core.providers"}, ...],
            "stats": {"total_modules": N, "total_lines": N, "total_edges": N}
        }
    """
    global _graph_cache

    pkg_root = _get_package_root()
    try:
        init_mtime = (pkg_root / "__init__.py").stat().st_mtime
    except Exception:
        init_mtime = 0.0
    cache_key = (init_mtime, max_depth)
    if cache_key in _graph_cache:
        return _graph_cache[cache_key]

    modules: dict[str, dict[str, Any]] = {}
    edges: set[tuple[str, str]] = set()
    total_lines = 0

    for py_file in sorted(pkg_root.rglob("*.py")):
        rel_str = str(py_file.relative_to(pkg_root.parent))
        if "__pycache__" in rel_str:
            continue
        if not include_tests and "/tests/" in rel_str:
            continue

        # Build module name
        mod_name = rel_str.replace("/", ".").replace(".py", "")
        if mod_name.endswith(".__init__"):
            mod_name = mod_name[:-9]

        short = _module_depth(mod_name, max_depth)

        # Determine group (top-level subpackage)
        parts = mod_name.split(".")
        group = parts[1] if len(parts) > 1 else "root"

        if short not in modules:
            modules[short] = {"files": 0, "lines": 0, "group": group, "security": set()}
        modules[short]["files"] += 1

        try:
            source = py_file.read_text()
            line_count = len(source.splitlines())
            modules[short]["lines"] += line_count
            total_lines += line_count
        except Exception:
            continue

        # Parse imports — walks ALL nodes including function bodies
        # (catches late imports that static-only parsers miss)
        try:
            tree = ast.parse(source)
            for node in ast.walk(tree):
                # Security tagging — check ALL imports against indicators
                _imp_name = None
                if isinstance(node, ast.ImportFrom) and node.module:
                    _imp_name = node.module
                elif isinstance(node, ast.Import):
                    for _alias in node.names:
                        for _sec_key, _sec_tag in _SECURITY_INDICATORS.items():
                            if _sec_key in _alias.name:
                                modules[short]["security"].add(_sec_tag)
                if _imp_name:
                    for _sec_key, _sec_tag in _SECURITY_INDICATORS.items():
                        if _sec_key in _imp_name:
                            modules[short]["security"].add(_sec_tag)

                if isinstance(node, ast.ImportFrom):
                    if node.module and node.module.startswith("familiar"):
                        # Absolute: from familiar.core.X import Y
                        imp_short = _module_depth(node.module, max_depth)
                        if imp_short != short:
                            edges.add((short, imp_short))
                    elif node.level > 0:
                        # Relative import: from . import X  or  from .sub import Y
                        pkg_parts = mod_name.split(".")
                        base = ".".join(pkg_parts[:-node.level]) if node.level < len(pkg_parts) else ""
                        if node.module:
                            # from .sub import Y → base.sub
                            resolved = f"{base}.{node.module}" if base else node.module
                            if resolved.startswith("familiar"):
                                imp_short = _module_depth(resolved, max_depth)
                                if imp_short != short:
                                    edges.add((short, imp_short))
                        else:
                            # from . import X → base.X (each name is a submodule)
                            for alias in (node.names or []):
                                resolved = f"{base}.{alias.name}" if base else alias.name
                                if resolved.startswith("familiar"):
                                    imp_short = _module_depth(resolved, max_depth)
                                    if imp_short != short:
                                        edges.add((short, imp_short))
                elif isinstance(node, ast.Import):
                    for alias in node.names:
                        if alias.name.startswith("familiar"):
                            imp_short = _module_depth(alias.name, max_depth)
                            if imp_short != short:
                                edges.add((short, imp_short))
        except SyntaxError:
            continue

    # Filter edges to only reference known modules
    valid_edges = [(s, t) for s, t in edges if s in modules and t in modules]

    # ── Coupling metrics ──
    # Ca (afferent coupling): how many modules depend on this one
    # Ce (efferent coupling): how many modules this depends on
    # Instability = Ce / (Ca + Ce)  →  0 = stable, 1 = unstable
    ca: dict[str, int] = {m: 0 for m in modules}  # incoming
    ce: dict[str, int] = {m: 0 for m in modules}  # outgoing
    callers: dict[str, list[str]] = {m: [] for m in modules}
    callees: dict[str, list[str]] = {m: [] for m in modules}

    for s, t in valid_edges:
        ca[t] = ca.get(t, 0) + 1
        ce[s] = ce.get(s, 0) + 1
        callers[t].append(s)
        callees[s].append(t)

    # ── Deep import scan for same-group edges ──
    # At depth 3, familiar.core.agent and familiar.core.prompt_builder both
    # become "familiar.core", hiding the edge. Do a second pass at full depth
    # to find all intra-group imports and mark targets as "imported".
    deep_imported: set[str] = set()
    for py_file in sorted(pkg_root.rglob("*.py")):
        rel_str = str(py_file.relative_to(pkg_root.parent))
        if "__pycache__" in rel_str:
            continue
        if not include_tests and "/tests/" in rel_str:
            continue
        try:
            tree = ast.parse(py_file.read_text())
        except Exception:
            continue
        for node in ast.walk(tree):
            target_mod = None
            if isinstance(node, ast.ImportFrom) and node.module:
                if node.module.startswith("familiar"):
                    target_mod = node.module
            elif isinstance(node, ast.Import):
                for alias in node.names:
                    if alias.name.startswith("familiar"):
                        target_mod = alias.name
            if target_mod:
                deep_imported.add(_module_depth(target_mod, max_depth))

    # ── Dead code detection with false-positive suppression ──
    entry_points = _get_entry_point_modules()
    registry_loaded = _get_registry_loaded_modules(pkg_root, max_depth)
    scanner_prefixes = _get_scanner_loaded_prefixes()

    # Expand entry point modules to their truncated depth
    entry_truncated = {_module_depth(m, max_depth) for m in entry_points}

    def _is_not_dead(mod_id: str, afferent: int) -> tuple[bool, str]:
        """Returns (is_alive, reason). If alive, reason explains why."""
        if mod_id == "familiar":
            return True, "root"
        if afferent > 0:
            return True, "imported"
        # Deep scan found an import that the depth-truncated graph missed
        if mod_id in deep_imported:
            return True, "deep_import"
        if mod_id in entry_truncated:
            return True, "entry_point"
        if mod_id in registry_loaded:
            return True, "registry"
        for prefix in scanner_prefixes:
            if mod_id.startswith(prefix):
                return True, "scanner"
        # Package __init__.py files are always loaded when submodules import
        if mod_id.count(".") == 1:  # e.g. familiar.core, familiar.channels
            return True, "package"
        return False, ""

    nodes = []
    dead_code: list[str] = []
    for mod_id, info in sorted(modules.items()):
        afferent = ca.get(mod_id, 0)
        efferent = ce.get(mod_id, 0)
        total_coupling = afferent + efferent
        instability = round(efferent / total_coupling, 3) if total_coupling > 0 else 0.5

        alive, alive_reason = _is_not_dead(mod_id, afferent)
        is_dead = not alive

        if is_dead:
            dead_code.append(mod_id)

        sec_tags = sorted(info.get("security", set()))

        nodes.append({
            "id": mod_id,
            "files": info["files"],
            "lines": info["lines"],
            "group": info["group"],
            "ca": afferent,
            "ce": efferent,
            "instability": instability,
            "dead": is_dead,
            "alive_reason": alive_reason if alive and afferent == 0 else "",
            "callers": sorted(callers.get(mod_id, [])),
            "callees": sorted(callees.get(mod_id, [])),
            "security": sec_tags,
        })

    links = [{"source": s, "target": t} for s, t in sorted(valid_edges)]

    # ── Impact radius (precomputed BFS for each node) ──
    # Too expensive for 275 nodes — compute on demand via /api/code-graph/impact

    graph = {
        "nodes": nodes,
        "links": links,
        "stats": {
            "total_modules": len(nodes),
            "total_lines": total_lines,
            "total_edges": len(links),
            "groups": sorted(set(n["group"] for n in nodes)),
            "dead_code_count": len(dead_code),
            "dead_code": dead_code[:20],  # cap for response size
        },
    }

    _graph_cache[cache_key] = graph
    return graph


def compute_impact_radius(module_id: str) -> dict[str, Any]:
    """BFS from a module to find all transitive dependents (what breaks if this changes).

    Returns {"module": str, "impact": [str], "depth": {str: int}, "count": int}
    """
    graph = build_code_graph()
    # Build reverse adjacency: target → [sources that depend on it]
    # If A imports B, then changing B impacts A. So we need: who imports module_id?
    reverse_adj: dict[str, list[str]] = {}
    for link in graph["links"]:
        t = link["target"]
        s = link["source"]
        reverse_adj.setdefault(t, []).append(s)

    # BFS from module_id through reverse edges
    visited: dict[str, int] = {}
    queue = [module_id]
    visited[module_id] = 0

    while queue:
        current = queue.pop(0)
        depth = visited[current]
        for dependent in reverse_adj.get(current, []):
            if dependent not in visited:
                visited[dependent] = depth + 1
                queue.append(dependent)

    # Remove the module itself
    visited.pop(module_id, None)

    return {
        "module": module_id,
        "impact": sorted(visited.keys()),
        "depth": visited,
        "count": len(visited),
    }
