"""Tests for intelligence.learning -- metrics, improvement, experiments."""

from __future__ import annotations

import json

from orxaq.intelligence.learning.experiment import Experiment, ExperimentTracker
from orxaq.intelligence.learning.improvement import Comparison, ImprovementDetector
from orxaq.intelligence.learning.metrics import DiscoveryMetrics, MetricCollector

# ---------------------------------------------------------------------------
# DiscoveryMetrics
# ---------------------------------------------------------------------------


class TestDiscoveryMetrics:
    def test_creation_defaults(self):
        m = DiscoveryMetrics(run_id="r1")
        assert m.run_id == "r1"
        assert m.shd == 0
        assert m.edge_f1 == 0.0
        assert m.algorithm == ""
        assert isinstance(m.config, dict)

    def test_to_dict_roundtrip(self):
        m = DiscoveryMetrics(
            run_id="r2",
            algorithm="pc",
            shd=3,
            edge_precision=0.85,
            edge_recall=0.90,
            edge_f1=0.8743,
            orientation_accuracy=0.75,
            execution_time_ms=123.456,
            config={"alpha": 0.05},
        )
        d = m.to_dict()
        assert d["run_id"] == "r2"
        assert d["algorithm"] == "pc"
        assert d["shd"] == 3
        assert d["edge_precision"] == 0.85
        assert d["edge_recall"] == 0.9
        assert d["edge_f1"] == 0.8743
        assert d["execution_time_ms"] == 123.46
        assert d["config"] == {"alpha": 0.05}

    def test_edge_f1_computed(self):
        m = DiscoveryMetrics(run_id="r3", edge_precision=0.8, edge_recall=0.6)
        expected = 2 * (0.8 * 0.6) / (0.8 + 0.6)
        assert abs(m.edge_f1_computed - expected) < 1e-9

    def test_edge_f1_computed_zero_division(self):
        m = DiscoveryMetrics(run_id="r4", edge_precision=0.0, edge_recall=0.0)
        assert m.edge_f1_computed == 0.0


# ---------------------------------------------------------------------------
# MetricCollector
# ---------------------------------------------------------------------------


class TestMetricCollector:
    def test_record_and_latest(self):
        mc = MetricCollector()
        m1 = DiscoveryMetrics(run_id="a", shd=5)
        m2 = DiscoveryMetrics(run_id="b", shd=3)
        mc.record(m1)
        mc.record(m2)
        assert len(mc.runs) == 2
        latest = mc.latest(1)
        assert len(latest) == 1
        assert latest[0].run_id == "b"

    def test_latest_multiple(self):
        mc = MetricCollector()
        for i in range(5):
            mc.record(DiscoveryMetrics(run_id=f"r{i}"))
        latest = mc.latest(3)
        assert [r.run_id for r in latest] == ["r2", "r3", "r4"]

    def test_by_algorithm(self):
        mc = MetricCollector()
        mc.record(DiscoveryMetrics(run_id="a", algorithm="pc"))
        mc.record(DiscoveryMetrics(run_id="b", algorithm="ges"))
        mc.record(DiscoveryMetrics(run_id="c", algorithm="pc"))
        pc_runs = mc.by_algorithm("pc")
        assert len(pc_runs) == 2
        assert all(r.algorithm == "pc" for r in pc_runs)

    def test_best_run(self):
        mc = MetricCollector()
        mc.record(DiscoveryMetrics(run_id="a", edge_f1=0.7))
        mc.record(DiscoveryMetrics(run_id="b", edge_f1=0.9))
        mc.record(DiscoveryMetrics(run_id="c", edge_f1=0.8))
        best = mc.best_run("edge_f1")
        assert best is not None
        assert best.run_id == "b"

    def test_best_run_empty(self):
        mc = MetricCollector()
        assert mc.best_run() is None

    def test_summary(self):
        mc = MetricCollector()
        mc.record(
            DiscoveryMetrics(run_id="a", algorithm="pc", shd=5, edge_f1=0.7, execution_time_ms=100)
        )
        mc.record(
            DiscoveryMetrics(run_id="b", algorithm="ges", shd=2, edge_f1=0.9, execution_time_ms=200)
        )
        s = mc.summary()
        assert s["total_runs"] == 2
        assert s["algorithms"] == ["ges", "pc"]
        assert s["best_shd"] == 2
        assert s["best_f1"] == 0.9
        assert s["avg_execution_ms"] == 150.0

    def test_summary_empty(self):
        mc = MetricCollector()
        s = mc.summary()
        assert s["total_runs"] == 0
        assert s["best_shd"] is None

    def test_persistence_save_and_load(self, tmp_path):
        storage = tmp_path / "metrics.json"
        mc = MetricCollector(storage_path=storage)
        mc.record(
            DiscoveryMetrics(
                run_id="x",
                algorithm="pc",
                shd=4,
                edge_f1=0.85,
                timestamp=1000.0,
            )
        )
        assert storage.exists()
        data = json.loads(storage.read_text())
        assert len(data) == 1
        assert data[0]["run_id"] == "x"

        # Load into a fresh collector
        mc2 = MetricCollector()
        mc2.load(storage)
        assert len(mc2.runs) == 1
        assert mc2.runs[0].run_id == "x"
        assert mc2.runs[0].shd == 4

    def test_load_nonexistent_path(self, tmp_path):
        mc = MetricCollector()
        mc.load(tmp_path / "does_not_exist.json")
        assert len(mc.runs) == 0


