"""Tests for procedural memory — maturity, operations, matcher."""

from __future__ import annotations

import json

import pytest

from genesis.learning.procedural.matcher import find_best_match, find_relevant
from genesis.learning.procedural.maturity import get_maturity_stage
from genesis.learning.procedural.operations import (
    record_failure,
    record_success,
    record_workaround,
    store_procedure,
    store_procedure_checked,
    update_confidence,
)
from genesis.learning.types import MaturityStage

# ─── Maturity ─────────────────────────────────────────────────────────────────


@pytest.mark.asyncio
async def test_maturity_early_empty(db):
    assert await get_maturity_stage(db) == MaturityStage.EARLY


@pytest.mark.asyncio
async def test_maturity_growing(db):
    for _i in range(55):
        await store_procedure(
            db, task_type="t", principle="p", steps=["s"],
            tools_used=["tool"], context_tags=["tag"],
        )
    assert await get_maturity_stage(db) == MaturityStage.GROWING


@pytest.mark.asyncio
async def test_maturity_ignores_deprecated(db):
    for _i in range(55):
        await store_procedure(
            db, task_type="t", principle="p", steps=["s"],
            tools_used=["tool"], context_tags=["tag"],
        )
    # Deprecate 10 so we drop below 50
    cursor = await db.execute(
        "SELECT id FROM procedural_memory LIMIT 10"
    )
    rows = await cursor.fetchall()
    for r in rows:
        await db.execute(
            "UPDATE procedural_memory SET deprecated = 1 WHERE id = ?", (r[0],)
        )
    await db.commit()
    assert await get_maturity_stage(db) == MaturityStage.EARLY


@pytest.mark.asyncio
async def test_maturity_mature(db):
    # Insert 201 rows directly for speed
    for i in range(201):
        await db.execute(
            "INSERT INTO procedural_memory (id, task_type, principle, steps, tools_used, context_tags, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
            (f"id-{i}", "t", "p", "[]", "[]", "[]", "2026-01-01"),
        )
    await db.commit()
    assert await get_maturity_stage(db) == MaturityStage.MATURE


# ─── Operations ───────────────────────────────────────────────────────────────


@pytest.mark.asyncio
async def test_store_procedure(db):
    pid = await store_procedure(
        db, task_type="deploy", principle="always verify",
        steps=["build", "test", "deploy"],
        tools_used=["docker"], context_tags=["prod"],
    )
    assert len(pid) == 36  # UUID format


@pytest.mark.asyncio
async def test_record_success(db):
    pid = await store_procedure(
        db, task_type="t", principle="p", steps=["s"],
        tools_used=[], context_tags=[],
    )
    assert await record_success(db, pid) is True
    # Check values
    cursor = await db.execute("SELECT success_count, confidence, last_used FROM procedural_memory WHERE id = ?", (pid,))
    row = await cursor.fetchone()
    assert row[0] == 1  # success_count
    # Laplace: (1+1)/(1+0+2) = 2/3
    assert abs(row[1] - 2 / 3) < 1e-9
    assert row[2] is not None  # last_used set


@pytest.mark.asyncio
async def test_record_success_nonexistent(db):
    assert await record_success(db, "nonexistent") is False


@pytest.mark.asyncio
async def test_record_failure(db):
    pid = await store_procedure(
        db, task_type="t", principle="p", steps=["s"],
        tools_used=[], context_tags=[],
    )
    assert await record_failure(db, pid, condition="timeout") is True
    cursor = await db.execute("SELECT failure_count, failure_modes, confidence FROM procedural_memory WHERE id = ?", (pid,))
    row = await cursor.fetchone()
    assert row[0] == 1
    modes = json.loads(row[1])
    assert len(modes) == 1
    assert modes[0]["description"] == "timeout"
    assert modes[0]["times_hit"] == 1
    # Laplace: (0+1)/(0+1+2) = 1/3
    assert abs(row[2] - 1 / 3) < 1e-9


@pytest.mark.asyncio
async def test_record_failure_duplicate_increments(db):
    pid = await store_procedure(
        db, task_type="t", principle="p", steps=["s"],
        tools_used=[], context_tags=[],
    )
    await record_failure(db, pid, condition="timeout")
    await record_failure(db, pid, condition="timeout")
    cursor = await db.execute("SELECT failure_modes FROM procedural_memory WHERE id = ?", (pid,))
    row = await cursor.fetchone()
    modes = json.loads(row[0])
    assert len(modes) == 1
    assert modes[0]["times_hit"] == 2


@pytest.mark.asyncio
async def test_record_failure_nonexistent(db):
    assert await record_failure(db, "nonexistent", condition="x") is False


