"""Tests for batch dispatch, export, and batch notifications."""
import csv
import io
import json
import os
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from click.testing import CliRunner

from dobby.cli.main import cli
from dobby.core.api import create_app
from dobby.models import (
    Result,
    Task,
    TaskStatus,
    generate_batch_id,
)
from dobby.slack.notifier import SlackNotifier
from dobby.store.sqlite import SCHEMA_VERSION, SqliteStore

# ---------------------------------------------------------------------------
# Model: batch_id field
# ---------------------------------------------------------------------------

def test_generate_batch_id():
    bid = generate_batch_id()
    assert bid.startswith("b-")
    assert len(bid) == 15  # b-YYYYMMDD-XXXX


def test_task_batch_id_default():
    task = Task(project="spare", description="Test")
    assert task.batch_id is None


def test_task_batch_id_set():
    task = Task(project="spare", description="Test", batch_id="b-20260322-ab12")
    assert task.batch_id == "b-20260322-ab12"


def test_task_batch_id_roundtrip():
    task = Task(project="spare", description="Test", batch_id="b-20260322-ab12")
    d = task.to_dict()
    assert d["batch_id"] == "b-20260322-ab12"
    restored = Task.from_dict(d)
    assert restored.batch_id == "b-20260322-ab12"


def test_task_batch_id_none_roundtrip():
    task = Task(project="spare", description="Test")
    d = task.to_dict()
    assert d["batch_id"] is None
    restored = Task.from_dict(d)
    assert restored.batch_id is None


# ---------------------------------------------------------------------------
# Store: batch methods
# ---------------------------------------------------------------------------

async def test_list_tasks_by_batch(store):
    await store.create_task(Task(project="spare", description="A", batch_id="b-001"))
    await store.create_task(Task(project="spare", description="B", batch_id="b-001"))
    await store.create_task(Task(project="spare", description="C", batch_id="b-002"))
    await store.create_task(Task(project="spare", description="D"))  # no batch

    batch_tasks = await store.list_tasks_by_batch("b-001")
    assert len(batch_tasks) == 2
    assert all(t.batch_id == "b-001" for t in batch_tasks)


async def test_list_tasks_by_batch_empty(store):
    batch_tasks = await store.list_tasks_by_batch("b-nonexistent")
    assert batch_tasks == []


async def test_list_batches(store):
    await store.create_task(Task(project="spare", description="A", batch_id="b-001"))
    await store.create_task(Task(project="spare", description="B", batch_id="b-001"))
    await store.create_task(Task(project="spare", description="C", batch_id="b-002"))
    await store.create_task(Task(project="spare", description="D"))  # no batch

    batches = await store.list_batches()
    assert len(batches) == 2

    # Both are pending so done=0
    b001 = next(b for b in batches if b["batch_id"] == "b-001")
    assert b001["total"] == 2
    assert b001["done"] == 0
    assert b001["completed"] == 0
    assert b001["failed"] == 0
    assert b001["running"] == 0


async def test_list_batches_with_progress(store):
    t1 = Task(id="t-b1", project="spare", description="A", batch_id="b-001")
    t2 = Task(id="t-b2", project="spare", description="B", batch_id="b-001")
    await store.create_task(t1)
    await store.create_task(t2)

    # Complete one task
    await store.update_task("t-b1", status=TaskStatus.RUNNING.value)
    await store.update_task("t-b1", status=TaskStatus.COMPLETED.value)

    batches = await store.list_batches()
    b = batches[0]
    assert b["total"] == 2
    assert b["completed"] == 1
    assert b["done"] == 1


# ---------------------------------------------------------------------------
# API: batch endpoints
# ---------------------------------------------------------------------------

@pytest.fixture
async def client(tmp_path, aiohttp_client):
    store = SqliteStore(str(tmp_path / "test.db"))
    await store.initialize()
    results_dir = str(tmp_path / "results")
    app = create_app(store, results_dir=results_dir)
    client = await aiohttp_client(app)
    yield client
    await store.close()


