"""Tests for VPS deployment, remote dispatch, and dashboard modularization."""

import json
import os
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from dobby.deploy.manager import DeployManager
from dobby.deploy.vultr import VPSInfo, VultrProvisioner
from dobby.models import Project, Task

# ---------------------------------------------------------------------------
# VPSInfo tests
# ---------------------------------------------------------------------------


def test_vps_info_roundtrip():
    vps = VPSInfo(
        instance_id="abc-123",
        label="dobby-vps",
        ip="1.2.3.4",
        region="ewr",
        plan="vc2-1c-1gb",
        os_name="Ubuntu 24.04",
        status="active",
    )
    d = vps.to_dict()
    restored = VPSInfo.from_dict(d)
    assert restored.instance_id == "abc-123"
    assert restored.ip == "1.2.3.4"
    assert restored.label == "dobby-vps"
    assert restored.status == "active"


def test_vps_info_from_dict_defaults():
    d = {"instance_id": "x", "label": "l", "ip": "0.0.0.0", "region": "ewr", "plan": "p"}
    vps = VPSInfo.from_dict(d)
    assert vps.os_name == ""
    assert vps.status == "unknown"


# ---------------------------------------------------------------------------
# VultrProvisioner tests
# ---------------------------------------------------------------------------


def test_vultr_provisioner_requires_api_key():
    with patch.dict(os.environ, {}, clear=True):
        # Remove any VULTR_API_KEY from env
        os.environ.pop("VULTR_API_KEY", None)
        with pytest.raises(ValueError, match="Vultr API key required"):
            VultrProvisioner(api_key="")


def test_vultr_provisioner_uses_env_key():
    with patch.dict(os.environ, {"VULTR_API_KEY": "test-key-123"}):
        prov = VultrProvisioner()
        assert prov.api_key == "test-key-123"


def test_vultr_provisioner_explicit_key():
    prov = VultrProvisioner(api_key="explicit-key")
    assert prov.api_key == "explicit-key"


async def test_vultr_provision_makes_api_call():
    prov = VultrProvisioner(api_key="test-key")

    mock_response = {
        "instance": {
            "id": "inst-001",
            "main_ip": "pending",
            "status": "pending",
        }
    }

    with patch.object(prov, "_request", new_callable=AsyncMock, return_value=mock_response):
        vps = await prov.provision(label="test-vps")

    assert vps.instance_id == "inst-001"
    assert vps.label == "test-vps"
    assert vps.status == "pending"


async def test_vultr_destroy():
    prov = VultrProvisioner(api_key="test-key")
    with patch.object(prov, "_request", new_callable=AsyncMock, return_value={}) as mock_req:
        await prov.destroy("inst-001")
    mock_req.assert_called_once_with("DELETE", "/instances/inst-001")


async def test_vultr_get_instance():
    prov = VultrProvisioner(api_key="test-key")
    mock_response = {
        "instance": {
            "label": "dobby-vps",
            "main_ip": "1.2.3.4",
            "region": "ewr",
            "plan": "vc2-1c-1gb",
            "os": "Ubuntu 24.04",
            "status": "active",
        }
    }
    with patch.object(prov, "_request", new_callable=AsyncMock, return_value=mock_response):
        vps = await prov.get_instance("inst-001")

    assert vps.ip == "1.2.3.4"
    assert vps.status == "active"


async def test_vultr_wait_for_active():
    prov = VultrProvisioner(api_key="test-key")

    # First call: still pending. Second call: active.
    responses = [
        {"instance": {
            "id": "i1", "main_ip": "0.0.0.0", "status": "pending",
            "power_status": "stopped", "label": "", "region": "", "plan": "", "os": "",
        }},
        {"instance": {
            "id": "i1", "main_ip": "1.2.3.4", "status": "active",
            "power_status": "running", "label": "test", "region": "ewr", "plan": "p",
            "os": "Ubuntu",
        }},
    ]

    with patch.object(prov, "_request", new_callable=AsyncMock, side_effect=responses):
        vps = await prov.wait_for_active("i1", timeout=10, poll_interval=0.1)

    assert vps.ip == "1.2.3.4"
    assert vps.status == "active"


# ---------------------------------------------------------------------------
# DeployManager tests
# ---------------------------------------------------------------------------


def test_deploy_manager_save_and_load_vps(tmp_path):
    config_path = str(tmp_path / "dobby.yaml")
    mgr = DeployManager(config_path=config_path)

    vps = VPSInfo(
        instance_id="abc-123",
        label="dobby-vps",
        ip="1.2.3.4",
        region="ewr",
        plan="vc2-1c-1gb",
        os_name="Ubuntu 24.04",
        status="active",
    )
    mgr.save_vps_info(vps)

    loaded = mgr.get_vps_info()
    assert loaded is not None
    assert loaded.instance_id == "abc-123"
    assert loaded.ip == "1.2.3.4"