@pytest.mark.asyncio
async def test_record_workaround(db):
    pid = await store_procedure(
        db, task_type="t", principle="p", steps=["s"],
        tools_used=[], context_tags=[],
    )
    assert await record_workaround(
        db, pid, failed_method="curl", working_method="httpx", context="ssl issue"
    ) is True
    cursor = await db.execute("SELECT attempted_workarounds FROM procedural_memory WHERE id = ?", (pid,))
    row = await cursor.fetchone()
    wa = json.loads(row[0])
    assert len(wa) == 1
    assert wa[0]["description"] == "httpx"
    assert "curl" in wa[0]["outcome"]


@pytest.mark.asyncio
async def test_record_workaround_nonexistent(db):
    assert await record_workaround(db, "nonexistent", failed_method="a", working_method="b", context="c") is False


@pytest.mark.asyncio
async def test_update_confidence(db):
    pid = await store_procedure(
        db, task_type="t", principle="p", steps=["s"],
        tools_used=[], context_tags=[],
    )
    # Initial: s=0, f=0 -> (0+1)/(0+0+2) = 0.5
    conf = await update_confidence(db, pid)
    assert abs(conf - 0.5) < 1e-9

    await record_success(db, pid)
    await record_success(db, pid)
    # s=2, f=0 -> (2+1)/(2+0+2) = 3/4
    conf = await update_confidence(db, pid)
    assert abs(conf - 0.75) < 1e-9


@pytest.mark.asyncio
async def test_update_confidence_nonexistent(db):
    assert await update_confidence(db, "nonexistent") == 0.0


# ─── Matcher ──────────────────────────────────────────────────────────────────


@pytest.mark.asyncio
async def test_record_failure_updates_last_used(db):
    pid = await store_procedure(
        db, task_type="t", principle="p", steps=["s"],
        tools_used=[], context_tags=[],
    )
    await record_failure(db, pid, condition="timeout")
    cursor = await db.execute("SELECT last_used FROM procedural_memory WHERE id = ?", (pid,))
    row = await cursor.fetchone()
    assert row[0] is not None


# ─── Matcher ──────────────────────────────────────────────────────────────────


@pytest.mark.asyncio
async def test_find_best_match_returns_none_empty_context_tags(db):
    await store_procedure(
        db, task_type="deploy", principle="p", steps=["s"],
        tools_used=[], context_tags=["prod"],
    )
    assert await find_best_match(db, "deploy", []) is None


@pytest.mark.asyncio
async def test_find_best_match_returns_none_no_tag_overlap(db):
    await store_procedure(
        db, task_type="deploy", principle="p", steps=["s"],
        tools_used=[], context_tags=["prod"],
    )
    assert await find_best_match(db, "deploy", ["staging", "k8s"]) is None


@pytest.mark.asyncio
async def test_find_best_match_no_procedures(db):
    assert await find_best_match(db, "deploy", ["prod"]) is None


@pytest.mark.asyncio
async def test_find_best_match_single(db):
    pid = await store_procedure(
        db, task_type="deploy", principle="p", steps=["s"],
        tools_used=[], context_tags=["prod", "docker"],
    )
    await record_success(db, pid)
    match = await find_best_match(db, "deploy", ["prod", "docker"])
    assert match is not None
    assert match.procedure_id == pid
    assert match.task_type == "deploy"
    assert match.success_count == 1


@pytest.mark.asyncio
async def test_find_best_match_picks_higher_overlap(db):
    pid1 = await store_procedure(
        db, task_type="deploy", principle="p", steps=["s"],
        tools_used=[], context_tags=["prod"],
    )
    await record_success(db, pid1)
    pid2 = await store_procedure(
        db, task_type="deploy", principle="p", steps=["s"],
        tools_used=[], context_tags=["prod", "docker", "k8s"],
    )
    await record_success(db, pid2)
    # Query with ["prod", "docker"] — pid2 has better overlap (2/3 vs 1/2)
    # Both have same confidence after 1 success: 2/3
    match = await find_best_match(db, "deploy", ["prod", "docker"])
    assert match is not None
    assert match.procedure_id == pid2


@pytest.mark.asyncio
async def test_find_best_match_skips_deprecated(db):
    pid = await store_procedure(
        db, task_type="deploy", principle="p", steps=["s"],
        tools_used=[], context_tags=["prod"],
    )
    await record_success(db, pid)
    await db.execute("UPDATE procedural_memory SET deprecated = 1 WHERE id = ?", (pid,))
    await db.commit()
    assert await find_best_match(db, "deploy", ["prod"]) is None