async def test_create_batch(client):
    resp = await client.post("/batches", json={
        "tasks": [
            {"project": "spare", "description": "Task A"},
            {"project": "spare", "description": "Task B", "priority": "high"},
        ]
    })
    assert resp.status == 201
    data = await resp.json()
    assert data["batch_id"].startswith("b-")
    assert len(data["tasks"]) == 2
    assert all(t["batch_id"] == data["batch_id"] for t in data["tasks"])


async def test_create_batch_empty(client):
    resp = await client.post("/batches", json={"tasks": []})
    assert resp.status == 400


async def test_list_batches_api(client):
    # Create two batches
    await client.post("/batches", json={
        "tasks": [
            {"project": "spare", "description": "Batch 1 Task A"},
            {"project": "spare", "description": "Batch 1 Task B"},
        ]
    })
    await client.post("/batches", json={
        "tasks": [
            {"project": "memagent", "description": "Batch 2 Task A"},
        ]
    })

    resp = await client.get("/batches")
    assert resp.status == 200
    data = await resp.json()
    assert len(data) == 2


async def test_get_batch(client):
    create_resp = await client.post("/batches", json={
        "tasks": [
            {"project": "spare", "description": "Task A"},
            {"project": "spare", "description": "Task B"},
        ]
    })
    batch_id = (await create_resp.json())["batch_id"]

    resp = await client.get(f"/batches/{batch_id}")
    assert resp.status == 200
    data = await resp.json()
    assert data["batch_id"] == batch_id
    assert data["total"] == 2
    assert data["done"] == 0
    assert data["all_done"] is False


async def test_get_batch_not_found(client):
    resp = await client.get("/batches/b-nonexistent")
    assert resp.status == 404


async def test_batch_progress_tracking(client):
    create_resp = await client.post("/batches", json={
        "tasks": [
            {"project": "spare", "description": "Task A"},
            {"project": "spare", "description": "Task B"},
        ]
    })
    create_data = await create_resp.json()
    batch_id = create_data["batch_id"]
    task_ids = [t["id"] for t in create_data["tasks"]]

    # Complete the first task
    await client.patch(f"/tasks/{task_ids[0]}", json={"status": "running"})
    await client.patch(f"/tasks/{task_ids[0]}", json={"status": "completed"})

    resp = await client.get(f"/batches/{batch_id}")
    data = await resp.json()
    assert data["completed"] == 1
    assert data["done"] == 1
    assert data["all_done"] is False

    # Complete the second task
    await client.patch(f"/tasks/{task_ids[1]}", json={"status": "running"})
    await client.patch(f"/tasks/{task_ids[1]}", json={"status": "completed"})

    resp = await client.get(f"/batches/{batch_id}")
    data = await resp.json()
    assert data["completed"] == 2
    assert data["done"] == 2
    assert data["all_done"] is True


# ---------------------------------------------------------------------------
# API: export endpoint
# ---------------------------------------------------------------------------

async def test_export_json(client):
    await client.post("/tasks", json={"project": "spare", "description": "Task 1"})
    await client.post("/tasks", json={"project": "spare", "description": "Task 2"})

    resp = await client.get("/export?format=json")
    assert resp.status == 200
    data = await resp.json()
    assert len(data) == 2
    assert all("task_id" in row for row in data)
    assert all("project" in row for row in data)
    assert all("cost_usd" in row for row in data)


async def test_export_json_filter_project(client):
    await client.post("/tasks", json={"project": "spare", "description": "Task 1"})
    await client.post("/tasks", json={"project": "memagent", "description": "Task 2"})

    resp = await client.get("/export?format=json&project=spare")
    data = await resp.json()
    assert len(data) == 1
    assert data[0]["project"] == "spare"


async def test_export_csv(client):
    await client.post("/tasks", json={"project": "spare", "description": "Task 1"})

    resp = await client.get("/export?format=csv")
    assert resp.status == 200
    assert "text/csv" in resp.headers["Content-Type"]
    text = await resp.text()
    reader = csv.DictReader(io.StringIO(text))
    rows = list(reader)
    assert len(rows) == 1
    assert rows[0]["project"] == "spare"


