"""
Drift Detector — tests.

Covers:
  - detect_drift: empty report when no snapshot
  - detect_drift: no alerts when no significant change
  - detect_drift: new files > 20% → medium alert
  - detect_drift: hub gained >3 dependents → high alert
  - detect_drift: new hub (>= 5 dependents) → medium alert
  - detect_drift: lost hub (>5 dependents lost) → info alert
  - DriftReport.has_alerts: False / True
  - multiple alerts in one report
  - dependency_growth tracking
  - new_hubs list populated correctly
  - get_drift_report: missing .kodara/ → empty report
  - get_drift_report: exception → empty report
  - alert levels are constrained to valid values
  - drift CLI: clean repo → "No significant drift"
  - drift CLI: alerts found → shows alert count
"""

from __future__ import annotations

import time
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest
from click.testing import CliRunner

from kodara.drift.detector import (
    DriftAlert,
    DriftReport,
    detect_drift,
    get_drift_report,
)
from kodara.memory.store import KodaraIndex


# ── Helpers ───────────────────────────────────────────────────────────────────


def _make_index(
    modules: dict | None = None,
    graph_edges: list | None = None,
) -> KodaraIndex:
    mods = modules or {}
    return KodaraIndex(
        repo_id="local/test-aabbccdd",
        display_name="test",
        source_root="/project",
        indexed_at=time.time(),
        index_version=3,
        file_count=len(mods),
        language_counts={"python": len(mods)},
        modules=mods,
        graph_edges=graph_edges or [],
        file_hashes={},
    )


def _make_snapshot(
    modules: dict | None = None,
    graph_edges: list | None = None,
) -> dict:
    mods = modules or {}
    return {
        "repo_id": "local/test-aabbccdd",
        "modules": mods,
        "graph_edges": graph_edges or [],
        "file_count": len(mods),
        "language_counts": {"python": len(mods)},
    }


def _mods(*paths: str) -> dict:
    """Build a modules dict from a list of paths."""
    return {p: {"path": p, "language": "python"} for p in paths}


def _edges(*pairs: tuple[str, str]) -> list[dict]:
    """Build graph_edges from (from, to) pairs."""
    return [{"from": f, "to": t, "kind": "import"} for f, t in pairs]


# ── DriftReport.has_alerts ────────────────────────────────────────────────────


def test_has_alerts_false_when_empty():
    report = DriftReport()
    assert report.has_alerts is False


def test_has_alerts_true_when_alerts_exist():
    report = DriftReport(alerts=[DriftAlert(level="info", message="test")])
    assert report.has_alerts is True


# ── detect_drift — no previous snapshot ──────────────────────────────────────


def test_detect_drift_returns_empty_when_no_snapshot():
    idx = _make_index(_mods("a.py", "b.py"))
    report = detect_drift(idx, {})
    assert not report.has_alerts
    assert report.new_files == 0
    assert report.removed_files == 0


def test_detect_drift_returns_empty_when_snapshot_is_none():
    idx = _make_index(_mods("a.py"))
    # None is treated the same as falsy empty dict
    report = detect_drift(idx, None)  # type: ignore[arg-type]
    assert not report.has_alerts


# ── detect_drift — no significant changes ────────────────────────────────────


def test_detect_drift_no_alerts_when_no_significant_change():
    files = _mods("a.py", "b.py", "c.py")
    edges = _edges(("a.py", "b.py"), ("c.py", "b.py"))
    idx = _make_index(files, edges)
    snap = _make_snapshot(files, edges)
    report = detect_drift(idx, snap)
    assert not report.has_alerts


# ── detect_drift — new files > 20% ───────────────────────────────────────────


def test_detect_drift_medium_alert_on_file_count_growth():
    # 5 old files + 2 new = 40% growth (> 20%)
    old_files = _mods("a.py", "b.py", "c.py", "d.py", "e.py")
    new_files = _mods("a.py", "b.py", "c.py", "d.py", "e.py", "f.py", "g.py")
    idx = _make_index(new_files)
    snap = _make_snapshot(old_files)
    report = detect_drift(idx, snap)
    assert report.new_files == 2
    levels = [a.level for a in report.alerts]
    assert "medium" in levels
    messages = " ".join(a.message for a in report.alerts)
    assert "new modules" in messages


