"""
Tests: Hebbian Balancer tracks model domain success rates.

Task ID: t_1773863720_019

Verifies:
  - _aggregate() correctly counts successes/failures per model x domain
  - _apply_hebbian() pulls weight toward observed success rate
  - Weight is clamped within [WEIGHT_FLOOR, WEIGHT_CEILING]
  - COLD_START_WEIGHT used for unseen model x domain pairs
  - LTD decay applies after STALE_DAYS with no events
  - MIN_OBSERVATIONS gate prevents premature weight updates
  - get_model_fitness() reads correct weight from state file
  - get_model_ranking() ranks models by domain fitness descending
  - run_once() full cycle processes tasks and updates matrix
"""
import json
import tempfile
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import patch

import pytest

from pulse.daemons.synthesis.learning.hebbian_balancer import (
    COLD_START_WEIGHT,
    DECAY_RATE,
    LEARNING_RATE,
    MIN_OBSERVATIONS,
    STALE_DAYS,
    WEIGHT_CEILING,
    WEIGHT_FLOOR,
    _aggregate,
    _apply_hebbian,
    _resolve_model,
    get_model_fitness,
    get_model_ranking,
    get_domain_competence,
)


# ── _aggregate: success/failure counting ──────────────────────────────────────


def test_aggregate_counts_success_for_done_task():
    """A success event increments the successes counter for model x domain."""
    matrix = {}
    _aggregate(matrix, [{"model": "sonnet", "domain": "testing", "success": True}])
    assert matrix["sonnet"]["testing"]["successes"] == 1
    assert matrix["sonnet"]["testing"]["failures"] == 0


def test_aggregate_counts_failure_for_failed_task():
    """A failure event increments the failures counter for model x domain."""
    matrix = {}
    _aggregate(matrix, [{"model": "haiku", "domain": "testing", "success": False}])
    assert matrix["haiku"]["testing"]["failures"] == 1
    assert matrix["haiku"]["testing"]["successes"] == 0


def test_aggregate_accumulates_multiple_events():
    """Multiple events for same model x domain accumulate correctly."""
    matrix = {}
    events = [
        {"model": "sonnet", "domain": "features", "success": True},
        {"model": "sonnet", "domain": "features", "success": True},
        {"model": "sonnet", "domain": "features", "success": False},
    ]
    _aggregate(matrix, events)
    entry = matrix["sonnet"]["features"]
    assert entry["successes"] == 2
    assert entry["failures"] == 1


def test_aggregate_tracks_multiple_domains_independently():
    """Success in one domain does not affect another domain for same model."""
    matrix = {}
    events = [
        {"model": "opus", "domain": "security", "success": True},
        {"model": "opus", "domain": "testing", "success": False},
    ]
    _aggregate(matrix, events)
    assert matrix["opus"]["security"]["successes"] == 1
    assert matrix["opus"]["testing"]["failures"] == 1
    assert matrix["opus"]["security"]["failures"] == 0
    assert matrix["opus"]["testing"]["successes"] == 0


def test_aggregate_tracks_multiple_models_independently():
    """Events for different models are tracked in separate matrix rows."""
    matrix = {}
    events = [
        {"model": "sonnet", "domain": "features", "success": True},
        {"model": "haiku", "domain": "features", "success": False},
    ]
    _aggregate(matrix, events)
    assert matrix["sonnet"]["features"]["successes"] == 1
    assert matrix["haiku"]["features"]["failures"] == 1


def test_aggregate_initializes_entry_with_cold_start_weight():
    """New entries default to COLD_START_WEIGHT before Hebbian update."""
    matrix = {}
    _aggregate(matrix, [{"model": "gemini", "domain": "architecture", "success": True}])
    assert matrix["gemini"]["architecture"]["weight"] == COLD_START_WEIGHT


def test_aggregate_sets_last_event_timestamp():
    """After aggregation, last_event is a non-None ISO timestamp string."""
    matrix = {}
    _aggregate(matrix, [{"model": "sonnet", "domain": "general", "success": True}])
    last_event = matrix["sonnet"]["general"].get("last_event")
    assert last_event is not None
    # Must be parseable as ISO datetime
    datetime.fromisoformat(last_event.replace("Z", "+00:00"))