async def test_export_with_results(client):
    task_resp = await client.post("/tasks", json={"project": "spare", "description": "Task 1"})
    task_id = (await task_resp.json())["id"]
    await client.app["store"].store_result(
        Result(
            task_id=task_id,
            status="completed",
            summary="All done",
            cost={"input_tokens": 100, "output_tokens": 50, "total_tokens": 150, "cost_usd": 0.05},
        )
    )

    resp = await client.get("/export?format=json")
    data = await resp.json()
    assert len(data) == 1
    assert data[0]["result_summary"] == "All done"
    assert data[0]["cost_usd"] == 0.05


# ---------------------------------------------------------------------------
# CLI: batch command
# ---------------------------------------------------------------------------

def test_batch_cli(runner, tmp_path):
    yaml_file = tmp_path / "batch.yaml"
    yaml_file.write_text(
        "tasks:\n"
        "  - project: spare\n"
        "    description: 'Fix warmup scheduler'\n"
        "    priority: high\n"
        "  - project: spare\n"
        "    description: 'Add eval harness'\n"
    )
    with patch("dobby.cli.client.DobbyClient.post", new_callable=AsyncMock) as mock_post:
        mock_post.return_value = {
            "batch_id": "b-20260322-ab12",
            "tasks": [
                {"id": "t-001", "project": "spare", "description": "Fix warmup scheduler", "batch_id": "b-20260322-ab12"},
                {"id": "t-002", "project": "spare", "description": "Add eval harness", "batch_id": "b-20260322-ab12"},
            ],
        }
        result = runner.invoke(cli, ["batch", str(yaml_file)])
    assert result.exit_code == 0
    assert "b-20260322-ab12" in result.output
    assert "2" in result.output  # tasks count


def test_batch_cli_empty_yaml(runner, tmp_path):
    yaml_file = tmp_path / "empty.yaml"
    yaml_file.write_text("tasks: []\n")
    result = runner.invoke(cli, ["batch", str(yaml_file)])
    assert result.exit_code == 1


def test_batch_status_cli(runner):
    with patch("dobby.cli.client.DobbyClient.get", new_callable=AsyncMock) as mock_get:
        mock_get.return_value = {
            "batch_id": "b-20260322-ab12",
            "total": 3, "completed": 2, "failed": 0, "running": 1, "done": 2,
            "all_done": False,
            "tasks": [
                {"id": "t-001", "status": "completed", "description": "Task A"},
                {"id": "t-002", "status": "completed", "description": "Task B"},
                {"id": "t-003", "status": "running", "description": "Task C"},
            ],
        }
        result = runner.invoke(cli, ["batch-status", "b-20260322-ab12"])
    assert result.exit_code == 0
    assert "2/3 done" in result.output


# ---------------------------------------------------------------------------
# CLI: export command
# ---------------------------------------------------------------------------

def test_export_cli_json(runner):
    with patch("dobby.cli.client.DobbyClient.get", new_callable=AsyncMock) as mock_get:
        mock_get.return_value = [
            {"task_id": "t-001", "project": "spare", "description": "Task 1",
             "status": "completed", "cost_usd": 0.10},
        ]
        result = runner.invoke(cli, ["export", "--format", "json"])
    assert result.exit_code == 0
    data = json.loads(result.output)
    assert len(data) == 1


def test_export_cli_json_to_file(runner, tmp_path):
    output_file = str(tmp_path / "export.json")
    with patch("dobby.cli.client.DobbyClient.get", new_callable=AsyncMock) as mock_get:
        mock_get.return_value = [
            {"task_id": "t-001", "project": "spare", "description": "Task 1",
             "status": "completed", "cost_usd": 0.10},
        ]
        result = runner.invoke(cli, ["export", "--format", "json", "-o", output_file])
    assert result.exit_code == 0
    assert os.path.exists(output_file)
    with open(output_file) as f:
        data = json.load(f)
    assert len(data) == 1


# ---------------------------------------------------------------------------
# Slack: batch notification
# ---------------------------------------------------------------------------

def test_notifier_suppresses_individual_batch_task():
    """Individual notifications should be suppressed for batched tasks."""
    slack_client = MagicMock()
    notifier = SlackNotifier(slack_client, "#general")

    import asyncio
    # Task with batch_id should be suppressed
    task_with_batch = {"id": "t-001", "project": "spare", "batch_id": "b-001", "description": "Test"}
    result = {"status": "completed", "summary": "Done", "cost": {}, "artifacts": []}
    asyncio.run(notifier.notify_completed(task_with_batch, result))
    slack_client.chat_postMessage.assert_not_called()

    asyncio.run(notifier.notify_failed(task_with_batch, result))
    slack_client.chat_postMessage.assert_not_called()


