import json
import os
from datetime import UTC, datetime, timedelta
from unittest.mock import AsyncMock, patch

import pytest

from dobby.config import DobbyConfig
from dobby.core.daemon import DobbyDaemon
from dobby.models import InfraConfig, Project, Result, Schedule, ScheduleType, Task, TaskStatus


def _make_readline_mock(output: bytes):
    """Return an async callable that yields lines from output then b''."""
    lines = [line + b"\n" for line in output.split(b"\n") if line]
    lines.append(b"")
    idx = 0

    async def readline():
        nonlocal idx
        val = lines[idx] if idx < len(lines) else b""
        idx = min(idx + 1, len(lines))
        return val

    return readline


@pytest.fixture
async def daemon(tmp_path):
    config = DobbyConfig(
        db_path=str(tmp_path / "test.db"),
        results_dir=str(tmp_path / "results"),
        max_concurrent_agents=2,
        port=0,  # random port
    )
    d = DobbyDaemon(config)
    await d.initialize()
    yield d
    await d.shutdown()


async def test_daemon_initializes(daemon):
    assert daemon.store is not None
    assert daemon.queue is not None
    assert daemon.dispatcher is not None


async def test_daemon_processes_task(daemon):
    project = Project(name="test-project", repo="/tmp/test-repo")
    await daemon.store.register_project(project)

    task = Task(project="test-project", description="Say hello", exit_criteria="done")
    await daemon.store.create_task(task)

    mock_result_output = (
        '---DOBBY_RESULT---\n'
        '{"status": "completed", "summary": "Hello!", "pr_url": null,'
        ' "files_changed": [], "metrics": {}, "error": null}'
        '\n---END_DOBBY_RESULT---'
    )

    mock_proc = AsyncMock()
    mock_proc.stdout.readline = _make_readline_mock(mock_result_output.encode())
    mock_proc.returncode = 0
    mock_proc.pid = 1
    mock_proc.wait = AsyncMock()

    with patch("asyncio.create_subprocess_exec", return_value=mock_proc):
        await daemon.tick()

    updated = await daemon.store.get_task(task.id)
    assert updated.status.value == "completed"

    result = await daemon.store.get_result(task.id)
    assert result is not None
    assert result.summary == "Hello!"


async def test_daemon_skips_locked_projects(daemon):
    project = Project(name="locked-proj", repo="/tmp")
    await daemon.store.register_project(project)
    task = Task(project="locked-proj", description="Should skip")
    await daemon.store.create_task(task)

    daemon.queue.lock_project("locked-proj")
    await daemon.tick()

    t = await daemon.store.get_task(task.id)
    assert t.status.value == "pending"


async def test_daemon_runs_due_recurring_task_and_reactivates(daemon):
    project = Project(name="scheduled-project", repo="/tmp/test-repo")
    await daemon.store.register_project(project)

    task = Task(
        project="scheduled-project",
        description="Nightly eval",
        schedule=Schedule(type=ScheduleType.INTERVAL, every="30m"),
    )
    await daemon.store.create_task(task)

    mock_result_output = (
        '---DOBBY_RESULT---\n'
        '{"status": "completed", "summary": "Eval finished", "pr_url": null,'
        ' "files_changed": [], "metrics": {}, "error": null}'
        '\n---END_DOBBY_RESULT---'
    )

    mock_proc = AsyncMock()
    mock_proc.stdout.readline = _make_readline_mock(mock_result_output.encode())
    mock_proc.returncode = 0
    mock_proc.pid = 1
    mock_proc.wait = AsyncMock()

    with patch("asyncio.create_subprocess_exec", return_value=mock_proc):
        await daemon.tick()

    updated = await daemon.store.get_task(task.id)
    assert updated.status.value == "active"
    assert updated.last_run_at is not None

    result = await daemon.store.get_result(task.id)
    assert result is not None
    assert result.started_at is not None


async def test_daemon_graceful_shutdown(daemon):
    await daemon.shutdown()


async def test_daemon_marks_orphaned_tasks_on_restart(daemon):
    task = Task(project="test", description="Was running")
    tid = await daemon.store.create_task(task)
    # Valid transition: pending -> running
    await daemon.store.update_task(tid, status="running")

    config = daemon.config
    await daemon.shutdown()
    d2 = DobbyDaemon(config)
    await d2.initialize()

    t = await d2.store.get_task(tid)
    assert t.status.value == "failed"
    await d2.shutdown()