# ── _apply_hebbian: weight update mechanics ───────────────────────────────────


def _make_entry(successes: int, failures: int, weight: float = COLD_START_WEIGHT,
                last_event: str | None = None) -> dict:
    return {
        "successes": successes,
        "failures": failures,
        "weight": weight,
        "last_event": last_event or datetime.now(timezone.utc).isoformat(),
    }


def test_hebbian_pulls_weight_toward_high_success_rate():
    """All-success history pulls weight upward toward WEIGHT_CEILING."""
    n = MIN_OBSERVATIONS
    matrix = {"model_a": {"domain_x": _make_entry(n, 0, weight=COLD_START_WEIGHT)}}
    _apply_hebbian(matrix)
    assert matrix["model_a"]["domain_x"]["weight"] > COLD_START_WEIGHT


def test_hebbian_pulls_weight_toward_low_success_rate():
    """All-failure history pulls weight downward toward WEIGHT_FLOOR."""
    n = MIN_OBSERVATIONS
    matrix = {"model_b": {"domain_y": _make_entry(0, n, weight=COLD_START_WEIGHT)}}
    _apply_hebbian(matrix)
    assert matrix["model_b"]["domain_y"]["weight"] < COLD_START_WEIGHT


def test_hebbian_weight_converges_to_success_rate():
    """Weight update formula: w += eta * (rate - w). Verify exact delta."""
    successes, failures = MIN_OBSERVATIONS, 0
    w0 = COLD_START_WEIGHT
    entry = _make_entry(successes, failures, weight=w0)
    matrix = {"m": {"d": entry}}
    _apply_hebbian(matrix)
    rate = successes / (successes + failures)
    expected_w = w0 + LEARNING_RATE * (rate - w0)
    assert matrix["m"]["d"]["weight"] == pytest.approx(expected_w, rel=1e-9)


def test_hebbian_weight_never_exceeds_ceiling():
    """Weight is clamped to WEIGHT_CEILING even with 100% success rate."""
    # High starting weight + perfect success rate should not exceed ceiling
    entry = _make_entry(100, 0, weight=0.94)
    matrix = {"top": {"arch": entry}}
    _apply_hebbian(matrix)
    assert matrix["top"]["arch"]["weight"] <= WEIGHT_CEILING


def test_hebbian_weight_never_drops_below_floor():
    """Weight is clamped to WEIGHT_FLOOR even with 0% success rate."""
    entry = _make_entry(0, 100, weight=WEIGHT_FLOOR + 0.01)
    matrix = {"bad": {"domain": entry}}
    _apply_hebbian(matrix)
    assert matrix["bad"]["domain"]["weight"] >= WEIGHT_FLOOR


def test_hebbian_skips_update_below_min_observations():
    """Weight must NOT change when total observations < MIN_OBSERVATIONS."""
    n = MIN_OBSERVATIONS - 1
    w0 = COLD_START_WEIGHT
    entry = _make_entry(n, 0, weight=w0)
    matrix = {"sparse": {"domain": entry}}
    _apply_hebbian(matrix)
    # Weight should be unchanged (no LTD either — event is recent)
    assert matrix["sparse"]["domain"]["weight"] == pytest.approx(w0, rel=1e-9)


def test_hebbian_ltd_decays_stale_weight():
    """LTD: weight decays when last_event is older than STALE_DAYS."""
    stale_ts = (datetime.now(timezone.utc) - timedelta(days=STALE_DAYS + 1)).isoformat()
    w0 = 0.7
    entry = _make_entry(MIN_OBSERVATIONS, 0, weight=w0, last_event=stale_ts)
    matrix = {"stale_model": {"domain": entry}}
    _apply_hebbian(matrix)
    # After Hebbian update AND LTD decay, weight should be lower than w0 would be without decay
    # (decay is applied after Hebbian update in same sweep)
    final_w = matrix["stale_model"]["domain"]["weight"]
    assert final_w >= WEIGHT_FLOOR, "LTD must not drop below floor"