def test_notifier_sends_individual_non_batch_task():
    """Individual notifications should fire for non-batched tasks."""
    slack_client = MagicMock()
    notifier = SlackNotifier(slack_client, "#general")

    import asyncio
    task_no_batch = {"id": "t-001", "project": "spare", "description": "Test"}
    result = {"status": "completed", "summary": "Done", "cost": {}, "artifacts": []}
    asyncio.run(notifier.notify_completed(task_no_batch, result))
    slack_client.chat_postMessage.assert_called_once()


def test_notifier_batch_completed_blocks():
    """Batch completion notification should include aggregate summary."""
    blocks = SlackNotifier._build_batch_completed_blocks(
        "b-001",
        {
            "total": 3,
            "completed": 2,
            "failed": 1,
            "task_summaries": [
                {"task_id": "t-001", "status": "completed", "summary": "OK 1"},
                {"task_id": "t-002", "status": "completed", "summary": "OK 2"},
                {"task_id": "t-003", "status": "failed", "summary": "Error happened"},
            ],
            "total_cost_usd": 1.23,
        },
    )
    # Should have sections for header, counts, per-task, cost, divider, context
    text = json.dumps(blocks)
    assert "b-001" in text
    assert "2 succeeded" in text
    assert "1 failed" in text
    assert "1.2300" in text


def test_notifier_batch_all_success_blocks():
    blocks = SlackNotifier._build_batch_completed_blocks(
        "b-002",
        {"total": 2, "completed": 2, "failed": 0, "task_summaries": [], "total_cost_usd": 0},
    )
    text = json.dumps(blocks)
    assert "All tasks succeeded" in text


# ---------------------------------------------------------------------------
# DB migration v3 -> v4
# ---------------------------------------------------------------------------

async def test_migration_v3_to_v4(tmp_path):
    """Simulate a v3 DB and verify migration adds batch_id."""
    import aiosqlite
    db_path = str(tmp_path / "v3.db")
    async with aiosqlite.connect(db_path) as db:
        await db.execute("PRAGMA journal_mode=WAL")
        await db.execute("CREATE TABLE tasks (id TEXT PRIMARY KEY, data TEXT NOT NULL)")
        await db.execute("CREATE TABLE results (id INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT NOT NULL, data TEXT NOT NULL)")
        await db.execute("CREATE TABLE projects (name TEXT PRIMARY KEY, data TEXT NOT NULL)")
        await db.execute("CREATE TABLE schema_version (id INTEGER PRIMARY KEY CHECK (id = 1), version INTEGER NOT NULL)")
        await db.execute("INSERT INTO schema_version (id, version) VALUES (1, 3)")
        task_data = json.dumps({
            "id": "t-test-v3", "project": "p", "description": "v3 task",
            "mode": "quick", "priority": "normal", "exit_criteria": None,
            "schedule": {"type": "once", "expr": None, "every": None, "until": None, "depends_on": None},
            "infra": None, "timeout": None, "checkpoint": False, "plan": [],
            "session_id": None, "backend": "claude",
            "created_at": "2026-01-01T00:00:00+00:00",
            "started_at": None, "last_run_at": None, "next_run_at": None,
            "attempt_number": 0, "max_failures": 3, "consecutive_failures": 0,
            "events": [{"status": "pending", "timestamp": "2026-01-01T00:00:00+00:00", "detail": "created"}],
            "status": "pending",
        })
        await db.execute("INSERT INTO tasks (id, data) VALUES (?, ?)", ("t-test-v3", task_data))
        await db.commit()

    s = SqliteStore(db_path)
    await s.initialize()
    version = await s.get_schema_version()
    assert version == SCHEMA_VERSION

    task = await s.get_task("t-test-v3")
    assert task.batch_id is None  # backfilled to None
    await s.close()


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

@pytest.fixture
def runner():
    return CliRunner()