async def test_daemon_increments_attempt_number(daemon):
    """tick() should increment the task's attempt_number and stamp the result."""
    project = Project(name="attempt-proj", repo="/tmp/test-repo")
    await daemon.store.register_project(project)

    task = Task(project="attempt-proj", description="Attempt test")
    await daemon.store.create_task(task)
    assert task.attempt_number == 0

    mock_result_output = (
        '---DOBBY_RESULT---\n'
        '{"status": "completed", "summary": "Done", "pr_url": null,'
        ' "files_changed": [], "metrics": {}, "error": null}'
        '\n---END_DOBBY_RESULT---'
    )

    mock_proc = AsyncMock()
    mock_proc.stdout.readline = _make_readline_mock(mock_result_output.encode())
    mock_proc.returncode = 0
    mock_proc.pid = 1
    mock_proc.wait = AsyncMock()

    with patch("asyncio.create_subprocess_exec", return_value=mock_proc):
        await daemon.tick()

    updated = await daemon.store.get_task(task.id)
    assert updated.attempt_number == 1

    result = await daemon.store.get_result(task.id)
    assert result.attempt_number == 1


async def test_daemon_records_events_through_lifecycle(daemon):
    """Full lifecycle should produce events: created, running, completed."""
    project = Project(name="events-proj", repo="/tmp/test-repo")
    await daemon.store.register_project(project)

    task = Task(project="events-proj", description="Event test")
    await daemon.store.create_task(task)

    mock_result_output = (
        '---DOBBY_RESULT---\n'
        '{"status": "completed", "summary": "Done", "pr_url": null,'
        ' "files_changed": [], "metrics": {}, "error": null}'
        '\n---END_DOBBY_RESULT---'
    )

    mock_proc = AsyncMock()
    mock_proc.stdout.readline = _make_readline_mock(mock_result_output.encode())
    mock_proc.returncode = 0
    mock_proc.pid = 1
    mock_proc.wait = AsyncMock()

    with patch("asyncio.create_subprocess_exec", return_value=mock_proc):
        await daemon.tick()

    updated = await daemon.store.get_task(task.id)
    statuses = [e.status for e in updated.events]
    assert statuses == ["pending", "running", "completed"]


async def test_tick_calls_check_gpu_jobs(daemon):
    """tick() should call _check_gpu_jobs on every iteration."""
    with patch.object(daemon, "_check_gpu_jobs") as mock_check:
        await daemon.tick()  # No dispatchable tasks — just returns early
        mock_check.assert_called_once()


async def test_gpu_phase2_triggered_on_gpu_result(daemon):
    """When Phase 1 returns gpu_pid + log_path and task has infra, Phase 2 starts."""
    infra = InfraConfig(name="gpu-box", type="ssh", host="user@gpuhost")
    project = Project(name="gpu-proj", repo="/tmp/gpu-repo", infra=[infra])
    await daemon.store.register_project(project)

    task = Task(
        project="gpu-proj",
        description="Train model",
        infra="gpu-box",
    )
    await daemon.store.create_task(task)

    # Phase 1 result: agent launched training, reports PID and log path
    phase1_output = json.dumps({
        "status": "completed",
        "summary": "Training launched",
        "pr_url": None,
        "files_changed": [],
        "metrics": {"gpu_pid": 12345, "log_path": "/home/user/train.log"},
        "error": None,
    })
    mock_result = f'---DOBBY_RESULT---\n{phase1_output}\n---END_DOBBY_RESULT---'

    mock_proc = AsyncMock()
    mock_proc.stdout.readline = _make_readline_mock(mock_result.encode())
    mock_proc.returncode = 0
    mock_proc.pid = 1
    mock_proc.wait = AsyncMock()

    with (
        patch("asyncio.create_subprocess_exec", return_value=mock_proc),
        patch.object(daemon, "_start_gpu_monitor") as mock_start,
    ):
        await daemon.tick()
        mock_start.assert_called_once_with(
            task.id, "user@gpuhost", 12345, "/home/user/train.log", "Train model",
        )

    # Task should still be in running state (GPU monitoring continues)
    updated = await daemon.store.get_task(task.id)
    assert updated.status.value == "running"