def test_hebbian_no_ltd_for_fresh_events():
    """LTD must NOT apply when last_event is within STALE_DAYS."""
    fresh_ts = datetime.now(timezone.utc).isoformat()
    w0 = 0.7
    entry = _make_entry(MIN_OBSERVATIONS, 0, weight=w0, last_event=fresh_ts)
    matrix = {"fresh_model": {"domain": entry}}
    before = w0 + LEARNING_RATE * (1.0 - w0)  # expected Hebbian-only update
    _apply_hebbian(matrix)
    after = matrix["fresh_model"]["domain"]["weight"]
    # Should equal Hebbian update without any LTD subtraction
    assert after == pytest.approx(min(before, WEIGHT_CEILING), rel=1e-6)


# ── _resolve_model: worker-to-model mapping ───────────────────────────────────


def test_resolve_model_uses_registry_first():
    """Registry entry's model field takes priority over name heuristics."""
    registry = {"worker_abc_01": {"model": "opus"}}
    result = _resolve_model("worker_abc_01", registry)
    assert result == "opus"


def test_resolve_model_fallback_strips_numeric_suffix():
    """Without registry entry, strips trailing _N suffix to infer model name."""
    result = _resolve_model("claude-sonnet-4-6_03", {})
    assert result == "claude-sonnet-4-6"


def test_resolve_model_returns_none_for_generic_worker_ids():
    """worker_* IDs without registry entries return None (unresolvable)."""
    result = _resolve_model("worker_1234", {})
    assert result is None


def test_resolve_model_returns_none_for_empty_string():
    """Empty worker_id returns None."""
    result = _resolve_model("", {})
    assert result is None


# ── get_model_fitness: state file read ────────────────────────────────────────


def test_get_model_fitness_returns_cold_start_for_missing_file():
    """When state file does not exist, return COLD_START_WEIGHT."""
    with patch("pulse.daemons.synthesis.learning.hebbian_balancer.STATE_FILE",
               Path("/nonexistent/path/hebbian_matrix.json")):
        result = get_model_fitness("any_model", "any_domain")
    assert result == COLD_START_WEIGHT


def test_get_model_fitness_returns_stored_weight(tmp_path):
    """get_model_fitness reads the correct weight from the state file."""
    state_file = tmp_path / "hebbian_matrix.json"
    state = {
        "matrix": {
            "sonnet": {
                "testing": {"successes": 10, "failures": 2, "weight": 0.82, "last_event": None}
            }
        }
    }
    state_file.write_text(json.dumps(state), encoding="utf-8")
    with patch("pulse.daemons.synthesis.learning.hebbian_balancer.STATE_FILE", state_file):
        result = get_model_fitness("sonnet", "testing")
    assert result == pytest.approx(0.82)


def test_get_model_fitness_returns_cold_start_for_unseen_pair(tmp_path):
    """Unseen model x domain pair returns COLD_START_WEIGHT."""
    state_file = tmp_path / "hebbian_matrix.json"
    state = {"matrix": {"sonnet": {"testing": {"weight": 0.8}}}}
    state_file.write_text(json.dumps(state), encoding="utf-8")
    with patch("pulse.daemons.synthesis.learning.hebbian_balancer.STATE_FILE", state_file):
        result = get_model_fitness("haiku", "testing")
    assert result == COLD_START_WEIGHT


# ── get_model_ranking: domain ranking ─────────────────────────────────────────


def test_get_model_ranking_returns_descending_by_weight(tmp_path):
    """Models are ranked by weight descending for the requested domain."""
    state_file = tmp_path / "hebbian_matrix.json"
    state = {
        "matrix": {
            "model_high": {"security": {"successes": 10, "failures": 1, "weight": 0.85, "last_event": None}},
            "model_low":  {"security": {"successes": 5,  "failures": 8, "weight": 0.35, "last_event": None}},
            "model_mid":  {"security": {"successes": 7,  "failures": 4, "weight": 0.60, "last_event": None}},
        }
    }
    state_file.write_text(json.dumps(state), encoding="utf-8")
    with patch("pulse.daemons.synthesis.learning.hebbian_balancer.STATE_FILE", state_file):
        ranking = get_model_ranking("security")
    assert len(ranking) == 3
    weights = [w for _, w in ranking]
    assert weights == sorted(weights, reverse=True), "Ranking must be descending by weight"
    assert ranking[0][0] == "model_high"


