"""Collect and persist health metrics for snapshot/trend tracking."""

from __future__ import annotations

import sqlite3
import subprocess
import time

# W886: delegate to the canonical commands-layer helper (W873 audit).
# Previous narrow Python-only variant missed test files in non-Python
# projects (Go ``*_test.go``, Java ``*Test.java``, JS ``*.spec.ts``,
# etc.), which let their dead-export rows leak into the snapshot. The
# canonical helper covers all of those.
from roam.commands.changed_files import is_test_file as _is_test_path
from roam.db.connection import find_project_root
from roam.db.queries import TOP_BY_BETWEENNESS, TOP_BY_DEGREE, UNREFERENCED_EXPORTS


def _compute_health_score(
    conn,
    G,
    symbols,
    god_items,
    bn_items,
    bn_p90,
    layer_violations,
    find_cycles_fn,
    is_utility_path_fn,
):
    """Compute the weighted geometric mean health score (0-100)."""
    import math

    def _hf(value, scale):
        return math.exp(-value / scale) if scale > 0 else 1.0

    tangle_r = 0.0
    if G is not None and symbols > 0:
        from networkx.exception import NetworkXException

        try:
            cyc_ids = set()
            for scc in find_cycles_fn(G):
                cyc_ids.update(scc)
            tangle_r = len(cyc_ids) / symbols * 100
        except (ImportError, NetworkXException) as _exc:
            from roam.observability import log_swallowed

            log_swallowed("metrics_history:nested", _exc)

    # Score signal: actionable items only (utilities are expected to
    # have high fan-in / high betweenness — penalising them tanks the
    # score on healthy codebases). Normalised per 1k symbols so a 14k-
    # symbol repo with 23 actionable god components (0.16%) doesn't
    # score the same as a 100-symbol repo with 23 (23%). Mirrors the
    # fix in cmd_health.py.
    god_actionable = [g for g in god_items if not is_utility_path_fn(g["file"])]
    bn_actionable = [b for b in bn_items if not is_utility_path_fn(b["file"])]
    god_critical = sum(1 for g in god_actionable if g["degree"] > 50)
    bn_critical = sum(1 for b in bn_actionable if b["betweenness"] > bn_p90)

    size_norm = max(1.0, symbols / 1000.0)
    god_signal = (god_critical * 3 + len(god_actionable) * 0.5) / size_norm
    bn_signal = (bn_critical * 2 + len(bn_actionable) * 0.3) / size_norm

    factors = [
        (_hf(tangle_r, 10), 0.30),
        (_hf(god_signal, 1.5), 0.20),
        (_hf(bn_signal, 1.0), 0.15),
        (_hf(layer_violations, 5), 0.15),
    ]
    try:
        avg_fh = conn.execute("SELECT AVG(health_score) FROM file_stats WHERE health_score IS NOT NULL").fetchone()[0]
        factors.append((min(1.0, (avg_fh or 10) / 10.0), 0.20))
    except sqlite3.Error:
        factors.append((1.0, 0.20))

    log_score = sum(w * math.log(max(h, 1e-9)) for h, w in factors)
    return max(0, min(100, int(100 * math.exp(log_score))))


def _count_layer_violations_for_resilient_snapshots(G):
    """Count optional layer violations without hiding non-layer bugs."""
    if G is None:
        return 0

    from networkx.exception import NetworkXException

    from roam.graph.layers import detect_layers, find_violations

    try:
        layer_map = detect_layers(G)
        if not layer_map:
            return 0
        return len(find_violations(G, layer_map))
    except NetworkXException as _exc:
        from roam.observability import log_swallowed

        log_swallowed("metrics_history:layers", _exc)
        return 0


