"""Tests for the Dobby Slack bot — confirmation flow, threads, batch, corrections, queries."""
from datetime import UTC
from unittest.mock import AsyncMock, MagicMock, patch

from dobby.slack.bot import (
    _PENDING,
    _SUMMARY_THREADS,
    _TASK_THREADS,
    DobbySlackBot,
    _build_task_payload,
    _parse_task_end_time,
    _PendingBatch,
    _PendingConfirmation,
    _progress_bar,
    _strip_mention,
    get_task_thread,
    register_task_thread,
)
from dobby.slack.notifier import SlackNotifier
from dobby.slack.parser import SlackParser, looks_like_batch, split_batch_items

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

def _make_bot():
    """Create a DobbySlackBot with mocked Slack dependencies."""
    with patch("dobby.slack.bot.DobbyClient") as MockClient, \
         patch("slack_bolt.async_app.AsyncApp"):
        bot = DobbySlackBot.__new__(DobbySlackBot)
        bot._client_wrapper = MockClient()
        bot._parser = SlackParser(anthropic_api_key=None)
        bot.app = MagicMock()
        return bot


def _clear_state():
    """Clear module-level state between tests."""
    _PENDING.clear()
    _TASK_THREADS.clear()
    _SUMMARY_THREADS.clear()


# ---------------------------------------------------------------------------
# Parser: batch detection
# ---------------------------------------------------------------------------

def test_looks_like_batch_bullets():
    msg = "For spare:\n- add tests\n- fix linting\n- update readme"
    assert looks_like_batch(msg)


def test_looks_like_batch_numbered():
    msg = "Tasks for spare:\n1. add tests\n2. fix linting"
    assert looks_like_batch(msg)


def test_looks_like_batch_single_item():
    msg = "- just one item"
    assert not looks_like_batch(msg)


def test_looks_like_batch_no_bullets():
    msg = "add tests to spare"
    assert not looks_like_batch(msg)


def test_split_batch_items_bullets():
    msg = "For spare:\n- add tests\n- fix linting\n- update readme"
    items = split_batch_items(msg)
    assert len(items) == 3
    assert "add tests" in items[0]
    assert "For spare:" in items[0]  # preamble preserved


def test_split_batch_items_numbered():
    msg = "Tasks:\n1. first task\n2. second task"
    items = split_batch_items(msg)
    assert len(items) == 2
    assert "first task" in items[0]
    assert "second task" in items[1]


def test_split_batch_items_no_preamble():
    msg = "- task one\n- task two"
    items = split_batch_items(msg)
    assert len(items) == 2


# ---------------------------------------------------------------------------
# Parser: correction
# ---------------------------------------------------------------------------

async def test_correction_fallback_backend():
    parser = SlackParser(anthropic_api_key=None)
    current = {
        "project": "spare", "description": "add tests", "priority": "normal", "mode": "quick",
    }
    updated = await parser.apply_correction(current, "make it codex", ["spare"])
    assert updated.get("backend") == "codex"


async def test_correction_fallback_priority():
    parser = SlackParser(anthropic_api_key=None)
    current = {"project": "spare", "description": "add tests", "priority": "normal"}
    updated = await parser.apply_correction(current, "set priority high", ["spare"])
    assert updated["priority"] == "high"


async def test_correction_fallback_mode():
    parser = SlackParser(anthropic_api_key=None)
    current = {"project": "spare", "description": "add tests", "mode": "quick"}
    updated = await parser.apply_correction(current, "make it planned", ["spare"])
    assert updated["mode"] == "planned"


async def test_correction_fallback_project():
    parser = SlackParser(anthropic_api_key=None)
    current = {"project": "spare", "description": "add tests"}
    updated = await parser.apply_correction(current, "actually for memagent", ["spare", "memagent"])
    assert updated["project"] == "memagent"


async def test_correction_with_claude():
    parser = SlackParser()
    mock_response = MagicMock()
    mock_response.content = [
        MagicMock(text=(
            '{"project": "spare", "description": "add tests",'
            ' "priority": "high", "mode": "quick", "backend": "codex"}'
        ))
    ]

    with patch.object(parser, "_client") as mock_client:
        mock_client.messages.create = AsyncMock(return_value=mock_response)
        current = {
            "project": "spare", "description": "add tests",
            "priority": "normal", "mode": "quick",
        }
        updated = await parser.apply_correction(current, "priority high and use codex", ["spare"])

    assert updated["priority"] == "high"
    assert updated.get("backend") == "codex"


