"""
tests/test_commitments.py — Full test coverage for commitments & open loops (Issue #364).

Tests:
    Storage layer (via real SQLiteAdapter in-memory):
        - create_commitment
        - list_commitments (with status/due_before filters)
        - close_commitment (completed / cancelled)
        - get_due_commitments
        - create_open_loop
        - list_open_loops (with status filter)
        - resolve_open_loop

    Carry-forward logic:
        - carry_forward_loops increments carry_count
        - carry_forward_loops auto-resolves stale loops (carry_count > 5)
        - get_boot_items returns due commitments + open loops

    HTTP API (via FastAPI TestClient with mocked plugin):
        - POST   /api/v1/commitments
        - GET    /api/v1/commitments
        - PATCH  /api/v1/commitments/{id}
        - POST   /api/v1/open-loops
        - GET    /api/v1/open-loops
        - PATCH  /api/v1/open-loops/{id}
        - 404 on unknown IDs
        - 422 on invalid status values
"""
from __future__ import annotations

import pytest
import pytest_asyncio
from typing import Any, Optional
from unittest.mock import AsyncMock, MagicMock, patch

from cortex.config import CortexConfig
from cortex.storage import SQLiteAdapter
from cortex.commitments.models import Commitment, OpenLoop
from cortex.commitments.store import (
    create_commitment,
    list_commitments,
    close_commitment,
    get_due_commitments,
    create_open_loop,
    list_open_loops,
    resolve_open_loop,
)
from cortex.commitments.carry_forward import carry_forward_loops, get_boot_items


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------

@pytest_asyncio.fixture
async def adapter():
    """Fresh in-memory SQLiteAdapter (runs all migrations, including v9)."""
    a = SQLiteAdapter(db_path=":memory:", busy_timeout_ms=1000, enable_wal=False)
    await a.initialize()
    yield a
    await a.close()


# ---------------------------------------------------------------------------
# Model field validation
# ---------------------------------------------------------------------------

class TestModels:
    def test_commitment_dataclass_fields(self):
        c = Commitment(
            id="abc",
            owner_id="user1",
            content="Ship the feature",
            due_at="2099-01-01T00:00:00+00:00",
            status=Commitment.OPEN,
            source_turn_id="turn-1",
            created_at="2024-01-01T00:00:00+00:00",
            closed_at=None,
        )
        assert c.id == "abc"
        assert c.content == "Ship the feature"
        assert c.status == "open"
        assert c.closed_at is None

    def test_open_loop_dataclass_fields(self):
        lp = OpenLoop(
            id="xyz",
            owner_id="user1",
            content="Follow up on PR",
            context="Andrew mentioned it during standup",
            status=OpenLoop.OPEN,
            carry_count=2,
            created_at="2024-01-01T00:00:00+00:00",
            resolved_at=None,
        )
        assert lp.carry_count == 2
        assert lp.STALE_THRESHOLD == 5
        assert lp.status == "open"

    def test_commitment_valid_statuses(self):
        assert "open" in Commitment.VALID_STATUSES
        assert "completed" in Commitment.VALID_STATUSES
        assert "cancelled" in Commitment.VALID_STATUSES

    def test_open_loop_valid_statuses(self):
        assert "open" in OpenLoop.VALID_STATUSES
        assert "resolved" in OpenLoop.VALID_STATUSES


# ---------------------------------------------------------------------------
# Storage: Commitments
# ---------------------------------------------------------------------------