# ---------------------------------------------------------------------------
# ImprovementDetector
# ---------------------------------------------------------------------------


class TestImprovementDetector:
    def _make_metrics(self, **kwargs) -> DiscoveryMetrics:
        defaults = {
            "run_id": "test",
            "edge_precision": 0.8,
            "edge_recall": 0.8,
            "edge_f1": 0.8,
            "orientation_accuracy": 0.7,
            "shd": 5,
            "execution_time_ms": 100.0,
        }
        defaults.update(kwargs)
        return DiscoveryMetrics(**defaults)

    def test_compare_improvement(self):
        detector = ImprovementDetector(tolerance=0.01)
        prev = self._make_metrics(run_id="prev", edge_f1=0.7)
        curr = self._make_metrics(run_id="curr", edge_f1=0.9)
        comps = detector.compare(prev, curr)
        f1_comp = next(c for c in comps if c.metric_name == "edge_f1")
        assert f1_comp.improved is True
        assert f1_comp.regression is False
        assert abs(f1_comp.delta - 0.2) < 1e-9

    def test_compare_regression(self):
        detector = ImprovementDetector(tolerance=0.01)
        prev = self._make_metrics(run_id="prev", shd=3)
        curr = self._make_metrics(run_id="curr", shd=8)
        comps = detector.compare(prev, curr)
        shd_comp = next(c for c in comps if c.metric_name == "shd")
        assert shd_comp.regression is True
        assert shd_comp.improved is False

    def test_compare_unchanged_within_tolerance(self):
        detector = ImprovementDetector(tolerance=0.05)
        prev = self._make_metrics(run_id="prev", edge_f1=0.80)
        curr = self._make_metrics(run_id="curr", edge_f1=0.82)
        comps = detector.compare(prev, curr)
        f1_comp = next(c for c in comps if c.metric_name == "edge_f1")
        assert f1_comp.improved is False
        assert f1_comp.regression is False

    def test_has_regression_true(self):
        detector = ImprovementDetector()
        prev = self._make_metrics(run_id="prev", edge_f1=0.9)
        curr = self._make_metrics(run_id="curr", edge_f1=0.5)
        assert detector.has_regression(prev, curr) is True

    def test_has_regression_false(self):
        detector = ImprovementDetector()
        prev = self._make_metrics(run_id="prev")
        curr = self._make_metrics(run_id="curr", edge_f1=0.85)
        # edge_f1 improved, everything else same
        assert detector.has_regression(prev, curr) is False

    def test_improvement_summary_structure(self):
        detector = ImprovementDetector(tolerance=0.01)
        prev = self._make_metrics(run_id="prev", edge_f1=0.7, shd=3)
        curr = self._make_metrics(run_id="curr", edge_f1=0.9, shd=8)
        summary = detector.improvement_summary(prev, curr)
        assert "improved" in summary
        assert "regressed" in summary
        assert "unchanged" in summary
        assert "has_regression" in summary
        assert "net_improvements" in summary
        assert summary["has_regression"] is True

    def test_lower_is_better_improvement(self):
        detector = ImprovementDetector(tolerance=0.01)
        prev = self._make_metrics(run_id="prev", execution_time_ms=200.0)
        curr = self._make_metrics(run_id="curr", execution_time_ms=50.0)
        comps = detector.compare(prev, curr)
        time_comp = next(c for c in comps if c.metric_name == "execution_time_ms")
        assert time_comp.improved is True
        assert time_comp.regression is False


# ---------------------------------------------------------------------------
# Comparison
# ---------------------------------------------------------------------------


class TestComparison:
    def test_to_dict(self):
        c = Comparison(
            metric_name="edge_f1",
            previous_value=0.7123,
            current_value=0.8567,
            delta=0.1444,
            improved=True,
            regression=False,
        )
        d = c.to_dict()
        assert d["metric"] == "edge_f1"
        assert d["previous"] == 0.7123
        assert d["current"] == 0.8567
        assert d["delta"] == 0.1444
        assert d["improved"] is True
        assert d["regression"] is False


# ---------------------------------------------------------------------------
# ExperimentTracker
# ---------------------------------------------------------------------------