# ---------------------------------------------------------------------------
# Parser: batch parsing
# ---------------------------------------------------------------------------

async def test_parse_batch():
    parser = SlackParser(anthropic_api_key=None)
    msg = "For spare:\n- add tests\n- fix linting"
    results = await parser.parse_batch(msg, ["spare"])
    assert len(results) == 2
    assert all(r.get("project") == "spare" for r in results)


async def test_parse_batch_single_item_fallback():
    parser = SlackParser(anthropic_api_key=None)
    msg = "add tests to spare"
    results = await parser.parse_batch(msg, ["spare"])
    assert len(results) == 1


# ---------------------------------------------------------------------------
# Bot: strip mention
# ---------------------------------------------------------------------------

def test_strip_mention():
    assert _strip_mention("<@U12345ABC> hello") == "hello"
    assert _strip_mention("no mention") == "no mention"


# ---------------------------------------------------------------------------
# Bot: build_task_payload
# ---------------------------------------------------------------------------

def test_build_task_payload_basic():
    parsed = {"project": "spare", "description": "add tests", "mode": "quick", "priority": "normal"}
    payload = _build_task_payload(parsed)
    assert payload["project"] == "spare"
    assert payload["description"] == "add tests"
    assert "backend" not in payload


def test_build_task_payload_with_backend():
    parsed = {
        "project": "spare", "description": "add tests",
        "backend": "codex", "mode": "quick", "priority": "normal",
    }
    payload = _build_task_payload(parsed)
    assert payload["backend"] == "codex"


def test_build_task_payload_with_schedule():
    parsed = {
        "project": "spare", "description": "run evals",
        "schedule_type": "cron", "schedule_expr": "0 3 * * *",
        "mode": "quick", "priority": "normal",
    }
    payload = _build_task_payload(parsed)
    assert payload["schedule"]["type"] == "cron"
    assert payload["schedule"]["expr"] == "0 3 * * *"


# ---------------------------------------------------------------------------
# Bot: progress bar
# ---------------------------------------------------------------------------

def test_progress_bar():
    assert _progress_bar(50) == "█████░░░░░"
    assert _progress_bar(100) == "██████████"
    assert _progress_bar(0) == "░░░░░░░░░░"


# ---------------------------------------------------------------------------
# Bot: thread registration
# ---------------------------------------------------------------------------

def test_register_and_get_task_thread():
    _clear_state()
    register_task_thread("t-123", "C001", "1234.5678")
    result = get_task_thread("t-123")
    assert result == ("C001", "1234.5678")


def test_get_task_thread_missing():
    _clear_state()
    assert get_task_thread("t-missing") is None


# ---------------------------------------------------------------------------
# Bot: confirmation flow
# ---------------------------------------------------------------------------

async def test_handle_task_confirmation():
    _clear_state()
    bot = _make_bot()
    bot._client_wrapper.get = AsyncMock(return_value=[{"name": "spare"}])

    say = AsyncMock(return_value={"ts": "1234.5678"})
    client = AsyncMock()
    event = {"channel": "C001", "text": "add tests to spare"}

    await bot._handle_task_confirmation("add tests to spare", event, say, client)

    # Should have posted a preview
    say.assert_called_once()
    msg = say.call_args[0][0]
    assert "Task preview" in msg
    assert "spare" in msg

    # Should have added reactions
    assert client.reactions_add.call_count == 2

    # Should have a pending confirmation
    assert "1234.5678" in _PENDING
    pending = _PENDING["1234.5678"]
    assert isinstance(pending, _PendingConfirmation)
    assert pending.parsed["project"] == "spare"


async def test_confirm_single_task():
    _clear_state()
    bot = _make_bot()
    bot._client_wrapper.post = AsyncMock(return_value={
        "id": "t-20260322-ab12",
        "project": "spare",
        "description": "add tests",
        "status": "pending",
    })

    pending = _PendingConfirmation(
        parsed={"project": "spare", "description": "add tests", "mode": "quick",
                "priority": "normal"},
        channel="C001",
        projects=["spare"],
    )
    client = AsyncMock()

    await bot._confirm_single(pending, "C001", "1234.5678", client)

    # Should have created the task
    bot._client_wrapper.post.assert_called_once()

    # Should have registered the thread
    assert get_task_thread("t-20260322-ab12") == ("C001", "1234.5678")

    # Should have posted confirmation in thread
    client.chat_postMessage.assert_called_once()
    msg = client.chat_postMessage.call_args[1]
    assert "queued" in msg["text"].lower()
    assert msg["thread_ts"] == "1234.5678"