class TestCommitmentStore:
    async def test_create_commitment_basic(self, adapter):
        c = await create_commitment(adapter, owner_id="u1", content="Do the thing")
        assert c.id
        assert c.owner_id == "u1"
        assert c.content == "Do the thing"
        assert c.status == Commitment.OPEN
        assert c.due_at is None
        assert c.source_turn_id is None
        assert c.closed_at is None
        assert c.created_at

    async def test_create_commitment_with_all_fields(self, adapter):
        c = await create_commitment(
            adapter,
            owner_id="u1",
            content="Deploy to prod",
            due_at="2099-12-31T23:59:59+00:00",
            status=Commitment.OPEN,
            source_turn_id="turn-xyz",
        )
        assert c.due_at == "2099-12-31T23:59:59+00:00"
        assert c.source_turn_id == "turn-xyz"

    async def test_create_commitment_invalid_status(self, adapter):
        with pytest.raises(ValueError, match="Invalid status"):
            await create_commitment(adapter, owner_id="u1", content="X", status="bogus")

    async def test_list_commitments_empty(self, adapter):
        items = await list_commitments(adapter, owner_id="nobody")
        assert items == []

    async def test_list_commitments_owner_scoped(self, adapter):
        await create_commitment(adapter, owner_id="u1", content="Task A")
        await create_commitment(adapter, owner_id="u2", content="Task B")
        u1_items = await list_commitments(adapter, owner_id="u1")
        assert len(u1_items) == 1
        assert u1_items[0].content == "Task A"

    async def test_list_commitments_status_filter(self, adapter):
        c1 = await create_commitment(adapter, owner_id="u1", content="Open task")
        c2 = await create_commitment(adapter, owner_id="u1", content="Done task")
        await close_commitment(adapter, c2.id, status=Commitment.COMPLETED)

        open_items = await list_commitments(adapter, owner_id="u1", status=Commitment.OPEN)
        assert len(open_items) == 1
        assert open_items[0].id == c1.id

        done_items = await list_commitments(adapter, owner_id="u1", status=Commitment.COMPLETED)
        assert len(done_items) == 1
        assert done_items[0].id == c2.id

    async def test_list_commitments_due_before_filter(self, adapter):
        await create_commitment(
            adapter, owner_id="u1", content="Past due",
            due_at="2000-01-01T00:00:00+00:00"
        )
        await create_commitment(
            adapter, owner_id="u1", content="Future",
            due_at="2099-01-01T00:00:00+00:00"
        )
        await create_commitment(adapter, owner_id="u1", content="No deadline")

        overdue = await list_commitments(
            adapter, owner_id="u1", due_before="2024-01-01T00:00:00+00:00"
        )
        assert len(overdue) == 1
        assert overdue[0].content == "Past due"

    async def test_close_commitment_completed(self, adapter):
        c = await create_commitment(adapter, owner_id="u1", content="Finish report")
        updated = await close_commitment(adapter, c.id, status=Commitment.COMPLETED)
        assert updated is not None
        assert updated.status == Commitment.COMPLETED
        assert updated.closed_at is not None

    async def test_close_commitment_cancelled(self, adapter):
        c = await create_commitment(adapter, owner_id="u1", content="Optional task")
        updated = await close_commitment(adapter, c.id, status=Commitment.CANCELLED)
        assert updated.status == Commitment.CANCELLED
        assert updated.closed_at is not None

    async def test_close_commitment_invalid_status(self, adapter):
        c = await create_commitment(adapter, owner_id="u1", content="X")
        with pytest.raises(ValueError, match="completed.*cancelled"):
            await close_commitment(adapter, c.id, status="open")

    async def test_close_commitment_not_found(self, adapter):
        result = await close_commitment(adapter, "nonexistent-id", status=Commitment.COMPLETED)
        assert result is None

    async def test_close_commitment_owner_scoped(self, adapter):
        c = await create_commitment(adapter, owner_id="u1", content="Task")
        # Wrong owner — should not update
        result = await close_commitment(adapter, c.id, status=Commitment.COMPLETED, owner_id="u2")
        # Row won't match owner, so update is no-op and fetch returns None
        assert result is None

        # Correct owner — should update
        updated = await close_commitment(adapter, c.id, status=Commitment.COMPLETED, owner_id="u1")
        assert updated is not None
        assert updated.status == Commitment.COMPLETED

    async def test_get_due_commitments(self, adapter):
        await create_commitment(
            adapter, owner_id="u1", content="Overdue",
            due_at="2000-01-01T00:00:00+00:00"
        )
        await create_commitment(
            adapter, owner_id="u1", content="Future",
            due_at="2099-01-01T00:00:00+00:00"
        )
        await create_commitment(adapter, owner_id="u1", content="No deadline")

        due = await get_due_commitments(
            adapter, owner_id="u1", as_of="2024-01-01T00:00:00+00:00"
        )
        assert len(due) == 1
        assert due[0].content == "Overdue"

    async def test_get_due_commitments_excludes_closed(self, adapter):
        c = await create_commitment(
            adapter, owner_id="u1", content="Completed overdue",
            due_at="2000-01-01T00:00:00+00:00"
        )
        await close_commitment(adapter, c.id, status=Commitment.COMPLETED)
        due = await get_due_commitments(
            adapter, owner_id="u1", as_of="2024-01-01T00:00:00+00:00"
        )
        assert len(due) == 0

    async def test_list_commitments_pagination(self, adapter):
        for i in range(5):
            await create_commitment(adapter, owner_id="u1", content=f"Task {i}")
        page1 = await list_commitments(adapter, owner_id="u1", limit=3, offset=0)
        page2 = await list_commitments(adapter, owner_id="u1", limit=3, offset=3)
        assert len(page1) == 3
        assert len(page2) == 2
        ids1 = {c.id for c in page1}
        ids2 = {c.id for c in page2}
        assert ids1.isdisjoint(ids2)