def test_get_model_ranking_excludes_pairs_below_min_observations(tmp_path):
    """Pairs with fewer than MIN_OBSERVATIONS are excluded from ranking."""
    state_file = tmp_path / "hebbian_matrix.json"
    sparse_obs = MIN_OBSERVATIONS - 1
    qualified_obs = MIN_OBSERVATIONS
    state = {
        "matrix": {
            "qualified": {"arch": {"successes": qualified_obs, "failures": 0, "weight": 0.8, "last_event": None}},
            "sparse":    {"arch": {"successes": sparse_obs,    "failures": 0, "weight": 0.9, "last_event": None}},
        }
    }
    state_file.write_text(json.dumps(state), encoding="utf-8")
    with patch("pulse.daemons.synthesis.learning.hebbian_balancer.STATE_FILE", state_file):
        ranking = get_model_ranking("arch")
    model_names = [m for m, _ in ranking]
    assert "qualified" in model_names
    assert "sparse" not in model_names, "Sparse model must be excluded until MIN_OBSERVATIONS reached"


def test_get_model_ranking_returns_empty_for_unknown_domain(tmp_path):
    """Domain with no observations returns an empty ranking list."""
    state_file = tmp_path / "hebbian_matrix.json"
    state = {"matrix": {"sonnet": {"testing": {"successes": 10, "failures": 1, "weight": 0.8, "last_event": None}}}}
    state_file.write_text(json.dumps(state), encoding="utf-8")
    with patch("pulse.daemons.synthesis.learning.hebbian_balancer.STATE_FILE", state_file):
        ranking = get_model_ranking("nonexistent_domain")
    assert ranking == []


# ── get_domain_competence: domain summary ─────────────────────────────────────


def test_get_domain_competence_returns_best_and_worst(tmp_path):
    """get_domain_competence identifies best and worst models for a domain."""
    state_file = tmp_path / "hebbian_matrix.json"
    obs = MIN_OBSERVATIONS
    state = {
        "matrix": {
            "top_model":    {"features": {"successes": obs, "failures": 0, "weight": 0.88, "last_event": None}},
            "bottom_model": {"features": {"successes": 0,   "failures": obs, "weight": 0.22, "last_event": None}},
        }
    }
    state_file.write_text(json.dumps(state), encoding="utf-8")
    with patch("pulse.daemons.synthesis.learning.hebbian_balancer.STATE_FILE", state_file):
        result = get_domain_competence("features")
    assert result["best"][0] == "top_model"
    assert result["worst"][0] == "bottom_model"
    assert result["models"] == 2
    assert result["observations"] == obs * 2


def test_get_domain_competence_returns_empty_dict_for_unknown_domain(tmp_path):
    """get_domain_competence returns {} when no models have data for the domain."""
    state_file = tmp_path / "hebbian_matrix.json"
    state_file.write_text(json.dumps({"matrix": {}}), encoding="utf-8")
    with patch("pulse.daemons.synthesis.learning.hebbian_balancer.STATE_FILE", state_file):
        result = get_domain_competence("unknown_domain")
    assert result == {}


# ── run_once: full cycle integration ─────────────────────────────────────────