# ---------------------------------------------------------------------------
# Bot: reaction handling
# ---------------------------------------------------------------------------

async def test_reaction_approve():
    _clear_state()
    bot = _make_bot()
    bot._client_wrapper.post = AsyncMock(return_value={
        "id": "t-20260322-ab12", "project": "spare",
        "description": "add tests", "status": "pending",
    })

    # Set up pending confirmation
    _PENDING["1234.5678"] = _PendingConfirmation(
        parsed={"project": "spare", "description": "add tests", "mode": "quick",
                "priority": "normal"},
        channel="C001",
        projects=["spare"],
    )

    event = {
        "reaction": "white_check_mark",
        "item": {"ts": "1234.5678", "channel": "C001"},
    }
    client = AsyncMock()

    await bot._handle_reaction(event, client)

    # Pending should be cleared
    assert "1234.5678" not in _PENDING

    # Task should have been created
    bot._client_wrapper.post.assert_called_once()


async def test_reaction_cancel():
    _clear_state()
    bot = _make_bot()

    _PENDING["1234.5678"] = _PendingConfirmation(
        parsed={"project": "spare", "description": "add tests"},
        channel="C001",
        projects=["spare"],
    )

    event = {
        "reaction": "x",
        "item": {"ts": "1234.5678", "channel": "C001"},
    }
    client = AsyncMock()

    await bot._handle_reaction(event, client)

    assert "1234.5678" not in _PENDING
    client.chat_postMessage.assert_called_once()
    assert "cancelled" in client.chat_postMessage.call_args[1]["text"].lower()


async def test_reaction_ignored_for_unknown_message():
    _clear_state()
    bot = _make_bot()
    event = {
        "reaction": "white_check_mark",
        "item": {"ts": "unknown.ts", "channel": "C001"},
    }
    client = AsyncMock()
    await bot._handle_reaction(event, client)
    # Should be a no-op
    client.chat_postMessage.assert_not_called()


# ---------------------------------------------------------------------------
# Bot: batch confirmation
# ---------------------------------------------------------------------------

async def test_handle_batch():
    _clear_state()
    bot = _make_bot()
    bot._client_wrapper.get = AsyncMock(return_value=[{"name": "spare"}])

    say = AsyncMock(return_value={"ts": "batch.ts"})
    client = AsyncMock()
    event = {"channel": "C001"}

    text = "For spare:\n- add tests\n- fix linting\n- update docs"
    await bot._handle_batch(text, event, say, client)

    say.assert_called_once()
    msg = say.call_args[0][0]
    assert "Batch preview" in msg
    assert "3 tasks total" in msg

    assert "batch.ts" in _PENDING
    pending = _PENDING["batch.ts"]
    assert isinstance(pending, _PendingBatch)
    assert len(pending.tasks) == 3


async def test_confirm_batch():
    _clear_state()
    bot = _make_bot()
    bot._client_wrapper.post = AsyncMock(return_value={
        "batch_id": "b-20260322-ab12",
        "tasks": [
            {"id": "t-20260322-0001", "project": "spare", "description": "task 1"},
            {"id": "t-20260322-0002", "project": "spare", "description": "task 2"},
        ],
    })

    pending = _PendingBatch(
        tasks=[
            {"project": "spare", "description": "task 1", "mode": "quick", "priority": "normal"},
            {"project": "spare", "description": "task 2", "mode": "quick", "priority": "normal"},
        ],
        channel="C001",
        projects=["spare"],
    )
    client = AsyncMock()

    await bot._confirm_batch(pending, "C001", "batch.ts", client)

    # Should have called /batches endpoint
    bot._client_wrapper.post.assert_called_once()
    call_args = bot._client_wrapper.post.call_args
    assert call_args[0][0] == "/batches"

    # Both tasks should have threads registered
    assert get_task_thread("t-20260322-0001") == ("C001", "batch.ts")
    assert get_task_thread("t-20260322-0002") == ("C001", "batch.ts")


# ---------------------------------------------------------------------------
# Bot: inline corrections
# ---------------------------------------------------------------------------

