"""Tests for app_lifespan branches, _strip_tools/_strip_resources, and cache edge cases."""

from __future__ import annotations

import json
from datetime import datetime
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from project_hub.core.cache import Cache, CacheMigrationRequired
from project_hub.core.models import (
    MR, Event, EventType, Issue, Pipeline, Vulnerability,
)
from project_hub.core.workspace import Workspace, WorkspaceState
from project_hub.mcp.server import (
    AppContext,
    _strip_resources,
    _strip_tools,
    acknowledge_alerts,
    app_lifespan,
)


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


def _make_ctx(app: AppContext) -> MagicMock:
    ctx = MagicMock()
    ctx.request_context.lifespan_context = app
    ctx.session = AsyncMock()
    return ctx


def _make_mr(
    iid: int,
    repo_name: str = "repo-alpha",
    assignees: list[str] | None = None,
    reviewers: list[str] | None = None,
) -> MR:
    return MR(
        id=iid * 100,
        iid=iid,
        title=f"MR {iid}",
        author="alice",
        state="opened",
        source_branch=f"feature/{iid}",
        target_branch="main",
        web_url=f"https://gitlab.com/mr/{iid}",
        user_notes_count=0,
        approved=False,
        created_at="2024-01-01T00:00:00Z",
        updated_at="2024-01-02T00:00:00Z",
        repo_name=repo_name,
        sha="abc123",
        assignees=assignees,
        reviewers=reviewers,
    )


def _make_pipeline(
    pid: int, repo_name: str = "repo-alpha", status: str = "success", ref: str = "main",
) -> Pipeline:
    return Pipeline(
        id=pid,
        status=status,
        ref=ref,
        web_url=f"https://gitlab.com/pipelines/{pid}",
        created_at="2024-01-01T00:00:00Z",
        repo_name=repo_name,
    )


# ---------------------------------------------------------------------------
# Part 1 — app_lifespan branches
# ---------------------------------------------------------------------------


@pytest.mark.anyio
async def test_lifespan_disabled_opt_out():
    """DISABLED_OPT_OUT strips all tools and resources, yields no workspace."""
    server = MagicMock()
    # Make list_tools / list_resources return some items so _strip can iterate
    tool = MagicMock()
    tool.name = "list_repos"
    resource = MagicMock()
    resource.uri = "workspace://topology"
    server.local_provider.list_tools = AsyncMock(return_value=[tool])
    server.local_provider.list_resources = AsyncMock(return_value=[resource])

    with (
        patch("project_hub.mcp.server.check_git_version"),
        patch("project_hub.mcp.server.detect_state", return_value=WorkspaceState.DISABLED_OPT_OUT),
    ):
        async with app_lifespan(server) as ctx:
            assert isinstance(ctx, AppContext)
            assert ctx.workspace is None

    server.local_provider.remove_tool.assert_called_with("list_repos")
    server.local_provider.remove_resource.assert_called_with("workspace://topology")


@pytest.mark.anyio
async def test_lifespan_disabled_sub_repo():
    """DISABLED_SUB_REPO strips all tools and resources, yields no workspace."""
    server = MagicMock()
    tool = MagicMock()
    tool.name = "fetch_all"
    resource = MagicMock()
    resource.uri = "workspace://status"
    server.local_provider.list_tools = AsyncMock(return_value=[tool])
    server.local_provider.list_resources = AsyncMock(return_value=[resource])

    with (
        patch("project_hub.mcp.server.check_git_version"),
        patch("project_hub.mcp.server.detect_state", return_value=WorkspaceState.DISABLED_SUB_REPO),
    ):
        async with app_lifespan(server) as ctx:
            assert isinstance(ctx, AppContext)
            assert ctx.workspace is None

    server.local_provider.remove_tool.assert_called_with("fetch_all")
    server.local_provider.remove_resource.assert_called_with("workspace://status")


@pytest.mark.anyio
async def test_lifespan_dormant_keeps_init():
    """DORMANT strips most tools but keeps 'init', strips resources."""
    server = MagicMock()

    init_tool = MagicMock()
    init_tool.name = "init"
    repos_tool = MagicMock()
    repos_tool.name = "list_repos"
    fetch_tool = MagicMock()
    fetch_tool.name = "fetch_all"
    server.local_provider.list_tools = AsyncMock(return_value=[init_tool, repos_tool, fetch_tool])

    resource = MagicMock()
    resource.uri = "workspace://alerts"
    server.local_provider.list_resources = AsyncMock(return_value=[resource])

    with (
        patch("project_hub.mcp.server.check_git_version"),
        patch("project_hub.mcp.server.detect_state", return_value=WorkspaceState.DORMANT),
    ):
        async with app_lifespan(server) as ctx:
            assert isinstance(ctx, AppContext)
            assert ctx.workspace is None

    # 'init' should NOT have been removed
    removed_tools = [
        call.args[0] for call in server.local_provider.remove_tool.call_args_list
    ]
    assert "list_repos" in removed_tools
    assert "fetch_all" in removed_tools
    assert "init" not in removed_tools

    server.local_provider.remove_resource.assert_called_with("workspace://alerts")


