"""Repository scanning, the persisted structural graph and staleness."""

from __future__ import annotations

import os
import time
from datetime import datetime, timezone
from pathlib import Path

from . import ignore, store, symbols, tokens

SCHEMA_VERSION = 4
MAX_FILE_BYTES = 512 * 1024
MAX_FILES = 20_000
CACHE_LIMIT = 4
# Call edges are kept deliberately conservative: a callee name only resolves
# when few files define it, and each file links to a bounded set of targets.
CALL_NAME_AMBIGUITY_CAP = 5
MAX_CALL_EDGES_PER_FILE = 15

_CACHE: dict[str, tuple[float, int, "Graph"]] = {}


def _iso(epoch: float) -> str:
    return datetime.fromtimestamp(epoch, timezone.utc).isoformat().replace("+00:00", "Z")


def read_text(path: Path) -> str | None:
    try:
        raw = path.read_bytes()
    except OSError:
        return None
    if b"\x00" in raw[:8192]:
        return None
    return raw.decode("utf-8", errors="replace")


def walk(root: Path) -> tuple[list[tuple[str, Path, int, float, int]], dict[str, int], bool]:
    """Collect indexable files honouring .gitignore, the always-skip set and the caps.

    Visited directories come back too: their mtimes are how a created or deleted
    file shows up as staleness, which no tracked file's mtime would reveal.
    """
    stack = ignore.IgnoreStack()
    collected: list[tuple[str, Path, int, float, int]] = []
    directories: dict[str, int] = {}
    truncated = False

    def visit(directory: Path, rel: str) -> None:
        nonlocal truncated
        try:
            before_mtime_ns = directory.stat().st_mtime_ns
        except OSError:
            before_mtime_ns = -1
        pushed = False
        gitignore = directory / ".gitignore"
        try:
            if gitignore.is_file():
                stack.push(rel, gitignore.read_text(encoding="utf-8", errors="replace"))
                pushed = True
        except OSError:
            pushed = False
        try:
            entries = sorted(os.scandir(directory), key=lambda entry: entry.name)
        except OSError:
            entries = []
        subdirectories: list[tuple[Path, str]] = []
        for entry in entries:
            if truncated:
                break
            name = entry.name
            child_rel = f"{rel}/{name}" if rel else name
            try:
                is_dir = entry.is_dir(follow_symlinks=False)
            except OSError:
                continue
            if is_dir:
                if name in ignore.ALWAYS_SKIP or stack.ignored(child_rel, True):
                    continue
                subdirectories.append((Path(entry.path), child_rel))
                continue
            if not entry.is_file(follow_symlinks=False):
                continue
            if symbols.language_for(name) is None:
                continue
            if stack.ignored(child_rel, False):
                continue
            try:
                info = entry.stat()
            except OSError:
                continue
            if info.st_size > MAX_FILE_BYTES:
                continue
            if len(collected) >= MAX_FILES:
                truncated = True
                break
            collected.append((child_rel, Path(entry.path), info.st_size, info.st_mtime, info.st_mtime_ns))
        for child_path, child_rel in subdirectories:
            if truncated:
                break
            visit(child_path, child_rel)
        if pushed:
            stack.pop()
        try:
            after_mtime_ns = directory.stat().st_mtime_ns
            directories[rel] = after_mtime_ns if after_mtime_ns == before_mtime_ns else -1
        except OSError:
            directories[rel] = -1

    visit(root, "")
    return collected, directories, truncated