async def test_inline_correction():
    _clear_state()
    bot = _make_bot()

    _PENDING["1234.5678"] = _PendingConfirmation(
        parsed={"project": "spare", "description": "add tests", "priority": "normal",
                "mode": "quick"},
        channel="C001",
        projects=["spare"],
    )

    event = {"channel": "C001", "thread_ts": "1234.5678"}
    say = AsyncMock()
    client = AsyncMock()

    await bot._handle_correction("1234.5678", "make it codex", event, say, client)

    # Should have posted the updated preview
    client.chat_postMessage.assert_called_once()
    msg = client.chat_postMessage.call_args[1]["text"]
    assert "Updated" in msg

    # Pending should be updated
    pending = _PENDING["1234.5678"]
    assert pending.parsed.get("backend") == "codex"


async def test_inline_correction_batch():
    _clear_state()
    bot = _make_bot()

    _PENDING["batch.ts"] = _PendingBatch(
        tasks=[
            {"project": "spare", "description": "task 1", "priority": "normal", "mode": "quick"},
            {"project": "spare", "description": "task 2", "priority": "normal", "mode": "quick"},
        ],
        channel="C001",
        projects=["spare"],
    )

    event = {"channel": "C001", "thread_ts": "batch.ts"}
    say = AsyncMock()
    client = AsyncMock()

    await bot._handle_correction("batch.ts", "set priority high", event, say, client)

    client.chat_postMessage.assert_called_once()
    pending = _PENDING["batch.ts"]
    assert all(t["priority"] == "high" for t in pending.tasks)


# ---------------------------------------------------------------------------
# Bot: DM shorthand - "running?"
# ---------------------------------------------------------------------------

async def test_running_query():
    _clear_state()
    bot = _make_bot()
    bot._client_wrapper.get = AsyncMock(return_value={
        "items": [
            {"id": "t-001", "project": "spare", "description": "running task", "status": "running"},
        ],
    })

    say = AsyncMock()
    await bot._handle_running_query(say)

    say.assert_called_once()
    msg = say.call_args[0][0]
    assert "1 running" in msg
    assert "t-001" in msg


async def test_running_query_empty():
    _clear_state()
    bot = _make_bot()
    bot._client_wrapper.get = AsyncMock(return_value={"items": []})

    say = AsyncMock()
    await bot._handle_running_query(say)

    say.assert_called_once()
    assert "No tasks" in say.call_args[0][0]


# ---------------------------------------------------------------------------
# Bot: DM shorthand - "results <project>"
# ---------------------------------------------------------------------------

async def test_results_query():
    _clear_state()
    bot = _make_bot()
    bot._client_wrapper.get = AsyncMock(return_value={
        "items": [
            {"task_id": "t-001", "status": "completed", "summary": "Tests pass",
             "cost": {"cost_usd": 0.05}},
        ],
    })

    say = AsyncMock()
    await bot._handle_results_query("spare", say)

    say.assert_called_once()
    msg = say.call_args[0][0]
    assert "Recent results" in msg
    assert "spare" in msg


async def test_results_query_empty():
    _clear_state()
    bot = _make_bot()
    bot._client_wrapper.get = AsyncMock(return_value={"items": []})

    say = AsyncMock()
    await bot._handle_results_query(None, say)

    say.assert_called_once()
    assert "No results" in say.call_args[0][0]


# ---------------------------------------------------------------------------
# Bot: detail-status query
# ---------------------------------------------------------------------------

async def test_detail_status_query():
    _clear_state()
    bot = _make_bot()
    bot._client_wrapper.get = AsyncMock(return_value={
        "status": "running",
        "phase": "implementing",
        "progress_pct": 60,
        "current_file": "src/main.py",
        "recent_actions": ["wrote function foo", "added tests"],
        "errors": [],
    })

    say = AsyncMock()
    await bot._handle_detail_status("t-20260322-ab12", say)

    say.assert_called_once()
    msg = say.call_args[0][0]
    assert "t-20260322-ab12" in msg
    assert "implementing" in msg
    assert "60%" in msg
    assert "src/main.py" in msg


# ---------------------------------------------------------------------------
# Bot: dispatch routing
# ---------------------------------------------------------------------------

async def test_dispatch_routes_running():
    _clear_state()
    bot = _make_bot()
    bot._handle_running_query = AsyncMock()
    say = AsyncMock()
    client = AsyncMock()
    event = {"channel": "C001"}

    await bot._dispatch("running?", event, say, client)
    bot._handle_running_query.assert_called_once()


async def test_dispatch_routes_results():
    _clear_state()
    bot = _make_bot()
    bot._handle_results_query = AsyncMock()
    say = AsyncMock()
    client = AsyncMock()
    event = {"channel": "C001"}

    await bot._dispatch("results spare", event, say, client)
    bot._handle_results_query.assert_called_once_with("spare", say)