# ---------------------------------------------------------------------------
# Storage: Open Loops
# ---------------------------------------------------------------------------

class TestOpenLoopStore:
    async def test_create_open_loop_basic(self, adapter):
        lp = await create_open_loop(adapter, owner_id="u1", content="Follow up on X")
        assert lp.id
        assert lp.owner_id == "u1"
        assert lp.content == "Follow up on X"
        assert lp.status == OpenLoop.OPEN
        assert lp.carry_count == 0
        assert lp.context is None
        assert lp.resolved_at is None

    async def test_create_open_loop_with_context(self, adapter):
        lp = await create_open_loop(
            adapter, owner_id="u1",
            content="Check deployment",
            context="Andrew mentioned during standup",
        )
        assert lp.context == "Andrew mentioned during standup"

    async def test_create_open_loop_invalid_status(self, adapter):
        with pytest.raises(ValueError, match="Invalid status"):
            await create_open_loop(adapter, owner_id="u1", content="X", status="invalid")

    async def test_list_open_loops_empty(self, adapter):
        items = await list_open_loops(adapter, owner_id="nobody")
        assert items == []

    async def test_list_open_loops_owner_scoped(self, adapter):
        await create_open_loop(adapter, owner_id="u1", content="Loop A")
        await create_open_loop(adapter, owner_id="u2", content="Loop B")
        u1_items = await list_open_loops(adapter, owner_id="u1")
        assert len(u1_items) == 1
        assert u1_items[0].content == "Loop A"

    async def test_list_open_loops_status_filter(self, adapter):
        lp1 = await create_open_loop(adapter, owner_id="u1", content="Open loop")
        lp2 = await create_open_loop(adapter, owner_id="u1", content="Resolved loop")
        await resolve_open_loop(adapter, lp2.id)

        open_items = await list_open_loops(adapter, owner_id="u1", status=OpenLoop.OPEN)
        assert len(open_items) == 1
        assert open_items[0].id == lp1.id

        resolved_items = await list_open_loops(adapter, owner_id="u1", status=OpenLoop.RESOLVED)
        assert len(resolved_items) == 1
        assert resolved_items[0].id == lp2.id

    async def test_resolve_open_loop(self, adapter):
        lp = await create_open_loop(adapter, owner_id="u1", content="Follow up")
        updated = await resolve_open_loop(adapter, lp.id)
        assert updated is not None
        assert updated.status == OpenLoop.RESOLVED
        assert updated.resolved_at is not None

    async def test_resolve_open_loop_not_found(self, adapter):
        result = await resolve_open_loop(adapter, "nonexistent-id")
        assert result is None

    async def test_resolve_open_loop_owner_scoped(self, adapter):
        lp = await create_open_loop(adapter, owner_id="u1", content="Loop")
        # Wrong owner
        result = await resolve_open_loop(adapter, lp.id, owner_id="u2")
        assert result is None
        # Correct owner
        updated = await resolve_open_loop(adapter, lp.id, owner_id="u1")
        assert updated is not None
        assert updated.status == OpenLoop.RESOLVED

    async def test_list_open_loops_pagination(self, adapter):
        for i in range(5):
            await create_open_loop(adapter, owner_id="u1", content=f"Loop {i}")
        page1 = await list_open_loops(adapter, owner_id="u1", limit=3, offset=0)
        page2 = await list_open_loops(adapter, owner_id="u1", limit=3, offset=3)
        assert len(page1) == 3
        assert len(page2) == 2


