"""TDD tests for the cross-project issue board feature."""

from __future__ import annotations

import pytest

from loom.exceptions import ProjectNotFoundError, TaskStateError
from loom.graph import store
from loom.graph.cache import add_to_ready_queue, get_ready_tasks, sync_task
from loom.graph.project import (
    create_project,
    find_project_by_name,
    get_project_status,
    update_project,
)
from loom.graph.task import Task, TaskStatus
from loom.bus.events import EventType
from loom.ids import task_id as gen_task_id


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


async def _make_issue(pool, project_id, title="test issue", context=None):
    return await store.create_issue(pool, project_id, title, context)


async def _make_pending(pool, redis_conn, project_id, title="pending task"):
    task = Task(
        id=gen_task_id(), project_id=project_id, title=title,
        status=TaskStatus.PENDING, priority="p1",
    )
    task = await store.create_task(pool, task)
    await sync_task(redis_conn, task)
    await add_to_ready_queue(redis_conn, task)
    return task


# ===========================================================================
# Cycle 1: Domain model (unit tests, no DB)
# ===========================================================================


class TestDomainModel:
    """Pure unit tests — no database needed."""

    def test_issue_status_exists(self):
        assert TaskStatus.ISSUE == "issue"

    def test_resolved_status_exists(self):
        assert TaskStatus.RESOLVED == "resolved"

    def test_project_status_has_issue_field(self):
        from loom.graph.task import ProjectStatus
        ps = ProjectStatus(project_id="x", project_name="x")
        assert ps.issue == 0

    def test_issue_event_types(self):
        assert EventType.ISSUE_CREATED == "issue.created"
        assert EventType.ISSUE_RESOLVED == "issue.resolved"


# ===========================================================================
# Cycle 2: Store CRUD (integration tests, needs DB fixtures)
# ===========================================================================


@pytest.mark.asyncio
async def test_create_issue(pool, project):
    """Create an issue and verify status=issue."""
    issue = await _make_issue(pool, project)
    assert issue.status == TaskStatus.ISSUE
    assert issue.title == "test issue"


@pytest.mark.asyncio
async def test_create_issue_with_context(pool, project):
    """Context dict preserved on issue creation."""
    ctx = {"description": "Something is broken", "reporter": "warp"}
    issue = await _make_issue(pool, project, context=ctx)
    assert issue.context["description"] == "Something is broken"
    assert issue.context["reporter"] == "warp"


@pytest.mark.asyncio
async def test_list_issues(pool, project):
    """Create 3 issues, verify all returned."""
    for i in range(3):
        await _make_issue(pool, project, title=f"issue {i}")
    issues = await store.list_issues(pool, project)
    assert len(issues) == 3
    assert all(i.status == TaskStatus.ISSUE for i in issues)


@pytest.mark.asyncio
async def test_resolve_issue(pool, project):
    """Resolve an issue — transitions to resolved with resolution in context."""
    issue = await _make_issue(pool, project)
    resolved = await store.resolve_issue(pool, issue.id, resolution="Fixed in v2")
    assert resolved.status == TaskStatus.RESOLVED
    assert resolved.context.get("resolution") == "Fixed in v2"


@pytest.mark.asyncio
async def test_resolve_non_issue_fails(pool, project, redis_conn):
    """Resolving a pending task raises TaskStateError."""
    task = await _make_pending(pool, redis_conn, project)
    with pytest.raises(TaskStateError, match="resolved"):
        await store.resolve_issue(pool, task.id)


@pytest.mark.asyncio
async def test_convert_issue_to_task(pool, project):
    """Convert an issue to a pending task."""
    issue = await _make_issue(pool, project)
    task = await store.convert_issue_to_task(pool, issue.id)
    assert task.status == TaskStatus.PENDING
    assert task.id == issue.id


@pytest.mark.asyncio
async def test_convert_non_issue_fails(pool, project):
    """Converting a resolved issue raises TaskStateError."""
    issue = await _make_issue(pool, project)
    await store.resolve_issue(pool, issue.id)
    with pytest.raises(TaskStateError, match="pending"):
        await store.convert_issue_to_task(pool, issue.id)


@pytest.mark.asyncio
async def test_issue_excluded_from_actionable(pool, project):
    """Issue is not returned by get_actionable_tasks."""
    issue = await _make_issue(pool, project)
    actionable = await store.get_actionable_tasks(pool, project)
    assert issue.id not in [t.id for t in actionable]


@pytest.mark.asyncio
async def test_issue_excluded_from_active(pool, project):
    """Issue is not in list_active_tasks."""
    issue = await _make_issue(pool, project)
    active = await store.list_active_tasks(pool, project)
    assert issue.id not in [t.id for t in active]


@pytest.mark.asyncio
async def test_resolved_excluded_from_actionable(pool, project):
    """Resolved issue is not returned by get_actionable_tasks."""
    issue = await _make_issue(pool, project)
    await store.resolve_issue(pool, issue.id)
    actionable = await store.get_actionable_tasks(pool, project)
    assert issue.id not in [t.id for t in actionable]


@pytest.mark.asyncio
async def test_claim_issue_fails(pool, project):
    """Claiming an issue gives a helpful error about converting first."""
    issue = await _make_issue(pool, project)
    with pytest.raises(TaskStateError, match="convert it first"):
        await store.claim_task(pool, issue.id, "agent-1")