async def test_dispatch_routes_detail_status():
    _clear_state()
    bot = _make_bot()
    bot._handle_detail_status = AsyncMock()
    say = AsyncMock()
    client = AsyncMock()
    event = {"channel": "C001"}

    await bot._dispatch("whats t-20260322-ab12 doing?", event, say, client)
    bot._handle_detail_status.assert_called_once_with("t-20260322-ab12", say)


async def test_dispatch_routes_status():
    _clear_state()
    bot = _make_bot()
    bot._handle_status = AsyncMock()
    say = AsyncMock()
    client = AsyncMock()
    event = {"channel": "C001"}

    await bot._dispatch("status", event, say, client)
    bot._handle_status.assert_called_once()


async def test_dispatch_routes_batch():
    _clear_state()
    bot = _make_bot()
    bot._handle_batch = AsyncMock()
    say = AsyncMock()
    client = AsyncMock()
    event = {"channel": "C001"}

    text = "For spare:\n- add tests\n- fix linting"
    await bot._dispatch(text, event, say, client)
    bot._handle_batch.assert_called_once()


# ---------------------------------------------------------------------------
# Notifier: thread support
# ---------------------------------------------------------------------------

async def test_notifier_completed_with_thread():
    _clear_state()
    register_task_thread("t-001", "C001", "1234.5678")

    mock_client = MagicMock()
    mock_client.chat_postMessage = AsyncMock()
    notifier = SlackNotifier(mock_client, "C001")

    task = {"id": "t-001", "project": "spare", "description": "test"}
    result = {
        "status": "completed", "summary": "All tests pass",
        "cost": {"cost_usd": 0.05}, "artifacts": [],
    }

    await notifier.notify_completed(task, result)

    # Should have posted to thread AND top-level
    assert mock_client.chat_postMessage.call_count == 2
    # First call is thread reply
    thread_call = mock_client.chat_postMessage.call_args_list[0]
    assert thread_call[1]["thread_ts"] == "1234.5678"
    assert "completed" in thread_call[1]["text"].lower()


async def test_notifier_failed_with_thread():
    _clear_state()
    register_task_thread("t-002", "C001", "5678.1234")

    mock_client = MagicMock()
    mock_client.chat_postMessage = AsyncMock()
    notifier = SlackNotifier(mock_client, "C001")

    task = {"id": "t-002", "project": "spare", "description": "test"}
    result = {"status": "failed", "summary": "Tests failed", "artifacts": []}

    await notifier.notify_failed(task, result)

    assert mock_client.chat_postMessage.call_count == 2
    thread_call = mock_client.chat_postMessage.call_args_list[0]
    assert thread_call[1]["thread_ts"] == "5678.1234"
    assert "failed" in thread_call[1]["text"].lower()


async def test_notifier_running_with_thread():
    _clear_state()
    register_task_thread("t-003", "C001", "9999.0000")

    mock_client = MagicMock()
    mock_client.chat_postMessage = AsyncMock()
    notifier = SlackNotifier(mock_client, "C001")

    task = {"id": "t-003", "project": "spare"}
    await notifier.notify_running(task)

    mock_client.chat_postMessage.assert_called_once()
    call = mock_client.chat_postMessage.call_args[1]
    assert call["thread_ts"] == "9999.0000"
    assert "running" in call["text"].lower()


async def test_notifier_running_no_thread():
    _clear_state()

    mock_client = MagicMock()
    mock_client.chat_postMessage = AsyncMock()
    notifier = SlackNotifier(mock_client, "C001")

    task = {"id": "t-no-thread", "project": "spare"}
    await notifier.notify_running(task)

    # No thread registered, so no message posted
    mock_client.chat_postMessage.assert_not_called()


async def test_notifier_batched_task_suppresses_toplevel():
    _clear_state()
    register_task_thread("t-batch-1", "C001", "batch.ts")

    mock_client = MagicMock()
    mock_client.chat_postMessage = AsyncMock()
    notifier = SlackNotifier(mock_client, "C001")

    task = {"id": "t-batch-1", "project": "spare", "batch_id": "b-123"}
    result = {"status": "completed", "summary": "Done", "cost": {}, "artifacts": []}

    await notifier.notify_completed(task, result)

    # Should only post thread reply (top-level suppressed for batch)
    mock_client.chat_postMessage.assert_called_once()
    call = mock_client.chat_postMessage.call_args[1]
    assert call["thread_ts"] == "batch.ts"