def build(root: Path, previous: Graph | None = None) -> dict:
    """Build the graph dict, re-extracting only what changed since ``previous``.

    A record is reused verbatim when its size and nanosecond mtime match -
    extraction is the expensive part. Import edges are
    always re-resolved from scratch: a created or deleted file can retarget
    other files' imports, and resolution is cheap in-memory matching.
    """
    # Created up front so writing the graph later cannot dirty the root directory
    # and make the graph look stale the moment it is written.
    try:
        store.store_dir(root).mkdir(parents=True, exist_ok=True)
    except OSError:
        pass
    files, directories, truncated = walk(root)
    records: list[dict] = []
    known: set[str] = set()
    suffix_index: dict[str, str] = {}
    reused = 0

    for rel, path, size, mtime, mtime_ns in files:
        prev = previous.files.get(rel) if previous is not None else None
        if (
            prev is not None
            and prev.get("size") == size
            and prev.get("mtime_ns") == mtime_ns
        ):
            # Shallow copy with fresh import entries: resolution below fills
            # them in, and the previous graph may still be process-cached.
            records.append(
                {
                    **prev,
                    "imports": [
                        {"spec": entry["spec"], "path": None}
                        for entry in prev.get("imports") or []
                        if isinstance(entry, dict) and isinstance(entry.get("spec"), str)
                    ],
                }
            )
            reused += 1
        else:
            text = read_text(path)
            if text is None:
                continue
            language = symbols.language_for(rel)
            found, specs = symbols.extract(text, language or "")
            records.append(
                {
                    "path": rel,
                    "language": language,
                    "size": size,
                    "mtime": mtime,
                    "mtime_ns": mtime_ns,
                    "lines": text.count("\n") + 1,
                    "symbols": found,
                    "imports": [{"spec": spec, "path": None} for spec in dict.fromkeys(specs)],
                    "calls": symbols.callees(text, language or ""),
                    "terms": tokens.digest(text),
                }
            )
        known.add(rel)
        suffix_index.setdefault(rel, rel)
        parts = rel.split("/")
        for index in range(1, len(parts)):
            suffix_index.setdefault("/".join(parts[index:]), rel)

    # Leaf symbol name -> defining files, for call-edge resolution. Names
    # defined all over the codebase resolve to nothing on purpose.
    defined: dict[str, set[str]] = {}
    for record in records:
        for symbol in record["symbols"]:
            name = symbol.get("name")
            if not isinstance(name, str):
                continue
            leaf = name.rsplit(".", 1)[-1]
            if len(leaf) >= 3:
                defined.setdefault(leaf, set()).add(record["path"])

    edges: list[dict] = []
    for record in records:
        rel = record["path"]
        language = record["language"] or ""
        seen: set[str] = set()
        for entry in record["imports"]:
            target = symbols.resolve_import(entry["spec"], rel, language, known, suffix_index)
            if target == rel:
                target = None
            entry["path"] = target
            if target and target not in seen:
                seen.add(target)
                edges.append({"from": rel, "to": target, "kind": "imports"})
        call_targets: set[str] = set()
        for name in record.get("calls") or []:
            holders = defined.get(name)
            if not holders or len(holders) > CALL_NAME_AMBIGUITY_CAP:
                continue
            for target in holders:
                if target != rel:
                    call_targets.add(target)
            if len(call_targets) >= MAX_CALL_EDGES_PER_FILE:
                break
        for target in sorted(call_targets)[:MAX_CALL_EDGES_PER_FILE]:
            edges.append({"from": rel, "to": target, "kind": "calls"})
        for symbol in record["symbols"]:
            edges.append({"from": rel, "to": f"{rel}::{symbol['name']}", "kind": "contains"})

    generated = time.time()
    symbol_total = sum(len(record["symbols"]) for record in records)
    return {
        "schema_version": SCHEMA_VERSION,
        "generated_at": _iso(generated),
        "generated_ts": generated,
        "root": str(root),
        "truncated": truncated,
        "stats": {
            "files": len(records),
            "symbols": symbol_total,
            "edges": len(edges),
            "reused": reused,
        },
        # Flat aliases: the launcher banner reads these keys straight off the JSON.
        "file_count": len(records),
        "symbol_count": symbol_total,
        "node_count": len(records) + symbol_total,
        "edge_count": len(edges),
        "dirs": list(directories),
        "dir_mtimes_ns": directories,
        "files": records,
        "edges": edges,
    }