@pytest.mark.asyncio
async def test_issue_not_in_ready_queue(pool, project, redis_conn):
    """Issue should not appear in ready tasks."""
    issue = await _make_issue(pool, project)
    await sync_task(redis_conn, issue)
    pending = await _make_pending(pool, redis_conn, project)

    ready = await get_ready_tasks(redis_conn, pool, project)
    ready_ids = [t.id for t in ready]
    assert pending.id in ready_ids
    assert issue.id not in ready_ids


@pytest.mark.asyncio
async def test_issue_count_in_project_status(pool, project):
    """Issue count shows in get_project_status."""
    await _make_issue(pool, project, title="issue 1")
    await _make_issue(pool, project, title="issue 2")
    ps = await get_project_status(pool, project)
    assert ps.issue == 2


# ===========================================================================
# Cycle 3: Project registry (integration tests)
# ===========================================================================


@pytest.mark.asyncio
async def test_create_project_with_tags(pool):
    """Tags stored and returned on project creation."""
    p = await create_project(pool, "tagged-proj", tags=["python", "backend"])
    assert p["tags"] == ["python", "backend"]


@pytest.mark.asyncio
async def test_create_project_default_tags_empty(pool):
    """Default tags is empty list."""
    p = await create_project(pool, "no-tags-proj")
    assert p["tags"] == []


@pytest.mark.asyncio
async def test_update_project_tags(pool):
    """Update project tags."""
    p = await create_project(pool, "update-tags-proj")
    pid = str(p["id"])
    updated = await update_project(pool, pid, tags=["rust", "cli"])
    assert updated["tags"] == ["rust", "cli"]


@pytest.mark.asyncio
async def test_find_project_by_name_exact(pool):
    """Lookup project by exact name."""
    p = await create_project(pool, "find-me-proj")
    found = await find_project_by_name(pool, "find-me-proj")
    assert str(found["id"]) == str(p["id"])


@pytest.mark.asyncio
async def test_find_project_by_name_not_found(pool):
    """Missing name raises ProjectNotFoundError."""
    with pytest.raises(ProjectNotFoundError):
        await find_project_by_name(pool, "does-not-exist")


@pytest.mark.asyncio
async def test_find_project_by_id_fallback(pool):
    """UUID string accepted as fallback."""
    p = await create_project(pool, "fallback-proj")
    pid = str(p["id"])
    found = await find_project_by_name(pool, pid)
    assert str(found["id"]) == pid


@pytest.mark.asyncio
async def test_project_status_includes_issue_count(pool, project):
    """get_project_status includes issue count."""
    await _make_issue(pool, project, title="issue A")
    await _make_issue(pool, project, title="issue B")
    await _make_issue(pool, project, title="issue C")
    ps = await get_project_status(pool, project)
    assert ps.issue == 3


# ===========================================================================
# Cycle 4: Cross-project issue filing (integration tests)
# ===========================================================================


@pytest.mark.asyncio
async def test_cross_project_issue_by_name(pool):
    """File an issue against another project by name."""
    proj_a = await create_project(pool, "project-alpha")
    proj_b = await create_project(pool, "project-beta")
    target = await find_project_by_name(pool, "project-beta")
    target_id = str(target["id"])

    issue = await store.create_issue(pool, target_id, "Bug in beta")
    assert issue.project_id == target_id

    # Issue shows up in beta's list, not alpha's
    beta_issues = await store.list_issues(pool, target_id)
    alpha_issues = await store.list_issues(pool, str(proj_a["id"]))
    assert len(beta_issues) == 1
    assert len(alpha_issues) == 0


@pytest.mark.asyncio
async def test_cross_project_issue_by_id(pool):
    """File an issue against another project by UUID string."""
    proj = await create_project(pool, "uuid-target-proj")
    pid = str(proj["id"])
    issue = await store.create_issue(pool, pid, "Bug via UUID")
    assert issue.project_id == pid


@pytest.mark.asyncio
async def test_cross_project_issue_invalid_target(pool):
    """Invalid project name raises ProjectNotFoundError."""
    with pytest.raises(ProjectNotFoundError):
        await find_project_by_name(pool, "no-such-project")


@pytest.mark.asyncio
async def test_issues_scoped_to_project(pool):
    """list_issues only returns issues for that project."""
    p1 = await create_project(pool, "scope-proj-1")
    p2 = await create_project(pool, "scope-proj-2")
    pid1 = str(p1["id"])
    pid2 = str(p2["id"])

    await store.create_issue(pool, pid1, "Issue in P1")
    await store.create_issue(pool, pid2, "Issue in P2")
    await store.create_issue(pool, pid2, "Another in P2")

    assert len(await store.list_issues(pool, pid1)) == 1
    assert len(await store.list_issues(pool, pid2)) == 2


# ===========================================================================
# Cycle 5: MCP tool-level tests (filter validation)
# ===========================================================================


class TestMCPFilters:
    """Tests for MCP tool filter/display changes (no server needed)."""

    def test_loom_status_filter_issue(self):
        """'issue' and 'resolved' should be valid filter values."""
        # This tests the valid set in loom_status — we just verify the
        # values exist in the set we'll update
        valid = {"pending", "claimed", "done", "failed", "blocked", "epic", "idea", "issue", "resolved"}
        assert "issue" in valid
        assert "resolved" in valid