# ---------------------------------------------------------------------------
# Formatting helpers
# ---------------------------------------------------------------------------

def test_format_confirmation_preview():
    parsed = {
        "project": "spare", "description": "add tests",
        "priority": "high", "mode": "quick", "backend": "claude",
        "exit_criteria": "tests pass",
    }
    preview = DobbySlackBot._format_confirmation_preview(parsed)
    assert "spare" in preview
    assert "add tests" in preview
    assert "high" in preview
    assert "submit" in preview.lower()


def test_format_confirmation_preview_updated():
    parsed = {
        "project": "spare", "description": "add tests",
        "priority": "normal", "mode": "quick",
    }
    preview = DobbySlackBot._format_confirmation_preview(parsed, updated=True)
    assert "Updated" in preview


def test_format_batch_preview():
    tasks = [
        {"project": "spare", "description": "task 1", "priority": "normal"},
        {"project": "spare", "description": "task 2", "priority": "high"},
    ]
    preview = DobbySlackBot._format_batch_preview(tasks)
    assert "Batch preview" in preview
    assert "2 tasks total" in preview
    assert "task 1" in preview
    assert "task 2" in preview


def test_format_status_response_empty():
    assert "No tasks" in DobbySlackBot.format_status_response([])


def test_format_status_response():
    tasks = [
        {"id": "t-001", "project": "spare", "description": "test task", "status": "running"},
        {"id": "t-002", "project": "spare", "description": "done task", "status": "completed"},
    ]
    result = DobbySlackBot.format_status_response(tasks)
    assert "2 tasks" in result
    assert "t-001" in result
    assert "in progress" in result.lower()


# ---------------------------------------------------------------------------
# Helper: _parse_task_end_time
# ---------------------------------------------------------------------------

def test_parse_task_end_time_from_event():
    from datetime import datetime
    task = {
        "events": [
            {"status": "pending", "timestamp": "2026-03-22T01:00:00+00:00"},
            {"status": "running", "timestamp": "2026-03-22T02:00:00+00:00"},
            {"status": "completed", "timestamp": "2026-03-22T03:00:00+00:00"},
        ],
    }
    result = _parse_task_end_time(task)
    assert result == datetime(2026, 3, 22, 3, 0, 0, tzinfo=UTC)


def test_parse_task_end_time_fallback_created_at():
    from datetime import datetime
    task = {"events": [], "created_at": "2026-03-22T01:00:00+00:00"}
    result = _parse_task_end_time(task)
    assert result == datetime(2026, 3, 22, 1, 0, 0, tzinfo=UTC)


def test_parse_task_end_time_none():
    assert _parse_task_end_time({}) is None


# ---------------------------------------------------------------------------
# Bot: summary / overnight report
# ---------------------------------------------------------------------------

async def test_handle_summary():
    _clear_state()
    bot = _make_bot()

    from datetime import datetime
    now = datetime.now(UTC)

    # API returns tasks with completed/failed status and recent events
    bot._client_wrapper.get = AsyncMock(side_effect=_make_summary_api(now))

    say = AsyncMock(return_value={"ts": "summary.ts"})
    await bot._handle_summary(None, "C001", say)

    say.assert_called_once()
    call_kwargs = say.call_args[1]
    blocks = call_kwargs["blocks"]

    # Should have Block Kit blocks
    assert isinstance(blocks, list)
    assert len(blocks) > 0

    # Header should contain "Overnight Summary"
    header_text = blocks[0]["text"]["text"]
    assert "Overnight Summary" in header_text

    # Should track summary thread
    assert "summary.ts" in _SUMMARY_THREADS


async def test_handle_summary_with_project():
    _clear_state()
    bot = _make_bot()

    from datetime import datetime
    now = datetime.now(UTC)

    bot._client_wrapper.get = AsyncMock(side_effect=_make_summary_api(now))

    say = AsyncMock(return_value={"ts": "summary2.ts"})
    await bot._handle_summary("spare", "C001", say)

    say.assert_called_once()
    call_kwargs = say.call_args[1]
    blocks = call_kwargs["blocks"]

    # Header should contain the project name
    header_text = blocks[0]["text"]["text"]
    assert "spare" in header_text

    # Summary thread should track the project filter
    assert _SUMMARY_THREADS.get("summary2.ts") == "spare"