@pytest.mark.anyio
async def test_lifespan_active_starts_and_stops_workspace(tmp_hub: Path):
    """ACTIVE creates workspace, starts it, yields, stops on exit."""
    server = MagicMock()
    server.local_provider.remove_tool = MagicMock()

    mock_ws = MagicMock(spec=Workspace)
    mock_ws.start = AsyncMock()
    mock_ws.stop = AsyncMock()
    mock_ws.refresh_once = AsyncMock()
    mock_ws.refresh_loop = AsyncMock(side_effect=lambda **kw: _hang())
    mock_ws.repos = []
    mock_ws.root = tmp_hub
    mock_ws.config = MagicMock()
    mock_ws.config.http_port = 0
    mock_ws.cache = MagicMock()
    mock_ws.cache.get_metadata = AsyncMock(return_value="2024-01-01")  # not first run

    with (
        patch("project_hub.mcp.server.check_git_version"),
        patch("project_hub.mcp.server.detect_state", return_value=WorkspaceState.ACTIVE),
        patch("project_hub.mcp.server.Workspace", return_value=mock_ws),
        patch("project_hub.mcp.server.Path") as mock_path_cls,
    ):
        mock_path_cls.cwd.return_value = tmp_hub
        mock_path_cls.side_effect = Path

        async with app_lifespan(server) as ctx:
            assert isinstance(ctx, AppContext)
            assert ctx.workspace is mock_ws

    mock_ws.start.assert_awaited_once()
    mock_ws.stop.assert_awaited_once()


async def _hang():
    """Coroutine that hangs forever, simulating refresh_loop."""
    import asyncio
    await asyncio.sleep(999999)


@pytest.mark.anyio
async def test_lifespan_cache_migration_required():
    """CacheMigrationRequired on start strips tools/resources, yields no workspace."""
    server = MagicMock()
    tool = MagicMock()
    tool.name = "list_repos"
    server.local_provider.list_tools = AsyncMock(return_value=[tool])
    resource = MagicMock()
    resource.uri = "workspace://topology"
    server.local_provider.list_resources = AsyncMock(return_value=[resource])

    mock_ws = MagicMock(spec=Workspace)
    mock_ws.start = AsyncMock(side_effect=CacheMigrationRequired("needs migration"))

    with (
        patch("project_hub.mcp.server.check_git_version"),
        patch("project_hub.mcp.server.detect_state", return_value=WorkspaceState.ACTIVE),
        patch("project_hub.mcp.server.Workspace", return_value=mock_ws),
        patch("project_hub.mcp.server.Path") as mock_path_cls,
    ):
        mock_path_cls.cwd.return_value = Path("/tmp/fake")
        mock_path_cls.side_effect = Path

        async with app_lifespan(server) as ctx:
            assert isinstance(ctx, AppContext)
            assert ctx.workspace is None

    server.local_provider.remove_tool.assert_called()
    server.local_provider.remove_resource.assert_called()


# ---------------------------------------------------------------------------
# Part 2 — _strip_tools / _strip_resources
# ---------------------------------------------------------------------------


@pytest.mark.anyio
async def test_strip_tools_removes_all():
    """_strip_tools with no keep set removes every tool."""
    server = MagicMock()
    t1, t2 = MagicMock(), MagicMock()
    t1.name = "list_repos"
    t2.name = "fetch_all"
    server.local_provider.list_tools = AsyncMock(return_value=[t1, t2])

    await _strip_tools(server)

    removed = {c.args[0] for c in server.local_provider.remove_tool.call_args_list}
    assert removed == {"list_repos", "fetch_all"}


@pytest.mark.anyio
async def test_strip_tools_keeps_subset():
    """_strip_tools with keep={'init'} removes everything except 'init'."""
    server = MagicMock()
    t1, t2, t3 = MagicMock(), MagicMock(), MagicMock()
    t1.name = "init"
    t2.name = "list_repos"
    t3.name = "fetch_all"
    server.local_provider.list_tools = AsyncMock(return_value=[t1, t2, t3])

    await _strip_tools(server, keep={"init"})

    removed = {c.args[0] for c in server.local_provider.remove_tool.call_args_list}
    assert removed == {"list_repos", "fetch_all"}
    assert "init" not in removed