def test_deploy_manager_remove_vps(tmp_path):
    config_path = str(tmp_path / "dobby.yaml")
    mgr = DeployManager(config_path=config_path)

    vps = VPSInfo("id", "label", "1.2.3.4", "ewr", "plan", "os", "active")
    mgr.save_vps_info(vps)
    mgr.remove_vps_info()

    assert mgr.get_vps_info() is None


def test_deploy_manager_no_config(tmp_path):
    config_path = str(tmp_path / "nonexistent.yaml")
    mgr = DeployManager(config_path=config_path)
    assert mgr.get_vps_info() is None


async def test_deploy_manager_provision_blocks_if_existing(tmp_path):
    config_path = str(tmp_path / "dobby.yaml")
    mgr = DeployManager(config_path=config_path)

    # Save existing VPS info
    vps = VPSInfo("old-id", "old", "1.1.1.1", "ewr", "plan", "os", "active")
    mgr.save_vps_info(vps)

    with pytest.raises(RuntimeError, match="VPS already provisioned"):
        await mgr.provision()


# ---------------------------------------------------------------------------
# Project dispatch_to tests
# ---------------------------------------------------------------------------


def test_project_dispatch_to_default():
    proj = Project(name="test", repo="/tmp/repo")
    assert proj.dispatch_to == "local"
    assert proj.is_remote is False
    assert proj.remote_host is None


def test_project_dispatch_to_remote():
    proj = Project(name="test", repo="/tmp/repo", dispatch_to="remote:1.2.3.4")
    assert proj.is_remote is True
    assert proj.remote_host == "1.2.3.4"


def test_project_dispatch_to_roundtrip():
    proj = Project(name="test", repo="/tmp/repo", dispatch_to="remote:myhost.com")
    d = proj.to_dict()
    assert d["dispatch_to"] == "remote:myhost.com"

    restored = Project.from_dict(d)
    assert restored.dispatch_to == "remote:myhost.com"
    assert restored.is_remote is True
    assert restored.remote_host == "myhost.com"


def test_project_from_dict_no_dispatch_to():
    """Projects created before dispatch_to was added should default to 'local'."""
    d = {"name": "old", "repo": "/tmp/old"}
    proj = Project.from_dict(d)
    assert proj.dispatch_to == "local"
    assert proj.is_remote is False


# ---------------------------------------------------------------------------
# Remote dispatch tests
# ---------------------------------------------------------------------------


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
def dispatcher(tmp_path):
    from dobby.core.dispatcher import Dispatcher
    return Dispatcher(
        max_concurrent=2,
        default_timeout=60,
        results_dir=str(tmp_path / "results"),
    )


async def test_dispatch_routes_to_local_for_local_project(dispatcher):
    task = Task(project="test", description="Test")
    project = Project(name="test", repo="/tmp/repo", dispatch_to="local")

    with patch.object(dispatcher, "_dispatch_local", new_callable=AsyncMock) as mock_local:
        mock_local.return_value = MagicMock()
        await dispatcher.dispatch(task, project)

    mock_local.assert_called_once()


async def test_dispatch_routes_to_remote_for_remote_project(dispatcher):
    task = Task(project="test", description="Test")
    project = Project(name="test", repo="/tmp/repo", dispatch_to="remote:1.2.3.4")

    with patch.object(dispatcher, "_dispatch_remote", new_callable=AsyncMock) as mock_remote:
        mock_remote.return_value = MagicMock()
        await dispatcher.dispatch(task, project)

    mock_remote.assert_called_once()


async def test_remote_dispatch_uses_ssh(dispatcher):
    task = Task(project="test", description="Test")
    project = Project(name="test", repo="/tmp/repo", dispatch_to="remote:1.2.3.4")

    mock_result = json.dumps({
        "result": (
            "---DOBBY_RESULT---\n"
            '{"status": "completed", "summary": "Done", "pr_url": null,'
            ' "files_changed": [], "metrics": {}, "error": null}'
            "\n---END_DOBBY_RESULT---"
        ),
        "cost_usd": 0.10,
        "input_tokens": 1000,
        "output_tokens": 500,
    })

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

    with patch("asyncio.create_subprocess_exec", return_value=mock_proc) as mock_exec:
        result = await dispatcher.dispatch(task, project)

    assert result.status == "completed"
    # Verify SSH was used
    call_args = mock_exec.call_args[0]
    assert call_args[0] == "ssh"
    # Verify the host is in the SSH args
    assert "root@1.2.3.4" in call_args