def test_detect_drift_no_alert_when_file_growth_under_20_percent():
    # 10 old + 1 new = 10% growth
    old_files = _mods(*[f"file{i}.py" for i in range(10)])
    new_files = _mods(*[f"file{i}.py" for i in range(11)])
    idx = _make_index(new_files)
    snap = _make_snapshot(old_files)
    report = detect_drift(idx, snap)
    # No medium alert for new_modules (there may be zero alerts total)
    new_module_alerts = [a for a in report.alerts if "new modules" in a.message]
    assert len(new_module_alerts) == 0


# ── detect_drift — hub gained dependents → high alert ────────────────────────


def test_detect_drift_high_alert_hub_gained_dependents():
    files = _mods("core.py", "a.py", "b.py", "c.py", "d.py", "e.py")

    # Before: core.py had 1 dependent
    old_edges = _edges(("a.py", "core.py"))

    # After: core.py gained 4 more (total 5 — delta = 4 > 3)
    new_edges = _edges(
        ("a.py", "core.py"),
        ("b.py", "core.py"),
        ("c.py", "core.py"),
        ("d.py", "core.py"),
        ("e.py", "core.py"),
    )

    idx = _make_index(files, new_edges)
    snap = _make_snapshot(files, old_edges)
    report = detect_drift(idx, snap)

    high_alerts = [a for a in report.alerts if a.level == "high"]
    assert len(high_alerts) >= 1
    assert any("core.py" in a.message for a in high_alerts)


# ── detect_drift — new hub (reached >= 5 dependents) → medium alert ──────────


def test_detect_drift_medium_alert_new_hub():
    files = _mods("utils.py", "a.py", "b.py", "c.py", "d.py", "e.py")

    # Before: utils.py had 2 dependents (not a hub)
    old_edges = _edges(("a.py", "utils.py"), ("b.py", "utils.py"))

    # After: utils.py has 5 dependents (hub threshold reached)
    new_edges = _edges(
        ("a.py", "utils.py"),
        ("b.py", "utils.py"),
        ("c.py", "utils.py"),
        ("d.py", "utils.py"),
        ("e.py", "utils.py"),
    )

    idx = _make_index(files, new_edges)
    snap = _make_snapshot(files, old_edges)
    report = detect_drift(idx, snap)

    assert "utils.py" in report.new_hubs
    medium_alerts = [a for a in report.alerts if a.level == "medium"]
    assert any("utils.py" in a.message and "hub" in a.message for a in medium_alerts)


# ── detect_drift — lost hub → info alert ─────────────────────────────────────


def test_detect_drift_info_alert_lost_hub():
    files = _mods("base.py", "a.py", "b.py", "c.py", "d.py", "e.py", "f.py", "g.py")

    # Before: base.py had 7 dependents (hub)
    old_edges = _edges(
        ("a.py", "base.py"),
        ("b.py", "base.py"),
        ("c.py", "base.py"),
        ("d.py", "base.py"),
        ("e.py", "base.py"),
        ("f.py", "base.py"),
        ("g.py", "base.py"),
    )

    # After: base.py has only 1 dependent (lost 6 > 5)
    new_edges = _edges(("a.py", "base.py"))

    idx = _make_index(files, new_edges)
    snap = _make_snapshot(files, old_edges)
    report = detect_drift(idx, snap)

    assert "base.py" in report.lost_hubs
    info_alerts = [a for a in report.alerts if a.level == "info"]
    assert any("base.py" in a.message for a in info_alerts)


# ── detect_drift — multiple alerts in one report ─────────────────────────────


def test_detect_drift_multiple_alerts():
    # 4 old files; add 2 new (50% growth → medium alert)
    # core.py gains 4 dependents (delta=4 > 3 → high alert)
    old_files = _mods("core.py", "a.py", "b.py", "c.py")
    new_files = _mods("core.py", "a.py", "b.py", "c.py", "d.py", "e.py")  # +2 = 50%

    old_edges = _edges(("a.py", "core.py"))
    new_edges = _edges(
        ("a.py", "core.py"),
        ("b.py", "core.py"),
        ("c.py", "core.py"),
        ("d.py", "core.py"),
        ("e.py", "core.py"),
    )

    idx = _make_index(new_files, new_edges)
    snap = _make_snapshot(old_files, old_edges)
    report = detect_drift(idx, snap)

    assert len(report.alerts) >= 2


# ── detect_drift — dependency_growth tracking ────────────────────────────────