@pytest.mark.asyncio
async def test_find_best_match_returns_failure_modes_and_workarounds(db):
    pid = await store_procedure(
        db, task_type="deploy", principle="p", steps=["s"],
        tools_used=[], context_tags=["prod"],
    )
    await record_success(db, pid)
    await record_failure(db, pid, condition="timeout")
    await record_workaround(db, pid, failed_method="curl", working_method="httpx", context="ssl")
    match = await find_best_match(db, "deploy", ["prod"])
    assert match is not None
    assert len(match.failure_modes) == 1
    assert len(match.workarounds) == 1


# ─── Explicit-teach store→recall round-trip ──────────────────────────────────


@pytest.mark.asyncio
async def test_store_procedure_explicit_teach_defaults(db):
    """Calling store_procedure with explicit-teach kwargs persists them."""
    pid = await store_procedure(
        db,
        task_type="discourse-forum-registration",
        principle="Browser is required; raw API returns fake success.",
        steps=["navigate", "fill", "click", "verify"],
        tools_used=["browser_navigate", "browser_fill", "browser_click"],
        context_tags=["discourse", "forum", "registration"],
        activation_tier="L3",
        speculative=0,
        success_count=1,
        confidence=2 / 3,
    )
    cursor = await db.execute(
        "SELECT speculative, success_count, confidence, activation_tier "
        "FROM procedural_memory WHERE id = ?",
        (pid,),
    )
    row = await cursor.fetchone()
    assert row[0] == 0  # speculative
    assert row[1] == 1  # success_count
    assert abs(row[2] - 2 / 3) < 1e-9  # Laplace for 1 success
    assert row[3] == "L3"  # activation_tier


@pytest.mark.asyncio
async def test_find_relevant_surfaces_explicit_teach(db):
    """An explicit-teach procedure must be visible to procedure_recall.

    Regression test for the bug where procedure_store wrote
    speculative=1, confidence=0.0 — making the procedure invisible to
    find_relevant's confidence>=0.3 filter.
    """
    await store_procedure(
        db,
        task_type="discourse-forum-registration",
        principle="Use browser, not API.",
        steps=["navigate", "fill", "click"],
        tools_used=["browser_navigate"],
        context_tags=["discourse", "forum", "registration"],
        activation_tier="L3",
        speculative=0,
        success_count=1,
        confidence=2 / 3,
    )
    matches = await find_relevant(db, ["discourse", "forum"], limit=3)
    assert len(matches) >= 1
    assert any(m.task_type == "discourse-forum-registration" for m in matches)


@pytest.mark.asyncio
async def test_find_relevant_still_hides_speculative_extractor_writes(db):
    """Auto-extracted procedures (speculative=1, confidence=0.0) stay hidden.

    Protects the extractor pipeline from regression — only explicit-teach
    writes should bypass the speculative quarantine.
    """
    await store_procedure(
        db,
        task_type="auto-extracted-task",
        principle="hypothetical procedure",
        steps=["maybe-do-this"],
        tools_used=["Bash"],
        context_tags=["extracted", "hypothesis"],
        # extractor defaults: speculative=1, success_count=0, confidence=0.0
    )
    matches = await find_relevant(db, ["extracted", "hypothesis"], limit=3)
    assert all(m.task_type != "auto-extracted-task" for m in matches)


# ─── Conflict Detection ──────────────────────────────────────────────────────


@pytest.mark.asyncio
async def test_store_checked_exact_match_upserts(db):
    """Same task_type → update existing, preserve counts, bump version."""
    proc_id = await store_procedure(
        db, task_type="deploy-safety", principle="original",
        steps=["step-a"], tools_used=["Bash"], context_tags=["deploy"],
        speculative=0, success_count=3, confidence=0.8,
    )
    result = await store_procedure_checked(
        db, task_type="deploy-safety", principle="updated principle",
        steps=["step-b", "step-c"], tools_used=["Bash"],
        context_tags=["deploy", "safety"],
    )
    assert result.action == "updated"
    assert result.procedure_id == proc_id
    # Read back and verify
    from genesis.db.crud.procedural import get_by_id
    row = await get_by_id(db, proc_id)
    assert row["principle"] == "updated principle"
    assert json.loads(row["steps"]) == ["step-b", "step-c"]
    assert row["version"] == 2  # bumped
    assert row["success_count"] == 3  # preserved
    assert row["confidence"] == 0.8  # preserved