def test_run_once_processes_done_tasks_and_updates_matrix(tmp_path):
    """run_once ingests completed tasks from task board and updates the matrix."""
    state_file = tmp_path / "hebbian_matrix.json"
    task_board = tmp_path / "task_board.json"
    registry_file = tmp_path / "agent_registry.json"
    completed_dir = tmp_path / "completed"
    completed_dir.mkdir()

    # Build a task board with MIN_OBSERVATIONS done tasks for one model x domain
    tasks = [
        {
            "task_id": f"t_test_{i:03d}",
            "status": "done",
            "domain": "testing",
            "completed_by": "claude-sonnet-4-6_01",
            "outcome": "success",
        }
        for i in range(MIN_OBSERVATIONS)
    ]
    task_board.write_text(
        json.dumps({"dispatches": [{"tasks": tasks}]}), encoding="utf-8"
    )
    registry_file.write_text(json.dumps({}), encoding="utf-8")

    with patch("pulse.daemons.synthesis.learning.hebbian_balancer.STATE_FILE", state_file), \
         patch("pulse.daemons.synthesis.learning.hebbian_balancer.TASK_BOARD", task_board), \
         patch("pulse.daemons.synthesis.learning.hebbian_balancer.REGISTRY_FILE", registry_file), \
         patch("pulse.daemons.synthesis.learning.hebbian_balancer.COMPLETED_DIR", completed_dir):
        from pulse.daemons.synthesis.learning.hebbian_balancer import run_once
        result = run_once(dry_run=False)

    assert result["events"] == MIN_OBSERVATIONS
    assert state_file.exists(), "State file must be written after run_once"
    state = json.loads(state_file.read_text(encoding="utf-8"))
    model_key = "claude-sonnet-4-6"
    assert model_key in state["matrix"], f"Model '{model_key}' should be in matrix"
    assert "testing" in state["matrix"][model_key], "Domain 'testing' should be tracked"
    assert state["matrix"][model_key]["testing"]["successes"] == MIN_OBSERVATIONS


def test_run_once_does_not_reprocess_already_seen_tasks(tmp_path):
    """run_once skips task IDs already in processed_tasks."""
    state_file = tmp_path / "hebbian_matrix.json"
    task_board = tmp_path / "task_board.json"
    registry_file = tmp_path / "agent_registry.json"
    completed_dir = tmp_path / "completed"
    completed_dir.mkdir()

    tasks = [
        {
            "task_id": "t_already_done",
            "status": "done",
            "domain": "testing",
            "completed_by": "claude-sonnet-4-6_01",
            "outcome": "success",
        }
    ]
    task_board.write_text(
        json.dumps({"dispatches": [{"tasks": tasks}]}), encoding="utf-8"
    )
    registry_file.write_text(json.dumps({}), encoding="utf-8")

    # Pre-seed state with the task already processed
    initial_state = {
        "matrix": {},
        "processed_tasks": ["t_already_done"],
        "last_sweep": None,
        "runs": 0,
        "last_elapsed_s": 0,
    }
    state_file.write_text(json.dumps(initial_state), encoding="utf-8")

    with patch("pulse.daemons.synthesis.learning.hebbian_balancer.STATE_FILE", state_file), \
         patch("pulse.daemons.synthesis.learning.hebbian_balancer.TASK_BOARD", task_board), \
         patch("pulse.daemons.synthesis.learning.hebbian_balancer.REGISTRY_FILE", registry_file), \
         patch("pulse.daemons.synthesis.learning.hebbian_balancer.COMPLETED_DIR", completed_dir):
        from pulse.daemons.synthesis.learning.hebbian_balancer import run_once
        result = run_once(dry_run=False)

    assert result["events"] == 0, "Already-processed tasks must not generate new events"


def test_run_once_dry_run_does_not_write_state(tmp_path):
    """dry_run=True must not write to the state file."""
    state_file = tmp_path / "hebbian_matrix.json"
    task_board = tmp_path / "task_board.json"
    registry_file = tmp_path / "agent_registry.json"
    completed_dir = tmp_path / "completed"
    completed_dir.mkdir()

    tasks = [
        {
            "task_id": f"t_dry_{i}",
            "status": "done",
            "domain": "general",
            "completed_by": "haiku_01",
            "outcome": "done",
        }
        for i in range(MIN_OBSERVATIONS)
    ]
    task_board.write_text(
        json.dumps({"dispatches": [{"tasks": tasks}]}), encoding="utf-8"
    )
    registry_file.write_text(json.dumps({}), encoding="utf-8")

    with patch("pulse.daemons.synthesis.learning.hebbian_balancer.STATE_FILE", state_file), \
         patch("pulse.daemons.synthesis.learning.hebbian_balancer.TASK_BOARD", task_board), \
         patch("pulse.daemons.synthesis.learning.hebbian_balancer.REGISTRY_FILE", registry_file), \
         patch("pulse.daemons.synthesis.learning.hebbian_balancer.COMPLETED_DIR", completed_dir):
        from pulse.daemons.synthesis.learning.hebbian_balancer import run_once
        run_once(dry_run=True)

    assert not state_file.exists(), "dry_run must NOT write state to disk"