# ---------------------------------------------------------------------------
# Carry-forward logic
# ---------------------------------------------------------------------------

class TestCarryForward:
    async def test_carry_forward_increments_count(self, adapter):
        lp = await create_open_loop(adapter, owner_id="u1", content="Pending thing")
        assert lp.carry_count == 0

        result = await carry_forward_loops(adapter, session_id="sess-1", owner_id="u1")
        assert result["incremented"] >= 1

        # Verify carry_count actually increased in DB
        loops = await list_open_loops(adapter, owner_id="u1", status=OpenLoop.OPEN)
        assert loops[0].carry_count == 1

    async def test_carry_forward_does_not_affect_resolved(self, adapter):
        lp1 = await create_open_loop(adapter, owner_id="u1", content="Open")
        lp2 = await create_open_loop(adapter, owner_id="u1", content="Already resolved")
        await resolve_open_loop(adapter, lp2.id)

        await carry_forward_loops(adapter, session_id="sess-1", owner_id="u1")

        open_loops = await list_open_loops(adapter, owner_id="u1", status=OpenLoop.OPEN)
        assert len(open_loops) == 1
        assert open_loops[0].carry_count == 1

        resolved = await list_open_loops(adapter, owner_id="u1", status=OpenLoop.RESOLVED)
        # lp2 was resolved before carry; its carry_count should still be 0
        resolved_lp2 = next(r for r in resolved if r.id == lp2.id)
        assert resolved_lp2.carry_count == 0

    async def test_carry_forward_auto_resolves_stale(self, adapter):
        """Loops at carry_count > 5 should be auto-resolved as stale."""
        lp = await create_open_loop(adapter, owner_id="u1", content="Stale loop")

        # Simulate 6 sleep/wake cycles (STALE_THRESHOLD is 5, resolve when > 5)
        for i in range(6):
            await carry_forward_loops(adapter, session_id=f"sess-{i}", owner_id="u1")

        # After 6 cycles the loop should be auto-resolved
        open_items = await list_open_loops(adapter, owner_id="u1", status=OpenLoop.OPEN)
        resolved_items = await list_open_loops(adapter, owner_id="u1", status=OpenLoop.RESOLVED)

        assert not any(item.id == lp.id for item in open_items), (
            "Stale loop should have been auto-resolved"
        )
        assert any(item.id == lp.id for item in resolved_items), (
            "Stale loop should appear in resolved list"
        )

    async def test_carry_forward_custom_stale_threshold(self, adapter):
        """Custom stale_threshold overrides the default (5)."""
        lp = await create_open_loop(adapter, owner_id="u1", content="Custom threshold loop")

        # With threshold=2, loop should auto-resolve after 3 cycles
        for i in range(3):
            await carry_forward_loops(
                adapter, session_id=f"sess-{i}", owner_id="u1",
                stale_threshold=2,
            )

        open_items = await list_open_loops(adapter, owner_id="u1", status=OpenLoop.OPEN)
        assert not any(item.id == lp.id for item in open_items), (
            "Loop should be auto-resolved with custom threshold=2 after 3 cycles"
        )

    async def test_carry_forward_returns_counts(self, adapter):
        await create_open_loop(adapter, owner_id="u1", content="A")
        await create_open_loop(adapter, owner_id="u1", content="B")
        result = await carry_forward_loops(adapter, session_id="sess-1", owner_id="u1")
        assert "incremented" in result
        assert "auto_resolved" in result
        assert result["incremented"] == 2
        assert result["auto_resolved"] == 0

    async def test_get_boot_items_empty(self, adapter):
        items = await get_boot_items(adapter, owner_id="nobody")
        assert items["due_commitments"] == []
        assert items["open_loops"] == []

    async def test_get_boot_items_returns_due_and_open(self, adapter):
        # Due commitment
        await create_commitment(
            adapter, owner_id="u1", content="Past due task",
            due_at="2000-01-01T00:00:00+00:00"
        )
        # Future commitment — should NOT appear in boot items (as_of = now)
        await create_commitment(
            adapter, owner_id="u1", content="Future task",
            due_at="2099-01-01T00:00:00+00:00"
        )
        # Open loop
        await create_open_loop(adapter, owner_id="u1", content="Pending follow-up")
        # Resolved loop — should NOT appear
        lp = await create_open_loop(adapter, owner_id="u1", content="Already done")
        await resolve_open_loop(adapter, lp.id)

        items = await get_boot_items(adapter, owner_id="u1")
        assert len(items["due_commitments"]) == 1
        assert items["due_commitments"][0].content == "Past due task"
        assert len(items["open_loops"]) == 1
        assert items["open_loops"][0].content == "Pending follow-up"