def collect_metrics(conn):
    """Query the DB for all health metrics and compute a health score.

    Returns a dict with keys: files, symbols, edges, cycles,
    god_components, bottlenecks, dead_exports, layer_violations,
    health_score (0-100, higher = healthier).
    """
    files = conn.execute("SELECT COUNT(*) FROM files").fetchone()[0]
    symbols = conn.execute("SELECT COUNT(*) FROM symbols").fetchone()[0]
    edges = conn.execute("SELECT COUNT(*) FROM edges").fetchone()[0]
    # Keep health math aligned with `roam health`.
    from roam.commands.cmd_health import _betweenness_percentiles, _is_utility_path

    # Cycles
    try:
        from roam.graph.builder import build_symbol_graph
        from roam.graph.cycles import find_cycles

        G = build_symbol_graph(conn)
        cycles = len(find_cycles(G))
    except (ImportError, sqlite3.Error):
        cycles = 0
        G = None

    # God components (same query + thresholds as cmd_health.py)
    degree_rows = conn.execute(TOP_BY_DEGREE, (50,)).fetchall()
    god_items = []
    for r in degree_rows:
        total = (r["in_degree"] or 0) + (r["out_degree"] or 0)
        if total > 20:
            god_items.append(
                {
                    "degree": total,
                    "file": r["file_path"],
                }
            )
    god_components = len(god_items)

    # Bottlenecks (same query + thresholds as cmd_health.py)
    bn_p90 = _betweenness_percentiles(conn, (90,)).values[0]

    bw_rows = conn.execute(TOP_BY_BETWEENNESS, (15,)).fetchall()
    _round = round
    bn_items = [
        {"betweenness": _round(r["betweenness"] or 0, 1), "file": r["file_path"]}
        for r in bw_rows
        if (r["betweenness"] or 0) > 0.5
    ]
    bottlenecks = len(bn_items)

    # Dead exports (filter test files — they're discovered by pytest, not imported)
    dead_rows = conn.execute(UNREFERENCED_EXPORTS).fetchall()
    dead_rows = [r for r in dead_rows if not _is_test_path(r["file_path"])]
    dead_exports = len(dead_rows)

    # Layer violations
    layer_violations = _count_layer_violations_for_resilient_snapshots(G)

    health_score = _compute_health_score(
        conn,
        G,
        symbols,
        god_items,
        bn_items,
        bn_p90,
        layer_violations,
        find_cycles,
        _is_utility_path,
    )

    # Tangle ratio: percentage of symbols in cycles
    tangle_ratio = 0.0
    if G is not None and symbols > 0:
        from networkx.exception import NetworkXException

        try:
            from roam.graph.cycles import find_cycles as _find_cycles

            cycle_list = _find_cycles(G)
            cycle_sym_ids = set()
            for scc in cycle_list:
                cycle_sym_ids.update(scc)
            tangle_ratio = round(len(cycle_sym_ids) / symbols * 100, 1)
        except (ImportError, NetworkXException) as _exc:
            from roam.observability import log_swallowed

            log_swallowed("metrics_history:nested", _exc)

    # Average complexity from symbol_metrics
    avg_complexity = 0.0
    try:
        row = conn.execute("SELECT AVG(cognitive_complexity) FROM symbol_metrics").fetchone()
        if row and row[0] is not None:
            avg_complexity = round(row[0], 1)
    except sqlite3.Error as _exc:
        from roam.observability import log_swallowed

        log_swallowed("metrics_history", _exc)

    # Brain methods (cc >= 25 AND line_count >= 50)
    brain_methods = 0
    try:
        row = conn.execute(
            "SELECT COUNT(*) FROM symbol_metrics WHERE cognitive_complexity >= 25 AND line_count >= 50"
        ).fetchone()
        if row:
            brain_methods = row[0]
    except sqlite3.Error as _exc:
        from roam.observability import log_swallowed

        log_swallowed("metrics_history", _exc)

    # B8: spectral gap (algebraic connectivity / lambda2) of the file graph.
    # Persisting one gap per snapshot lets `roam forecast` project a TRUE
    # historical decay series toward structural failure. None on a degenerate
    # graph (< 2 nodes) or eigensolver failure — stored as NULL, never a
    # misleading 0.0.
    # Expected failure families only: module chain unavailable (ImportError),
    # schema drift on an old index (sqlite3.Error), graph-algorithm failure
    # (NetworkXException). Eigensolver/scipy failures never reach here — they
    # degrade to a 0.0 sentinel inside spectral._compute_algebraic_connectivity.
    # Logic errors (TypeError/AttributeError/...) propagate.
    spectral_gap_val = None
    from networkx.exception import NetworkXException

    try:
        from roam.graph.spectral_forecast import compute_current_spectral_gap

        spectral_gap_val = compute_current_spectral_gap(conn)
    except (ImportError, sqlite3.Error, NetworkXException) as _exc:
        from roam.observability import log_swallowed

        log_swallowed("metrics_history", _exc)

    return {
        "files": files,
        "symbols": symbols,
        "edges": edges,
        "cycles": cycles,
        "god_components": god_components,
        "bottlenecks": bottlenecks,
        "dead_exports": dead_exports,
        "layer_violations": layer_violations,
        "health_score": health_score,
        "tangle_ratio": tangle_ratio,
        "avg_complexity": avg_complexity,
        "brain_methods": brain_methods,
        "spectral_gap": spectral_gap_val,
    }