@pytest.mark.anyio
async def test_strip_resources_removes_all():
    """_strip_resources removes every resource."""
    server = MagicMock()
    r1, r2 = MagicMock(), MagicMock()
    r1.uri = "workspace://topology"
    r2.uri = "workspace://alerts"
    server.local_provider.list_resources = AsyncMock(return_value=[r1, r2])

    await _strip_resources(server)

    removed = {c.args[0] for c in server.local_provider.remove_resource.call_args_list}
    assert removed == {"workspace://topology", "workspace://alerts"}


@pytest.mark.anyio
async def test_strip_tools_noop_when_empty():
    """_strip_tools on a server with no tools does nothing."""
    server = MagicMock()
    server.local_provider.list_tools = AsyncMock(return_value=[])

    await _strip_tools(server)

    server.local_provider.remove_tool.assert_not_called()


@pytest.mark.anyio
async def test_strip_resources_noop_when_empty():
    """_strip_resources on a server with no resources does nothing."""
    server = MagicMock()
    server.local_provider.list_resources = AsyncMock(return_value=[])

    await _strip_resources(server)

    server.local_provider.remove_resource.assert_not_called()


# ---------------------------------------------------------------------------
# Part 3 — Cache edge cases
# ---------------------------------------------------------------------------


async def test_store_pipelines_upsert_updates_status(cache: Cache):
    """Storing same pipeline ID twice updates status via ON CONFLICT."""
    p1 = _make_pipeline(1, status="running")
    await cache.store_pipelines("repo-alpha", [p1])

    result = await cache.get_pipelines("repo-alpha")
    assert len(result) == 1
    assert result[0].status == "running"

    p1_updated = _make_pipeline(1, status="success")
    await cache.store_pipelines("repo-alpha", [p1_updated])

    result = await cache.get_pipelines("repo-alpha")
    assert len(result) == 1
    assert result[0].status == "success"


async def test_store_pipelines_prunes_terminal_not_in_new_set(cache: Cache):
    """Terminal pipelines not in the new set get pruned; non-terminal are preserved."""
    # Start with 3 pipelines: one terminal success, one terminal failed, one running
    old = [
        _make_pipeline(1, status="success"),
        _make_pipeline(2, status="failed"),
        _make_pipeline(3, status="running"),
    ]
    await cache.store_pipelines("repo-alpha", old)

    # New set only has pipeline 1 (success). Pipeline 2 (failed, terminal) should be pruned.
    # Pipeline 3 (running, non-terminal) should be preserved.
    new = [_make_pipeline(1, status="success")]
    await cache.store_pipelines("repo-alpha", new)

    result = await cache.get_pipelines("repo-alpha")
    result_ids = {p.id for p in result}
    assert 1 in result_ids, "Pipeline 1 should remain (in new set)"
    assert 2 not in result_ids, "Pipeline 2 should be pruned (terminal, not in new set)"
    assert 3 in result_ids, "Pipeline 3 should be preserved (non-terminal, running)"


async def test_store_pipelines_empty_set_prunes_terminal(cache: Cache):
    """When API returns zero pipelines, terminal entries are pruned."""
    old = [
        _make_pipeline(1, status="success"),
        _make_pipeline(2, status="running"),
    ]
    await cache.store_pipelines("repo-alpha", old)

    await cache.store_pipelines("repo-alpha", [])

    result = await cache.get_pipelines("repo-alpha")
    result_ids = {p.id for p in result}
    assert 1 not in result_ids, "Terminal pipeline 1 should be pruned"
    assert 2 in result_ids, "Running pipeline 2 should be preserved"


async def test_store_mrs_with_assignees_and_reviewers(cache: Cache):
    """Assignees and reviewers survive round-trip through cache as JSON."""
    mr = _make_mr(1, assignees=["alice", "bob"], reviewers=["charlie"])
    await cache.store_mrs("repo-alpha", [mr])

    result = await cache.get_mrs("repo-alpha")
    assert len(result) == 1
    assert result[0].assignees == ["alice", "bob"]
    assert result[0].reviewers == ["charlie"]


async def test_store_mrs_with_empty_assignees(cache: Cache):
    """Empty assignees/reviewers round-trip as empty lists."""
    mr = _make_mr(2, assignees=[], reviewers=[])
    await cache.store_mrs("repo-alpha", [mr])

    result = await cache.get_mrs("repo-alpha")
    assert len(result) == 1
    assert result[0].assignees == []
    assert result[0].reviewers == []


async def test_store_mrs_with_none_assignees(cache: Cache):
    """None assignees/reviewers round-trip as empty lists (json.dumps(None or []) -> '[]')."""
    mr = _make_mr(3, assignees=None, reviewers=None)
    await cache.store_mrs("repo-alpha", [mr])

    result = await cache.get_mrs("repo-alpha")
    assert len(result) == 1
    assert result[0].assignees == []
    assert result[0].reviewers == []