async def test_gpu_monitor_worker_completes(daemon):
    """_gpu_monitor_worker polls, fetches log, parses, and updates task."""
    project = Project(name="gpu-proj2", repo="/tmp/gpu-repo2")
    await daemon.store.register_project(project)
    task = Task(project="gpu-proj2", description="Train model v2")
    await daemon.store.create_task(task)
    # Valid transition: pending -> running
    await daemon.store.update_task(task.id, status="running")

    # Mock GPU monitor methods
    daemon.gpu_monitor.wait_for_completion = AsyncMock(return_value=True)
    daemon.gpu_monitor.fetch_log = AsyncMock(return_value="epoch 10 loss=0.05\nTraining complete.")

    # Mock the Claude parse subprocess
    parse_output = (
        '---DOBBY_RESULT---\n'
        '{"status": "completed", "summary": "Training converged at loss=0.05", "pr_url": null,'
        ' "files_changed": [], "metrics": {"final_loss": 0.05}, "error": null}'
        '\n---END_DOBBY_RESULT---'
    )
    mock_proc = AsyncMock()
    mock_proc.communicate = AsyncMock(return_value=(parse_output.encode(), b""))
    mock_proc.returncode = 0

    with patch("asyncio.create_subprocess_exec", return_value=mock_proc):
        await daemon._gpu_monitor_worker(
            task.id, "user@gpuhost", 12345, "/home/user/train.log", "Train model v2",
        )

    # Task should be completed
    updated = await daemon.store.get_task(task.id)
    assert updated.status.value == "completed"

    # Result should have gpu_info
    result = await daemon.store.get_result(task.id)
    assert result is not None
    assert result.gpu_info["host"] == "user@gpuhost"
    assert result.gpu_info["pid"] == 12345
    assert result.summary == "Training converged at loss=0.05"


async def test_daemon_recurring_failure_auto_pause(daemon):
    """A recurring task should auto-pause after max_failures consecutive failures."""
    project = Project(name="fail-proj", repo="/tmp/fail-repo")
    await daemon.store.register_project(project)

    task = Task(
        project="fail-proj",
        description="Flaky job",
        schedule=Schedule(type=ScheduleType.INTERVAL, every="30m"),
        max_failures=2,
    )
    await daemon.store.create_task(task)

    mock_result_output = (
        '---DOBBY_RESULT---\n'
        '{"status": "failed", "summary": "Crash", "pr_url": null,'
        ' "files_changed": [], "metrics": {}, "error": "boom"}'
        '\n---END_DOBBY_RESULT---'
    )

    mock_proc = AsyncMock()
    mock_proc.stdout.readline = _make_readline_mock(mock_result_output.encode())
    mock_proc.returncode = 1
    mock_proc.pid = 1
    mock_proc.wait = AsyncMock()

    # First failure
    with patch("asyncio.create_subprocess_exec", return_value=mock_proc):
        await daemon.tick()

    updated = await daemon.store.get_task(task.id)
    assert updated.status == TaskStatus.ACTIVE
    assert updated.consecutive_failures == 1

    # Make the task due again by setting next_run_at/last_run_at in the past
    past = datetime.now(UTC) - timedelta(hours=1)
    await daemon.store.update_task(
        task.id,
        last_run_at=past.isoformat(),
        next_run_at=past.isoformat(),
    )

    # Second failure — should trigger auto-pause
    mock_proc2 = AsyncMock()
    mock_proc2.stdout.readline = _make_readline_mock(mock_result_output.encode())
    mock_proc2.returncode = 1
    mock_proc2.pid = 2
    mock_proc2.wait = AsyncMock()

    with patch("asyncio.create_subprocess_exec", return_value=mock_proc2):
        await daemon.tick()

    updated = await daemon.store.get_task(task.id)
    assert updated.status == TaskStatus.PAUSED
    assert updated.consecutive_failures == 2