@pytest.mark.asyncio
async def test_store_checked_auto_skips_explicit(db):
    """Auto-extracted should not overwrite explicit-teach."""
    proc_id = await store_procedure(
        db, task_type="manual-task", principle="human taught",
        steps=["do-this"], tools_used=["Bash"], context_tags=["manual"],
        speculative=0, success_count=1, confidence=2 / 3,
    )
    result = await store_procedure_checked(
        db, task_type="manual-task", principle="auto extracted",
        steps=["maybe-this"], tools_used=["Bash"],
        context_tags=["manual"], speculative=1, confidence=0.0,
    )
    assert result.action == "skipped"
    assert proc_id in result.conflicting_ids
    # Original unchanged
    from genesis.db.crud.procedural import get_by_id
    row = await get_by_id(db, proc_id)
    assert row["principle"] == "human taught"


@pytest.mark.asyncio
async def test_store_checked_context_overlap_warns(db):
    """High Jaccard overlap with different task_type → warn but create."""
    await store_procedure(
        db, task_type="youtube-fetch", principle="fetch youtube",
        steps=["s1"], tools_used=["WebFetch"],
        context_tags=["youtube", "video", "transcript"],
        speculative=0, success_count=1, confidence=2 / 3,
    )
    # Jaccard: 3/4 = 0.75 >= 0.7 threshold
    result = await store_procedure_checked(
        db, task_type="youtube-download", principle="download youtube",
        steps=["s2"], tools_used=["Bash"],
        context_tags=["youtube", "video", "transcript", "download"],
    )
    assert result.action == "created"
    assert len(result.warnings) > 0
    assert "youtube-fetch" in result.warnings[0]


@pytest.mark.asyncio
async def test_store_checked_clean_insert(db):
    """No conflicts → clean create, no warnings."""
    result = await store_procedure_checked(
        db, task_type="unique-task", principle="unique",
        steps=["s1"], tools_used=["Grep"],
        context_tags=["unique", "new"],
    )
    assert result.action == "created"
    assert result.warnings == []
    assert result.conflicting_ids == []


# ─── Provenance Tracking ─────────────────────────────────────────────────────


@pytest.mark.asyncio
async def test_source_persisted(db):
    """Source provenance round-trips through store/read."""
    proc_id = await store_procedure(
        db, task_type="sourced-task", principle="test",
        steps=["s1"], tools_used=["Bash"], context_tags=["test"],
        source={"type": "explicit_teach"},
    )
    from genesis.db.crud.procedural import get_by_id
    row = await get_by_id(db, proc_id)
    source = json.loads(row["source"])
    assert source["type"] == "explicit_teach"


@pytest.mark.asyncio
async def test_auto_extracted_source_persisted(db):
    """Auto-extracted procedures record triage outcome in source."""
    proc_id = await store_procedure(
        db, task_type="extracted-task", principle="test",
        steps=["s1"], tools_used=["Bash"], context_tags=["test"],
        source={"type": "auto_extracted", "triage_outcome": "approach_failure"},
    )
    from genesis.db.crud.procedural import get_by_id
    row = await get_by_id(db, proc_id)
    source = json.loads(row["source"])
    assert source["type"] == "auto_extracted"
    assert source["triage_outcome"] == "approach_failure"


@pytest.mark.asyncio
async def test_null_provenance_backward_compat(db):
    """Old rows with NULL source/promotion_history still work."""
    # Insert row without new columns (simulating pre-migration data)
    await db.execute(
        "INSERT INTO procedural_memory "
        "(id, task_type, principle, steps, tools_used, context_tags, created_at) "
        "VALUES (?, ?, ?, ?, ?, ?, ?)",
        ("old-row", "old-task", "p", "[]", "[]", '["old"]', "2026-01-01"),
    )
    await db.commit()

    # All existing operations should work
    from genesis.db.crud.procedural import get_by_id
    row = await get_by_id(db, "old-row")
    assert row["source"] is None
    assert row["promotion_history"] is None

    # record_success should work on rows without provenance
    result = await record_success(db, "old-row")
    assert result is True


@pytest.mark.asyncio
async def test_promoter_records_promotion_history(db):
    """Promotion appends to promotion_history JSON."""
    from unittest.mock import AsyncMock, patch

    from genesis.learning.procedural.promoter import promote_and_demote

    proc_id = await store_procedure(
        db, task_type="promotable", principle="test",
        steps=["s1"], tools_used=["Bash"], context_tags=["test"],
        speculative=0, success_count=5, confidence=0.78,
        activation_tier="L4",
    )
    # Mock regenerate to avoid clobbering the filesystem trigger cache
    with patch("genesis.learning.procedural.promoter.regenerate", new_callable=AsyncMock):
        result = await promote_and_demote(db)
    assert result["promotions"] >= 1

    from genesis.db.crud.procedural import get_by_id
    row = await get_by_id(db, proc_id)
    history = json.loads(row["promotion_history"])
    assert len(history) >= 1
    assert history[0]["from_tier"] == "L4"
    assert history[0]["reason"] == "metrics_promotion"