async def test_sync_wrappers_match_async(tmp_path: Path):
    """Sync wrappers return the same data written through async methods."""
    cache = Cache(tmp_path / "sync_test.db")
    await cache.initialize()

    # Write data via async
    mr = _make_mr(1, assignees=["alice"])
    await cache.store_mrs("repo-alpha", [mr])

    p = _make_pipeline(10, status="success")
    await cache.store_pipelines("repo-alpha", [p])

    v = Vulnerability(
        id=42, name="CVE-42", severity="high", state="detected",
        source="sast", web_url="https://example.com/v/42",
        repo_name="repo-alpha",
    )
    await cache.store_vulns("repo-alpha", [v])

    issue = Issue(
        id=200, iid=2, title="Bug", author="bob", state="opened",
        web_url="https://example.com/i/2", user_notes_count=1,
        created_at="2024-01-01", updated_at="2024-01-02",
        repo_name="repo-alpha", labels=["urgent"],
        assignees=["bob"], participants=["bob", "alice"],
        confidential=False,
    )
    await cache.store_issues("repo-alpha", [issue])

    event = Event(
        id=None, type=EventType.PIPELINE_FAILED, repo_name="repo-alpha",
        summary="fail", detail="d", created_at=datetime(2024, 1, 1, 12, 0),
    )
    await cache.store_events([event])

    # Flush WAL so sync readers see the data
    await cache.checkpoint()

    # Read via sync wrappers
    mrs_sync = cache.get_mrs_sync("repo-alpha")
    assert len(mrs_sync) == 1
    assert mrs_sync[0].iid == 1

    pips_sync = cache.get_pipelines_sync("repo-alpha")
    assert len(pips_sync) == 1
    assert pips_sync[0].id == 10

    vulns_sync = cache.get_vulns_sync("repo-alpha")
    assert len(vulns_sync) == 1
    assert vulns_sync[0].name == "CVE-42"

    issues_sync = cache.get_issues_sync("repo-alpha")
    assert len(issues_sync) == 1
    assert issues_sync[0].title == "Bug"
    assert issues_sync[0].labels == ["urgent"]

    events_sync = cache.get_unseen_events_sync()
    assert len(events_sync) == 1
    assert events_sync[0].summary == "fail"

    # Also test get_mrs_sync(None) for all repos
    all_mrs = cache.get_mrs_sync(None)
    assert len(all_mrs) == 1

    await cache.close()


async def test_acknowledge_alerts_malformed_ids(tmp_path: Path):
    """acknowledge_alerts with non-integer IDs returns a graceful error."""
    cache = Cache(tmp_path / "ack_test.db")
    await cache.initialize()

    ws = MagicMock(spec=Workspace)
    ws.cache = cache

    app = AppContext(workspace=ws)
    ctx = _make_ctx(app)

    result = await acknowledge_alerts(ctx, event_ids="abc,123")
    assert "Invalid event_ids" in result

    await cache.close()


async def test_acknowledge_alerts_empty_string_marks_all(tmp_path: Path):
    """acknowledge_alerts with empty string marks all events as seen."""
    cache = Cache(tmp_path / "ack_all.db")
    await cache.initialize()

    events = [
        Event(id=None, type=EventType.PIPELINE_FAILED, repo_name="repo-alpha",
              summary="fail1", detail="", created_at=datetime.now()),
        Event(id=None, type=EventType.MR_APPROVED, repo_name="repo-beta",
              summary="approved", detail="", created_at=datetime.now()),
    ]
    await cache.store_events(events)
    unseen = await cache.get_unseen_events()
    assert len(unseen) == 2

    ws = MagicMock(spec=Workspace)
    ws.cache = cache

    app = AppContext(workspace=ws)
    ctx = _make_ctx(app)

    result = await acknowledge_alerts(ctx, event_ids="")
    assert "All events marked as seen" in result

    unseen_after = await cache.get_unseen_events()
    assert len(unseen_after) == 0

    await cache.close()


async def test_store_pipelines_cross_repo_isolation(cache: Cache):
    """Storing pipelines for repo-alpha does not affect repo-beta."""
    pa = _make_pipeline(1, repo_name="repo-alpha", status="success")
    pb = _make_pipeline(2, repo_name="repo-beta", status="failed")
    await cache.store_pipelines("repo-alpha", [pa])
    await cache.store_pipelines("repo-beta", [pb])

    # Overwrite repo-alpha with empty -> prunes terminal
    await cache.store_pipelines("repo-alpha", [])

    alpha = await cache.get_pipelines("repo-alpha")
    beta = await cache.get_pipelines("repo-beta")
    assert len(alpha) == 0
    assert len(beta) == 1
    assert beta[0].id == 2