async def test_handle_summary_empty():
    _clear_state()
    bot = _make_bot()

    # No completed/failed tasks
    bot._client_wrapper.get = AsyncMock(return_value={"items": []})

    say = AsyncMock(return_value={"ts": "empty.ts"})
    await bot._handle_summary(None, "C001", say)

    say.assert_called_once()
    blocks = say.call_args[1]["blocks"]
    # Should contain "No tasks" message
    all_text = " ".join(
        b.get("text", {}).get("text", "") for b in blocks if "text" in b
    )
    assert "No tasks" in all_text


async def test_handle_summary_detail():
    _clear_state()
    bot = _make_bot()

    # Mock task and result endpoints
    task_data = {
        "id": "t-20260322-ab12", "project": "spare",
        "description": "add tests", "status": "completed",
    }
    result_data = {
        "task_id": "t-20260322-ab12", "status": "completed",
        "summary": "All tests pass now",
        "cost": {"cost_usd": 0.05, "input_tokens": 1000, "output_tokens": 500,
                 "total_tokens": 1500},
        "metrics": {"files_changed": 3, "insertions": 42, "deletions": 10},
        "artifacts": [{"type": "pr", "url": "https://github.com/org/repo/pull/123"}],
    }

    async def mock_get(path, **kwargs):
        if path.startswith("/tasks/"):
            return task_data
        if path.startswith("/results/"):
            return result_data
        return {}

    bot._client_wrapper.get = AsyncMock(side_effect=mock_get)

    say = AsyncMock()
    await bot._handle_summary_detail("tell me more about t-20260322-ab12", say)

    say.assert_called_once()
    blocks = say.call_args[1]["blocks"]

    # Should contain task ID in header
    all_text = " ".join(
        b.get("text", {}).get("text", "") for b in blocks if "text" in b
    )
    assert "t-20260322-ab12" in all_text
    assert "All tests pass" in all_text


async def test_handle_summary_detail_no_task_id():
    _clear_state()
    bot = _make_bot()

    say = AsyncMock()
    await bot._handle_summary_detail("details", say)

    say.assert_called_once()
    msg = say.call_args[0][0]
    assert "task ID" in msg


async def test_dispatch_routes_summary():
    _clear_state()
    bot = _make_bot()
    bot._handle_summary = AsyncMock()
    say = AsyncMock()
    client = AsyncMock()
    event = {"channel": "C001"}

    await bot._dispatch("summary", event, say, client)
    bot._handle_summary.assert_called_once_with(None, "C001", say)


async def test_dispatch_routes_summary_with_project():
    _clear_state()
    bot = _make_bot()
    bot._handle_summary = AsyncMock()
    say = AsyncMock()
    client = AsyncMock()
    event = {"channel": "C001"}

    await bot._dispatch("summary spare", event, say, client)
    bot._handle_summary.assert_called_once_with("spare", "C001", say)


async def test_dispatch_routes_overnight():
    _clear_state()
    bot = _make_bot()
    bot._handle_summary = AsyncMock()
    say = AsyncMock()
    client = AsyncMock()
    event = {"channel": "C001"}

    await bot._dispatch("overnight", event, say, client)
    bot._handle_summary.assert_called_once_with(None, "C001", say)


async def test_dispatch_routes_what_happened():
    _clear_state()
    bot = _make_bot()
    bot._handle_summary = AsyncMock()
    say = AsyncMock()
    client = AsyncMock()
    event = {"channel": "C001"}

    await bot._dispatch("what happened last night", event, say, client)
    bot._handle_summary.assert_called_once_with(None, "C001", say)


async def test_dispatch_routes_summary_thread_drilldown():
    _clear_state()
    bot = _make_bot()
    bot._handle_summary_detail = AsyncMock()
    say = AsyncMock()
    client = AsyncMock()

    # Register a summary thread
    _SUMMARY_THREADS["summary.ts"] = None

    event = {"channel": "C001", "thread_ts": "summary.ts"}
    await bot._dispatch("tell me more about t-20260322-ab12", event, say, client)
    bot._handle_summary_detail.assert_called_once_with("tell me more about t-20260322-ab12", say)


# ---------------------------------------------------------------------------
# Summary: Block Kit builders
# ---------------------------------------------------------------------------