def _git_info(root):
    """Get current git branch and commit hash."""
    branch = None
    commit = None
    try:
        r = subprocess.run(
            ["git", "rev-parse", "--abbrev-ref", "HEAD"],
            cwd=str(root),
            capture_output=True,
            text=True,
            encoding="utf-8",
            errors="replace",
            timeout=5,
        )
        if r.returncode == 0:
            branch = r.stdout.strip()
    except Exception as _exc:  # noqa: BLE001 — defensive
        from roam.observability import log_swallowed

        log_swallowed("metrics_history", _exc)
    try:
        r = subprocess.run(
            ["git", "rev-parse", "--short", "HEAD"],
            cwd=str(root),
            capture_output=True,
            text=True,
            encoding="utf-8",
            errors="replace",
            timeout=5,
        )
        if r.returncode == 0:
            commit = r.stdout.strip()
    except Exception as _exc:  # noqa: BLE001 — defensive
        from roam.observability import log_swallowed

        log_swallowed("metrics_history", _exc)
    return branch, commit


def append_snapshot(conn, tag=None, source="snapshot"):
    """Insert a new snapshot row with current metrics and git info.

    Returns the snapshot dict that was inserted.
    """
    root = find_project_root()
    metrics = collect_metrics(conn)
    branch, commit = _git_info(root)

    # Dedup by commit: re-indexing the same commit (e.g. a no-op / comment-only
    # reindex) must replace the data point, not append a duplicate row. Without this,
    # all snapshot rows share one commit and the trend commands (forecast / bisect /
    # alerts-trend) run Theil-Sen / Mann-Kendall over identical values = confidently
    # vacuous output. Keep one row per (git_commit, source); distinct commits accrue.
    if commit:
        conn.execute(
            "DELETE FROM snapshots WHERE git_commit = ? AND source = ?",
            (commit, source),
        )
    conn.execute(
        """INSERT INTO snapshots
           (timestamp, tag, source, git_branch, git_commit,
            files, symbols, edges, cycles, god_components,
            bottlenecks, dead_exports, layer_violations, health_score,
            tangle_ratio, avg_complexity, brain_methods, spectral_gap)
           VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
        (
            int(time.time()),
            tag,
            source,
            branch,
            commit,
            metrics["files"],
            metrics["symbols"],
            metrics["edges"],
            metrics["cycles"],
            metrics["god_components"],
            metrics["bottlenecks"],
            metrics["dead_exports"],
            metrics["layer_violations"],
            metrics["health_score"],
            metrics.get("tangle_ratio", 0),
            metrics.get("avg_complexity", 0),
            metrics.get("brain_methods", 0),
            metrics.get("spectral_gap"),
        ),
    )
    conn.commit()

    return {
        "tag": tag,
        "source": source,
        "git_branch": branch,
        "git_commit": commit,
        **metrics,
    }


def get_snapshots(conn, limit=None, since=None):
    """Fetch snapshot history, newest first.

    Args:
        limit: Max rows to return.
        since: Unix timestamp — only return snapshots after this time.
    """
    sql = "SELECT * FROM snapshots"
    params = []
    if since is not None:
        sql += " WHERE timestamp >= ?"
        params.append(since)
    sql += " ORDER BY timestamp DESC"
    if limit is not None:
        sql += " LIMIT ?"
        params.append(limit)
    return conn.execute(sql, params).fetchall()