def test_detect_drift_dependency_growth_tracked():
    files = _mods("lib.py", "a.py", "b.py", "c.py", "d.py", "e.py", "f.py", "g.py")

    old_edges = _edges(("a.py", "lib.py"))
    new_edges = _edges(
        ("a.py", "lib.py"),
        ("b.py", "lib.py"),
        ("c.py", "lib.py"),
        ("d.py", "lib.py"),
        ("e.py", "lib.py"),
        ("f.py", "lib.py"),
        ("g.py", "lib.py"),
    )

    idx = _make_index(files, new_edges)
    snap = _make_snapshot(files, old_edges)
    report = detect_drift(idx, snap)

    assert "lib.py" in report.dependency_growth
    assert report.dependency_growth["lib.py"] > 0


# ── detect_drift — new_hubs populated correctly ──────────────────────────────


def test_detect_drift_new_hubs_list_correct():
    files = _mods("hub1.py", "hub2.py", "a.py", "b.py", "c.py", "d.py", "e.py")

    old_edges: list[dict] = []  # no hubs before
    new_edges = _edges(
        ("a.py", "hub1.py"),
        ("b.py", "hub1.py"),
        ("c.py", "hub1.py"),
        ("d.py", "hub1.py"),
        ("e.py", "hub1.py"),
        ("a.py", "hub2.py"),
        ("b.py", "hub2.py"),
        ("c.py", "hub2.py"),
        ("d.py", "hub2.py"),
        ("e.py", "hub2.py"),
    )

    idx = _make_index(files, new_edges)
    snap = _make_snapshot(files, old_edges)
    report = detect_drift(idx, snap)

    assert "hub1.py" in report.new_hubs
    assert "hub2.py" in report.new_hubs


# ── detect_drift — alert levels are valid ────────────────────────────────────


def test_detect_drift_alert_levels_are_valid():
    files = _mods("core.py", "a.py", "b.py", "c.py", "d.py", "e.py")
    old_edges = _edges(("a.py", "core.py"))
    new_edges = _edges(
        ("a.py", "core.py"),
        ("b.py", "core.py"),
        ("c.py", "core.py"),
        ("d.py", "core.py"),
        ("e.py", "core.py"),
    )
    idx = _make_index(files, new_edges)
    snap = _make_snapshot(files, old_edges)
    report = detect_drift(idx, snap)

    valid_levels = {"high", "medium", "info"}
    for alert in report.alerts:
        assert alert.level in valid_levels, f"Invalid level: {alert.level!r}"


# ── get_drift_report — missing .kodara/ ──────────────────────────────────────


def test_get_drift_report_empty_on_missing_kodara(tmp_path):
    report = get_drift_report(str(tmp_path))
    assert not report.has_alerts
    assert isinstance(report, DriftReport)


# ── get_drift_report — exception handled gracefully ──────────────────────────


def test_get_drift_report_handles_exception_gracefully(tmp_path):
    # MemoryStore is imported lazily inside get_drift_report, so patch at the
    # source module level where it is ultimately imported from.
    with patch("kodara.memory.store.MemoryStore.__init__", side_effect=RuntimeError("crash")):
        report = get_drift_report(str(tmp_path))
    assert not report.has_alerts
    assert isinstance(report, DriftReport)


# ── drift CLI command ─────────────────────────────────────────────────────────


def test_drift_cli_clean_repo(tmp_path):
    from kodara.cli.main import main
    runner = CliRunner()

    with patch("kodara.drift.detector.get_drift_report") as mock_get:
        mock_get.return_value = DriftReport()
        result = runner.invoke(main, ["drift", str(tmp_path)])

    assert result.exit_code == 0
    assert "No significant drift detected" in result.output


def test_drift_cli_shows_alert_count(tmp_path):
    from kodara.cli.main import main
    runner = CliRunner()

    alerts = [
        DriftAlert(level="high", message="core.py gained 5 new dependents", detail="Now 6"),
        DriftAlert(level="medium", message="utils.py became a hub (5 dependents)"),
    ]
    report = DriftReport(alerts=alerts, new_files=3, new_hubs=["utils.py"])

    with patch("kodara.drift.detector.get_drift_report") as mock_get:
        mock_get.return_value = report
        result = runner.invoke(main, ["drift", str(tmp_path)])

    assert result.exit_code == 0
    assert "2 alerts" in result.output
    assert "[HIGH]" in result.output
    assert "[MED]" in result.output
    assert "New files: +3" in result.output