async def test_remote_dispatch_timeout(dispatcher):
    import asyncio as aio

    task = Task(project="test", description="Test", timeout=1)
    project = Project(name="test", repo="/tmp/repo", dispatch_to="remote:1.2.3.4")

    async def slow_readline():
        await aio.sleep(10)
        return b""

    mock_proc = AsyncMock()
    mock_proc.stdout.readline = slow_readline
    mock_proc.returncode = 1
    mock_proc.pid = 99999
    mock_proc.wait = AsyncMock()
    mock_proc.kill = MagicMock()

    with patch("asyncio.create_subprocess_exec", return_value=mock_proc):
        result = await dispatcher._dispatch_remote(task, project, timeout=1)

    assert result.status == "failed"
    assert "timed out" in result.summary


# ---------------------------------------------------------------------------
# Dashboard modularization tests
# ---------------------------------------------------------------------------


def test_dashboard_html_references_static_files():
    """Verify the dashboard HTML links to external CSS and JS."""
    html_path = os.path.join(
        os.path.dirname(os.path.dirname(__file__)),
        "dobby", "web", "templates", "dashboard.html",
    )
    with open(html_path) as f:
        html = f.read()

    assert '<link rel="stylesheet" href="/static/dashboard.css">' in html
    assert '<script src="/static/dashboard.js"></script>' in html
    # Should NOT contain inline <style> or <script> blocks with substantial content
    assert "<style>" not in html


def test_dashboard_css_exists():
    css_path = os.path.join(
        os.path.dirname(os.path.dirname(__file__)),
        "dobby", "web", "static", "dashboard.css",
    )
    assert os.path.exists(css_path)
    with open(css_path) as f:
        css = f.read()
    assert len(css) > 1000  # Should have substantial CSS
    assert "--bg:" in css  # Check for CSS variable


def test_dashboard_js_exists():
    js_path = os.path.join(
        os.path.dirname(os.path.dirname(__file__)),
        "dobby", "web", "static", "dashboard.js",
    )
    assert os.path.exists(js_path)
    with open(js_path) as f:
        js = f.read()
    assert len(js) > 1000  # Should have substantial JS
    assert "'use strict'" in js


# ---------------------------------------------------------------------------
# Dashboard static serving tests
# ---------------------------------------------------------------------------


async def test_static_route_serves_css():
    from aiohttp import web
    from aiohttp.test_utils import TestClient, TestServer

    from dobby.web.dashboard import setup_dashboard

    app = web.Application()
    setup_dashboard(app)

    async with TestClient(TestServer(app)) as client:
        resp = await client.get("/static/dashboard.css")
        assert resp.status == 200
        text = await resp.text()
        assert "--bg:" in text


async def test_static_route_serves_js():
    from aiohttp import web
    from aiohttp.test_utils import TestClient, TestServer

    from dobby.web.dashboard import setup_dashboard

    app = web.Application()
    setup_dashboard(app)

    async with TestClient(TestServer(app)) as client:
        resp = await client.get("/static/dashboard.js")
        assert resp.status == 200
        text = await resp.text()
        assert "'use strict'" in text


async def test_static_route_404_for_missing():
    from aiohttp import web
    from aiohttp.test_utils import TestClient, TestServer

    from dobby.web.dashboard import setup_dashboard

    app = web.Application()
    setup_dashboard(app)

    async with TestClient(TestServer(app)) as client:
        resp = await client.get("/static/nonexistent.css")
        assert resp.status == 404


async def test_static_route_forbids_traversal():
    from aiohttp import web
    from aiohttp.test_utils import TestClient, TestServer

    from dobby.web.dashboard import setup_dashboard

    app = web.Application()
    setup_dashboard(app)

    async with TestClient(TestServer(app)) as client:
        resp = await client.get("/static/../templates/dashboard.html")
        # aiohttp won't match this route since .. contains /
        assert resp.status in (403, 404)


async def test_dashboard_page_serves_html():
    from aiohttp import web
    from aiohttp.test_utils import TestClient, TestServer

    from dobby.web.dashboard import setup_dashboard

    app = web.Application()
    setup_dashboard(app)

    async with TestClient(TestServer(app)) as client:
        resp = await client.get("/dashboard")
        assert resp.status == 200
        text = await resp.text()
        assert "Dobby" in text
        assert "/static/dashboard.css" in text