class Graph:
    """Read side of ``info_graph.json`` with the lookups ranking needs."""

    def __init__(self, root: Path, data: dict) -> None:
        self.root = root
        self.data = data
        self.files: dict[str, dict] = {}
        for record in data.get("files") or []:
            if isinstance(record, dict) and isinstance(record.get("path"), str):
                record.setdefault("symbols", [])
                record.setdefault("imports", [])
                record.setdefault("terms", [])
                self.files[record["path"]] = record
        self.dirs: list[str] = [entry for entry in (data.get("dirs") or []) if isinstance(entry, str)]
        self.dir_mtimes_ns: dict[str, int] = {
            rel: value
            for rel, value in (data.get("dir_mtimes_ns") or {}).items()
            if isinstance(rel, str) and isinstance(value, int)
        }
        self.imports: dict[str, set[str]] = {}
        self.imported_by: dict[str, set[str]] = {}
        self.calls: dict[str, set[str]] = {}
        self.called_by: dict[str, set[str]] = {}
        for edge in data.get("edges") or []:
            if not isinstance(edge, dict):
                continue
            kind = edge.get("kind")
            source, target = edge.get("from"), edge.get("to")
            if not (isinstance(source, str) and isinstance(target, str)):
                continue
            if kind == "imports":
                self.imports.setdefault(source, set()).add(target)
                self.imported_by.setdefault(target, set()).add(source)
            elif kind == "calls":
                self.calls.setdefault(source, set()).add(target)
                self.called_by.setdefault(target, set()).add(source)
        try:
            self.generated_ts = float(data.get("generated_ts") or 0.0)
        except (TypeError, ValueError):
            self.generated_ts = 0.0

    @property
    def generated_at(self) -> str | None:
        value = self.data.get("generated_at")
        return value if isinstance(value, str) else None

    @property
    def file_count(self) -> int:
        return len(self.files)

    @property
    def symbol_count(self) -> int:
        return sum(len(record["symbols"]) for record in self.files.values())

    @property
    def edge_count(self) -> int:
        edges = self.data.get("edges")
        return len(edges) if isinstance(edges, list) else 0

    def stale(self) -> bool:
        if self.generated_ts <= 0 or set(self.dir_mtimes_ns) != set(self.dirs):
            return True
        for rel, record in self.files.items():
            path = self.root.joinpath(*rel.split("/")) if rel else self.root
            try:
                current = path.stat()
                if current.st_size != record.get("size") or current.st_mtime_ns != record.get("mtime_ns"):
                    return True
            except OSError:
                return True
        for rel, expected_mtime_ns in self.dir_mtimes_ns.items():
            path = self.root.joinpath(*rel.split("/")) if rel else self.root
            try:
                if path.stat().st_mtime_ns != expected_mtime_ns:
                    return True
            except OSError:
                return True
        return False

    def source(self, rel: str) -> list[str] | None:
        record = self.files.get(rel)
        if record is None:
            return None
        text = read_text(self.root.joinpath(*rel.split("/")))
        return None if text is None else text.split("\n")

    def symbol(self, rel: str, name: str) -> dict | None:
        record = self.files.get(rel)
        if record is None:
            return None
        for entry in record["symbols"]:
            if entry.get("name") == name:
                return entry
        return None

    def symbol_at(self, rel: str, line: int) -> dict | None:
        record = self.files.get(rel)
        if record is None:
            return None
        best: dict | None = None
        for entry in record["symbols"]:
            try:
                start, end = int(entry["line"]), int(entry["end_line"])
            except (KeyError, TypeError, ValueError):
                continue
            if start <= line <= end and (best is None or start >= int(best["line"])):
                best = entry
        return best


def load(root: Path) -> Graph | None:
    path = store.info_graph_path(root)
    try:
        info = path.stat()
    except OSError:
        _CACHE.pop(str(path), None)
        return None
    cached = _CACHE.get(str(path))
    if cached and cached[0] == info.st_mtime and cached[1] == info.st_size:
        return cached[2]
    data = store.read_json(path)
    if data is None or data.get("schema_version") != SCHEMA_VERSION:
        return None
    graph = Graph(root, data)
    _remember(path, info.st_mtime, info.st_size, graph)
    return graph


def _remember(path: Path, mtime: float, size: int, graph: "Graph") -> None:
    if len(_CACHE) >= CACHE_LIMIT:
        _CACHE.clear()
    _CACHE[str(path)] = (mtime, size, graph)


def scan(root: Path, force: bool = False) -> dict:
    """Refresh the graph. ``reused`` counts records carried over from the last build."""
    started = time.perf_counter()
    existing: Graph | None = None
    if not force:
        existing = load(root)
        if existing is not None and not existing.stale():
            return {
                "files": existing.file_count,
                "symbols": existing.symbol_count,
                "edges": existing.edge_count,
                "elapsed_ms": int((time.perf_counter() - started) * 1000),
                "skipped": True,
                "reused": 0,
            }
    if not root.is_dir():
        return {
            "files": 0,
            "symbols": 0,
            "edges": 0,
            "elapsed_ms": int((time.perf_counter() - started) * 1000),
            "skipped": False,
            "reused": 0,
        }
    data = build(root, previous=existing)
    path = store.info_graph_path(root)
    if store.write_json(path, data):
        try:
            info = path.stat()
            _remember(path, info.st_mtime, info.st_size, Graph(root, data))
        except OSError:
            _CACHE.pop(str(path), None)
    stats = data["stats"]
    return {
        "files": stats["files"],
        "symbols": stats["symbols"],
        "edges": stats["edges"],
        "elapsed_ms": int((time.perf_counter() - started) * 1000),
        "skipped": False,
        "reused": stats.get("reused", 0),
    }


def ensure(root: Path) -> Graph | None:
    """Load the graph, scanning once if none exists. Never raises."""
    try:
        graph = load(root)
        if graph is not None:
            return graph
        scan(root, force=True)
        return load(root)
    except Exception:
        return None