# ---------------------------------------------------------------------------
# HTTP API tests
# ---------------------------------------------------------------------------

# Only run API tests if FastAPI + httpx are installed
pytest.importorskip("fastapi", reason="fastapi not installed")
pytest.importorskip("httpx", reason="httpx not installed")

from fastapi.testclient import TestClient  # noqa: E402


class _MockStorage:
    """Minimal in-memory storage stub for HTTP API tests."""

    def __init__(self, real_adapter):
        self._adapter = real_adapter

    def __getattr__(self, name):
        return getattr(self._adapter, name)


def _make_test_client(adapter):
    """Create a TestClient with the given adapter injected as plugin.storage."""
    from cortex.api.http_server import create_app

    mock_plugin = MagicMock()
    mock_plugin.storage = adapter

    def _override_get_plugin():
        return mock_plugin

    app = create_app(api_key=None)
    from cortex.api.deps import get_plugin
    app.dependency_overrides[get_plugin] = _override_get_plugin
    return TestClient(app)


class TestCommitmentsAPI:
    """HTTP API tests for /api/v1/commitments."""

    @pytest_asyncio.fixture(autouse=True)
    async def setup(self, adapter):
        self.adapter = adapter
        self.client = _make_test_client(adapter)

    def test_create_commitment(self):
        resp = self.client.post("/api/v1/commitments", json={
            "owner_id": "u1",
            "content": "Ship v1.0",
        })
        assert resp.status_code == 201
        data = resp.json()
        assert data["content"] == "Ship v1.0"
        assert data["status"] == "open"
        assert data["id"]

    def test_create_commitment_with_all_fields(self):
        resp = self.client.post("/api/v1/commitments", json={
            "owner_id": "u1",
            "content": "Write tests",
            "due_at": "2099-01-01T00:00:00+00:00",
            "source_turn_id": "turn-001",
        })
        assert resp.status_code == 201
        data = resp.json()
        assert data["due_at"] == "2099-01-01T00:00:00+00:00"
        assert data["source_turn_id"] == "turn-001"

    def test_create_commitment_invalid_status(self):
        resp = self.client.post("/api/v1/commitments", json={
            "owner_id": "u1",
            "content": "Bad",
            "status": "invalid",
        })
        assert resp.status_code == 422

    def test_list_commitments_empty(self):
        resp = self.client.get("/api/v1/commitments?owner_id=nobody")
        assert resp.status_code == 200
        data = resp.json()
        assert data["commitments"] == []
        assert data["total"] == 0

    def test_list_commitments(self):
        self.client.post("/api/v1/commitments", json={"owner_id": "u1", "content": "A"})
        self.client.post("/api/v1/commitments", json={"owner_id": "u1", "content": "B"})
        resp = self.client.get("/api/v1/commitments?owner_id=u1")
        assert resp.status_code == 200
        data = resp.json()
        assert data["total"] == 2

    def test_list_commitments_status_filter(self):
        r1 = self.client.post("/api/v1/commitments", json={"owner_id": "u1", "content": "Open"})
        r2 = self.client.post("/api/v1/commitments", json={"owner_id": "u1", "content": "Done"})
        c2_id = r2.json()["id"]
        self.client.patch(f"/api/v1/commitments/{c2_id}", json={"status": "completed"})

        resp = self.client.get("/api/v1/commitments?owner_id=u1&status=open")
        assert resp.status_code == 200
        data = resp.json()
        assert data["total"] == 1
        assert data["commitments"][0]["content"] == "Open"

    def test_patch_commitment_complete(self):
        r = self.client.post("/api/v1/commitments", json={"owner_id": "u1", "content": "Done"})
        cid = r.json()["id"]
        resp = self.client.patch(f"/api/v1/commitments/{cid}", json={"status": "completed"})
        assert resp.status_code == 200
        data = resp.json()
        assert data["status"] == "completed"
        assert data["closed_at"] is not None

    def test_patch_commitment_cancel(self):
        r = self.client.post("/api/v1/commitments", json={"owner_id": "u1", "content": "Cancel"})
        cid = r.json()["id"]
        resp = self.client.patch(f"/api/v1/commitments/{cid}", json={"status": "cancelled"})
        assert resp.status_code == 200
        assert resp.json()["status"] == "cancelled"

    def test_patch_commitment_invalid_status(self):
        r = self.client.post("/api/v1/commitments", json={"owner_id": "u1", "content": "X"})
        cid = r.json()["id"]
        resp = self.client.patch(f"/api/v1/commitments/{cid}", json={"status": "open"})
        assert resp.status_code == 422

    def test_patch_commitment_not_found(self):
        resp = self.client.patch(
            "/api/v1/commitments/nonexistent-id",
            json={"status": "completed"},
        )
        assert resp.status_code == 404