def test_build_summary_blocks_with_tasks():
    tasks = [
        {"id": "t-001", "project": "spare", "description": "add tests", "status": "completed"},
        {"id": "t-002", "project": "spare", "description": "fix bug", "status": "failed"},
    ]
    results_map = {
        "t-001": {
            "summary": "Tests added", "cost": {"cost_usd": 0.05},
            "metrics": {"files_changed": 3, "insertions": 40, "deletions": 5},
            "artifacts": [{"type": "pr", "url": "https://github.com/org/repo/pull/1"}],
        },
        "t-002": {
            "summary": "Segmentation fault", "cost": {"cost_usd": 0.02},
            "metrics": {}, "artifacts": [],
        },
    }

    blocks = DobbySlackBot._build_summary_blocks(tasks, results_map, None)
    all_text = " ".join(
        b.get("text", {}).get("text", "") for b in blocks if "text" in b
    )

    assert "Overnight Summary" in all_text
    assert "t-001" in all_text
    assert "t-002" in all_text
    assert "Tests added" in all_text
    assert "Segmentation fault" in all_text
    assert "+40/-5" in all_text  # diff stats
    assert "PR" in all_text  # PR link


def test_build_summary_blocks_empty():
    blocks = DobbySlackBot._build_summary_blocks([], {}, None)
    all_text = " ".join(
        b.get("text", {}).get("text", "") for b in blocks if "text" in b
    )
    assert "No tasks" in all_text


def test_build_summary_blocks_with_project_filter():
    blocks = DobbySlackBot._build_summary_blocks([], {}, "spare")
    header = blocks[0]["text"]["text"]
    assert "spare" in header


def test_build_task_detail_blocks():
    task = {"id": "t-001", "project": "spare", "description": "add tests", "status": "completed"}
    result = {
        "summary": "All tests pass",
        "cost": {"cost_usd": 0.05, "input_tokens": 1000, "output_tokens": 500,
                 "total_tokens": 1500},
        "metrics": {"files_changed": 3},
        "artifacts": [{"type": "pr", "url": "https://github.com/org/repo/pull/1"}],
    }
    blocks = DobbySlackBot._build_task_detail_blocks(task, result)
    all_text = " ".join(
        b.get("text", {}).get("text", "") for b in blocks if "text" in b
    )
    assert "t-001" in all_text
    assert "All tests pass" in all_text
    assert "pr" in all_text.lower() or "PR" in all_text

    # Check cost fields
    fields_text = " ".join(
        f.get("text", "") for b in blocks if "fields" in b for f in b["fields"]
    )
    assert "$0.0500" in fields_text
    assert "1,500" in fields_text


def test_build_task_detail_blocks_no_result():
    task = {"id": "t-001", "project": "spare", "description": "add tests", "status": "running"}
    blocks = DobbySlackBot._build_task_detail_blocks(task, None)
    all_text = " ".join(
        b.get("text", {}).get("text", "") for b in blocks if "text" in b
    )
    assert "No result data" in all_text


# ---------------------------------------------------------------------------
# Summary test helpers
# ---------------------------------------------------------------------------

def _make_summary_api(now):
    """Create a mock API side_effect for summary tests."""
    from datetime import timedelta

    recent_ts = (now - timedelta(hours=2)).isoformat()
    old_ts = (now - timedelta(hours=24)).isoformat()

    tasks_response = {
        "items": [
            {
                "id": "t-001", "project": "spare", "description": "add tests",
                "status": "completed",
                "events": [
                    {"status": "completed", "timestamp": recent_ts},
                ],
            },
            {
                "id": "t-002", "project": "spare", "description": "fix bug",
                "status": "failed",
                "events": [
                    {"status": "failed", "timestamp": recent_ts},
                ],
            },
            {
                "id": "t-003", "project": "spare", "description": "old task",
                "status": "completed",
                "events": [
                    {"status": "completed", "timestamp": old_ts},
                ],
            },
            {
                "id": "t-004", "project": "spare", "description": "still running",
                "status": "running",
            },
        ],
    }

    results = {
        "t-001": {
            "task_id": "t-001", "status": "completed",
            "summary": "Tests added", "cost": {"cost_usd": 0.05},
            "metrics": {}, "artifacts": [],
        },
        "t-002": {
            "task_id": "t-002", "status": "failed",
            "summary": "Build failed", "cost": {"cost_usd": 0.02},
            "metrics": {}, "artifacts": [],
        },
    }

    async def mock_get(path, **kwargs):
        if path == "/tasks":
            return tasks_response
        if path.startswith("/results/"):
            task_id = path.split("/")[-1]
            if task_id in results:
                return results[task_id]
            raise Exception("Not found")
        return {}

    return mock_get