async def test_daemon_recurring_success_resets_failures(daemon):
    """A successful run should reset consecutive_failures to 0."""
    project = Project(name="reset-proj", repo="/tmp/reset-repo")
    await daemon.store.register_project(project)

    task = Task(
        project="reset-proj",
        description="Eventually works",
        schedule=Schedule(type=ScheduleType.INTERVAL, every="30m"),
    )
    await daemon.store.create_task(task)

    # First: a failure
    fail_output = (
        '---DOBBY_RESULT---\n'
        '{"status": "failed", "summary": "Fail", "pr_url": null,'
        ' "files_changed": [], "metrics": {}, "error": "err"}'
        '\n---END_DOBBY_RESULT---'
    )
    mock_proc = AsyncMock()
    mock_proc.stdout.readline = _make_readline_mock(fail_output.encode())
    mock_proc.returncode = 1
    mock_proc.pid = 1
    mock_proc.wait = AsyncMock()

    with patch("asyncio.create_subprocess_exec", return_value=mock_proc):
        await daemon.tick()

    updated = await daemon.store.get_task(task.id)
    assert updated.consecutive_failures == 1

    # Make the task due again
    past = datetime.now(UTC) - timedelta(hours=1)
    await daemon.store.update_task(
        task.id,
        last_run_at=past.isoformat(),
        next_run_at=past.isoformat(),
    )

    # Second: a success
    success_output = (
        '---DOBBY_RESULT---\n'
        '{"status": "completed", "summary": "Done", "pr_url": null,'
        ' "files_changed": [], "metrics": {}, "error": null}'
        '\n---END_DOBBY_RESULT---'
    )
    mock_proc2 = AsyncMock()
    mock_proc2.stdout.readline = _make_readline_mock(success_output.encode())
    mock_proc2.returncode = 0
    mock_proc2.pid = 2
    mock_proc2.wait = AsyncMock()

    with patch("asyncio.create_subprocess_exec", return_value=mock_proc2):
        await daemon.tick()

    updated = await daemon.store.get_task(task.id)
    assert updated.consecutive_failures == 0
    assert updated.status == TaskStatus.ACTIVE


async def test_daemon_sets_next_run_at_for_recurring(daemon):
    """After running a recurring task, next_run_at should be set."""
    project = Project(name="nextrun-proj", repo="/tmp/nextrun-repo")
    await daemon.store.register_project(project)

    task = Task(
        project="nextrun-proj",
        description="Interval task",
        schedule=Schedule(type=ScheduleType.INTERVAL, every="1h"),
    )
    await daemon.store.create_task(task)

    mock_result_output = (
        '---DOBBY_RESULT---\n'
        '{"status": "completed", "summary": "Done", "pr_url": null,'
        ' "files_changed": [], "metrics": {}, "error": null}'
        '\n---END_DOBBY_RESULT---'
    )
    mock_proc = AsyncMock()
    mock_proc.stdout.readline = _make_readline_mock(mock_result_output.encode())
    mock_proc.returncode = 0
    mock_proc.pid = 1
    mock_proc.wait = AsyncMock()

    with patch("asyncio.create_subprocess_exec", return_value=mock_proc):
        await daemon.tick()

    updated = await daemon.store.get_task(task.id)
    assert updated.next_run_at is not None
    assert updated.last_run_at is not None
    # next_run_at should be roughly 1h after last_run_at
    diff = (updated.next_run_at - updated.last_run_at).total_seconds()
    assert 3590 <= diff <= 3610


# ---------------------------------------------------------------------------
# Stale-run recovery tests
# ---------------------------------------------------------------------------

async def test_stale_task_detected_and_failed(daemon):
    """A running task with no log updates and dead process should be marked failed."""
    project = Project(name="stale-proj", repo="/tmp/stale-repo")
    await daemon.store.register_project(project)

    task = Task(project="stale-proj", description="Stale task")
    await daemon.store.create_task(task)
    await daemon.store.update_task(task.id, status="running")

    # Set stale timeout very short for testing
    daemon.config.stale_timeout = 1

    # Create a log file with old mtime
    log_dir = os.path.join(daemon.config.results_dir, task.id)
    os.makedirs(log_dir, exist_ok=True)
    log_path = os.path.join(log_dir, "agent.log")
    with open(log_path, "w") as f:
        f.write("some log output\n")
    # Set mtime to 2 hours ago
    old_time = (datetime.now(UTC) - timedelta(hours=2)).timestamp()
    os.utime(log_path, (old_time, old_time))

    # No running PID tracked → process is "dead"
    await daemon._check_stale_tasks()

    updated = await daemon.store.get_task(task.id)
    assert updated.status == TaskStatus.FAILED


async def test_stale_task_skipped_if_process_alive(daemon):
    """A stale task whose process is still alive should NOT be marked failed."""
    project = Project(name="alive-proj", repo="/tmp/alive-repo")
    await daemon.store.register_project(project)

    task = Task(project="alive-proj", description="Still running")
    await daemon.store.create_task(task)
    await daemon.store.update_task(task.id, status="running")

    daemon.config.stale_timeout = 1

    log_dir = os.path.join(daemon.config.results_dir, task.id)
    os.makedirs(log_dir, exist_ok=True)
    log_path = os.path.join(log_dir, "agent.log")
    with open(log_path, "w") as f:
        f.write("output\n")
    old_time = (datetime.now(UTC) - timedelta(hours=2)).timestamp()
    os.utime(log_path, (old_time, old_time))

    # Simulate a live process by setting a PID that is actually alive (our own PID)
    daemon.dispatcher.running_pids[task.id] = os.getpid()

    await daemon._check_stale_tasks()

    updated = await daemon.store.get_task(task.id)
    assert updated.status == TaskStatus.RUNNING  # not marked failed