class TestOpenLoopsAPI:
    """HTTP API tests for /api/v1/open-loops."""

    @pytest_asyncio.fixture(autouse=True)
    async def setup(self, adapter):
        self.adapter = adapter
        self.client = _make_test_client(adapter)

    def test_create_open_loop(self):
        resp = self.client.post("/api/v1/open-loops", json={
            "owner_id": "u1",
            "content": "Follow up on issue",
        })
        assert resp.status_code == 201
        data = resp.json()
        assert data["content"] == "Follow up on issue"
        assert data["status"] == "open"
        assert data["carry_count"] == 0
        assert data["id"]

    def test_create_open_loop_with_context(self):
        resp = self.client.post("/api/v1/open-loops", json={
            "owner_id": "u1",
            "content": "Check PR",
            "context": "Andrew mentioned in standup",
        })
        assert resp.status_code == 201
        assert resp.json()["context"] == "Andrew mentioned in standup"

    def test_create_open_loop_invalid_status(self):
        resp = self.client.post("/api/v1/open-loops", json={
            "owner_id": "u1",
            "content": "X",
            "status": "bad",
        })
        assert resp.status_code == 422

    def test_list_open_loops_empty(self):
        resp = self.client.get("/api/v1/open-loops?owner_id=nobody")
        assert resp.status_code == 200
        data = resp.json()
        assert data["open_loops"] == []
        assert data["total"] == 0

    def test_list_open_loops(self):
        self.client.post("/api/v1/open-loops", json={"owner_id": "u1", "content": "A"})
        self.client.post("/api/v1/open-loops", json={"owner_id": "u1", "content": "B"})
        resp = self.client.get("/api/v1/open-loops?owner_id=u1")
        assert resp.status_code == 200
        assert resp.json()["total"] == 2

    def test_list_open_loops_status_filter(self):
        r1 = self.client.post("/api/v1/open-loops", json={"owner_id": "u1", "content": "Open"})
        r2 = self.client.post("/api/v1/open-loops", json={"owner_id": "u1", "content": "Done"})
        lp2_id = r2.json()["id"]
        self.client.patch(f"/api/v1/open-loops/{lp2_id}", json={})

        resp = self.client.get("/api/v1/open-loops?owner_id=u1&status=open")
        assert resp.status_code == 200
        data = resp.json()
        assert data["total"] == 1
        assert data["open_loops"][0]["content"] == "Open"

    def test_patch_open_loop_resolve(self):
        r = self.client.post("/api/v1/open-loops", json={"owner_id": "u1", "content": "Resolve me"})
        lid = r.json()["id"]
        resp = self.client.patch(f"/api/v1/open-loops/{lid}", json={})
        assert resp.status_code == 200
        data = resp.json()
        assert data["status"] == "resolved"
        assert data["resolved_at"] is not None

    def test_patch_open_loop_not_found(self):
        resp = self.client.patch("/api/v1/open-loops/nonexistent-id", json={})
        assert resp.status_code == 404