class TestExperimentTracker:
    def test_create_experiment(self):
        tracker = ExperimentTracker()
        exp = tracker.create("e1", "test run", "pc", tags=["baseline"])
        assert exp.experiment_id == "e1"
        assert exp.status == "pending"
        assert exp.tags == ["baseline"]

    def test_start_experiment(self):
        tracker = ExperimentTracker()
        tracker.create("e1", "test run", "pc")
        exp = tracker.start("e1")
        assert exp.status == "running"

    def test_complete_experiment(self):
        tracker = ExperimentTracker()
        tracker.create("e1", "test run", "pc")
        tracker.start("e1")
        exp = tracker.complete("e1", {"shd": 3, "f1": 0.9})
        assert exp.status == "completed"
        assert exp.results == {"shd": 3, "f1": 0.9}
        assert exp.duration_ms >= 0
        assert exp.end_time >= exp.start_time

    def test_fail_experiment(self):
        tracker = ExperimentTracker()
        tracker.create("e1", "test run", "pc")
        tracker.start("e1")
        exp = tracker.fail("e1", "timeout")
        assert exp.status == "failed"
        assert exp.results == {"error": "timeout"}

    def test_get_experiment(self):
        tracker = ExperimentTracker()
        tracker.create("e1", "test run", "pc")
        assert tracker.get("e1") is not None
        assert tracker.get("nonexistent") is None

    def test_list_filter_by_status(self):
        tracker = ExperimentTracker()
        tracker.create("e1", "run1", "pc")
        tracker.create("e2", "run2", "ges")
        tracker.start("e1")
        pending = tracker.list_experiments(status="pending")
        assert len(pending) == 1
        assert pending[0].experiment_id == "e2"

    def test_list_filter_by_algorithm(self):
        tracker = ExperimentTracker()
        tracker.create("e1", "run1", "pc")
        tracker.create("e2", "run2", "ges")
        tracker.create("e3", "run3", "pc")
        pc_exps = tracker.list_experiments(algorithm="pc")
        assert len(pc_exps) == 2

    def test_list_filter_by_tag(self):
        tracker = ExperimentTracker()
        tracker.create("e1", "run1", "pc", tags=["baseline"])
        tracker.create("e2", "run2", "ges", tags=["tuned"])
        tagged = tracker.list_experiments(tag="baseline")
        assert len(tagged) == 1
        assert tagged[0].experiment_id == "e1"

    def test_summary(self):
        tracker = ExperimentTracker()
        tracker.create("e1", "run1", "pc")
        tracker.create("e2", "run2", "ges")
        tracker.start("e1")
        tracker.complete("e1", {"f1": 0.9})
        s = tracker.summary()
        assert s["total"] == 2
        assert s["by_status"]["completed"] == 1
        assert s["by_status"]["pending"] == 1
        assert s["by_algorithm"]["pc"] == 1
        assert s["by_algorithm"]["ges"] == 1

    def test_summary_empty(self):
        tracker = ExperimentTracker()
        s = tracker.summary()
        assert s["total"] == 0

    def test_persistence(self, tmp_path):
        storage = tmp_path / "experiments.json"
        tracker = ExperimentTracker(storage_path=storage)
        tracker.create("e1", "run1", "pc", tags=["v1"])
        tracker.start("e1")
        tracker.complete("e1", {"shd": 2})
        assert storage.exists()

        # Load into fresh tracker
        tracker2 = ExperimentTracker()
        tracker2.load(storage)
        exp = tracker2.get("e1")
        assert exp is not None
        assert exp.status == "completed"
        assert exp.results == {"shd": 2}
        assert exp.tags == ["v1"]


# ---------------------------------------------------------------------------
# Experiment dataclass
# ---------------------------------------------------------------------------


class TestExperiment:
    def test_duration_ms_not_ended(self):
        exp = Experiment(experiment_id="e1", name="test", algorithm="pc", end_time=0.0)
        assert exp.duration_ms == 0.0

    def test_duration_ms_completed(self):
        exp = Experiment(
            experiment_id="e1",
            name="test",
            algorithm="pc",
            start_time=1000.0,
            end_time=1002.5,
        )
        assert abs(exp.duration_ms - 2500.0) < 1e-6

    def test_to_dict(self):
        exp = Experiment(
            experiment_id="e1",
            name="test",
            algorithm="pc",
            start_time=1000.0,
            end_time=1001.0,
            status="completed",
            tags=["v1"],
        )
        d = exp.to_dict()
        assert d["experiment_id"] == "e1"
        assert d["duration_ms"] == 1000.0
        assert d["status"] == "completed"
        assert d["tags"] == ["v1"]


# ---------------------------------------------------------------------------
# Package-level imports
# ---------------------------------------------------------------------------


class TestPackageImports:
    def test_all_exports(self):
        from orxaq.intelligence.learning import (
            Comparison,
            DiscoveryMetrics,
            Experiment,
            ExperimentTracker,
            ImprovementDetector,
            MetricCollector,
        )

        assert all(
            cls is not None
            for cls in [
                Comparison,
                DiscoveryMetrics,
                Experiment,
                ExperimentTracker,
                ImprovementDetector,
                MetricCollector,
            ]
        )