async def test_stale_task_not_triggered_when_recent(daemon):
    """A running task with recent log updates should not be marked stale."""
    project = Project(name="recent-proj", repo="/tmp/recent-repo")
    await daemon.store.register_project(project)

    task = Task(project="recent-proj", description="Active task")
    await daemon.store.create_task(task)
    await daemon.store.update_task(task.id, status="running")

    daemon.config.stale_timeout = 3600  # 1 hour

    # Create a fresh log file (default mtime is now)
    log_dir = os.path.join(daemon.config.results_dir, task.id)
    os.makedirs(log_dir, exist_ok=True)
    with open(os.path.join(log_dir, "agent.log"), "w") as f:
        f.write("recent output\n")

    await daemon._check_stale_tasks()

    updated = await daemon.store.get_task(task.id)
    assert updated.status == TaskStatus.RUNNING


# ---------------------------------------------------------------------------
# Webhook notification tests
# ---------------------------------------------------------------------------

async def test_fire_webhook_posts_payload(daemon):
    """_fire_webhook should POST to the configured webhook URL."""
    daemon.config.webhook_url = "http://example.com/webhook"

    task_dict = {"id": "t-123", "project": "test", "description": "Test task"}
    result_dict = {"status": "completed", "summary": "Done"}

    with patch("aiohttp.ClientSession") as mock_session_cls:
        mock_resp = AsyncMock()
        mock_resp.status = 200
        mock_post = AsyncMock(return_value=AsyncMock(
            __aenter__=AsyncMock(return_value=mock_resp), __aexit__=AsyncMock()
        ))
        mock_session = AsyncMock()
        mock_session.post = mock_post
        mock_session_cls.return_value = AsyncMock(
            __aenter__=AsyncMock(return_value=mock_session), __aexit__=AsyncMock()
        )

        await daemon._fire_webhook(task_dict, result_dict)


async def test_fire_webhook_noop_without_url(daemon):
    """_fire_webhook should do nothing if webhook_url is not configured."""
    daemon.config.webhook_url = None
    # Should not raise
    await daemon._fire_webhook({"id": "t-1"}, {"status": "completed"})


async def test_notify_task_outcome_publishes_sse(daemon):
    """_notify_task_outcome should publish to the event bus."""
    project = Project(name="sse-proj", repo="/tmp/sse")
    await daemon.store.register_project(project)
    task = Task(project="sse-proj", description="SSE test")
    await daemon.store.create_task(task)

    result = Result(task_id=task.id, status="completed", summary="Done")

    q = daemon.event_bus.subscribe()
    await daemon._notify_task_outcome(task, result)

    msg = q.get_nowait()
    assert msg["event"] == "task_status"
    assert msg["data"]["task_id"] == task.id
    assert msg["data"]["status"] == "completed"
    daemon.event_bus.unsubscribe(q)


async def test_tick_fires_notifications_on_completion(daemon):
    """tick() should fire notifications when a one-off task completes."""
    project = Project(name="notify-proj", repo="/tmp/notify-repo")
    await daemon.store.register_project(project)

    task = Task(project="notify-proj", description="Notify test")
    await daemon.store.create_task(task)

    mock_result_output = (
        '---DOBBY_RESULT---\n'
        '{"status": "completed", "summary": "Done!", "pr_url": null,'
        ' "files_changed": [], "metrics": {}, "error": null}'
        '\n---END_DOBBY_RESULT---'
    )

    mock_proc = AsyncMock()
    mock_proc.stdout.readline = _make_readline_mock(mock_result_output.encode())
    mock_proc.returncode = 0
    mock_proc.pid = 1
    mock_proc.wait = AsyncMock()

    q = daemon.event_bus.subscribe()

    with patch("asyncio.create_subprocess_exec", return_value=mock_proc):
        await daemon.tick()

    # Check that SSE event was published
    events = []
    while not q.empty():
        events.append(q.get_nowait())

    task_events = [e for e in events if e["event"] == "task_status"]
    assert len(task_events) >= 1
    assert any(e["data"]["status"] == "completed" for e in task_events)
    daemon.event_bus.unsubscribe(q)
