"""Runtime configuration route tests."""

from __future__ import annotations

from dataclasses import replace
from pathlib import Path

import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker

from app.api.routes import assistant as assistant_routes
from app.api.routes import configuration as config_routes
from app.core.db import get_db
from app.models.base import Base
from app.models.vault import VaultChunk, VaultFile, VaultPath  # noqa: F401
from app.services.ai import provider_registry as ai_provider_registry
from app.services import assistant_service
from app.services import runtime_config
from app.services import vault as vault_service
from app.services.voice_runtime import voice_runtime


@pytest.fixture()
def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient:
    """Create isolated test app with temporary DB and config path."""
    db_path = tmp_path / "config_test.db"
    engine = create_engine(
        f"sqlite:///{db_path}",
        connect_args={"check_same_thread": False},
    )
    SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)

    Base.metadata.create_all(bind=engine)
    vault_service.ensure_vault_fts(engine)

    config_path = tmp_path / ".sentineledge" / "config.json"
    monkeypatch.setenv("SENTINELEDGE_CONFIG_PATH", str(config_path))
    runtime_config.reset_runtime_state()
    voice_runtime.reset()
    monkeypatch.setattr(
        assistant_service,
        "settings",
        replace(assistant_service.settings, gemini_api_key=None),
    )
    patched_settings = replace(
        runtime_config.settings,
        ai_provider="gemini",
        gemini_api_key=None,
        ai_allow_fallback=False,
        ollama_model="llama3.2:3b",
    )
    monkeypatch.setattr(runtime_config, "settings", patched_settings)
    monkeypatch.setattr(ai_provider_registry, "settings", replace(patched_settings))

    app = FastAPI()
    app.include_router(config_routes.router)
    app.include_router(assistant_routes.router)

    def override_get_db():
        db = SessionLocal()
        try:
            yield db
        finally:
            db.close()

    app.dependency_overrides[get_db] = override_get_db

    with TestClient(app) as test_client:
        yield test_client

    app.dependency_overrides.clear()
    runtime_config.reset_runtime_state()
    voice_runtime.reset()
    engine.dispose()


def test_config_state_and_skip(client: TestClient) -> None:
    """Missing config transitions to skipped state when requested."""
    state_before = client.get("/config/state")
    assert state_before.status_code == 200
    assert state_before.json()["status"] == "missing"
    assert state_before.json()["language"] == "es"
    assert state_before.json()["assistant_default_language"] == "es"
    assert state_before.json()["language_configured"] is False

    skip_response = client.post("/config/skip", json={})
    assert skip_response.status_code == 200
    assert skip_response.json()["state"]["status"] == "skipped"

    state_after = client.get("/config/state")
    assert state_after.status_code == 200
    assert state_after.json()["status"] == "skipped"


def test_save_config_persists_and_reports_warnings(client: TestClient, tmp_path: Path) -> None:
    """Saving config persists token and paths while returning non-blocking warnings."""
    existing_path = tmp_path / "vault"
    existing_path.mkdir(parents=True, exist_ok=True)
    missing_path = tmp_path / "vault-missing"

    response = client.post(
        "/config/save",
        json={
            "todoist_api_token": " short token ",
            "vault_paths": [str(existing_path), str(missing_path)],
        },
    )
    assert response.status_code == 200
    payload = response.json()
    assert payload["ok"] is True
    assert payload["state"]["status"] == "configured"
    assert payload["state"]["has_todoist_token"] is True
    assert payload["state"]["vault_paths_count"] == 2
    assert payload["state"]["language"] == "es"
    assert payload["state"]["assistant_default_language"] == "es"
    assert payload["state"]["language_configured"] is False
    assert payload["warnings"]

    saved_file = runtime_config.config_file_path()
    assert saved_file.exists()


def test_assistant_requires_config_for_todoist_and_vault_when_missing(client: TestClient) -> None:
    """TODOIST and VAULT intents should guide user to Config without setup."""
    todoist_query = client.post("/assistant/query", json={"message": "What tasks do I have today?"})
    assert todoist_query.status_code == 200
    todoist_payload = todoist_query.json()
    assert todoist_payload["intent"] == "TASKS"
    assert ("Config" in todoist_payload["response"]) or ("Configuración" in todoist_payload["response"])

    vault_query = client.post("/assistant/query", json={"message": "según mis notas del vault, resume kerberos"})
    assert vault_query.status_code == 200
    vault_payload = vault_query.json()
    assert vault_payload["intent"] == "VAULT"
    assert "Configuración" in vault_payload["response"]


def test_language_can_be_saved_independently_and_controls_ambiguous_fallback(client: TestClient) -> None:
    """Language-only save should persist independently and control ambiguous reply language."""
    save_language = client.post("/config/save", json={"language": "en"})
    assert save_language.status_code == 200
    payload = save_language.json()
    assert payload["state"]["language"] == "en"
    assert payload["state"]["assistant_default_language"] == "en"
    assert payload["state"]["language_configured"] is True
    assert payload["state"]["has_todoist_token"] is False
    assert payload["state"]["vault_paths_count"] == 0

    ambiguous_query_en = client.post("/assistant/query", json={"message": "Ping"})
    assert ambiguous_query_en.status_code == 200
    assert "Assistant is not configured" in ambiguous_query_en.json()["response"]

    save_language_es = client.post("/config/save", json={"language": "es"})
    assert save_language_es.status_code == 200
    ambiguous_query_es = client.post("/assistant/query", json={"message": "Ping"})
    assert ambiguous_query_es.status_code == 200
    assert "Asistente no configurado" in ambiguous_query_es.json()["response"]


def test_ai_provider_settings_save_and_switch_independently(client: TestClient) -> None:
    """AI provider config should persist independently and support switching."""
    save_ollama = client.post(
        "/config/save",
        json={
            "ai_provider": "ollama",
            "ollama_model": "llama3.2:3b",
            "allow_fallback": True,
        },
    )
    assert save_ollama.status_code == 200
    ollama_state = save_ollama.json()["state"]
    assert ollama_state["ai_provider"] == "ollama"
    assert ollama_state["ollama_model"] == "llama3.2:3b"
    assert ollama_state["allow_fallback"] is True

    save_gemini = client.post(
        "/config/save",
        json={
            "ai_provider": "gemini",
            "gemini_api_key": "test-gemini-key",
            "gemini_model": "gemini-2.5-flash",
        },
    )
    assert save_gemini.status_code == 200
    gemini_state = save_gemini.json()["state"]
    assert gemini_state["ai_provider"] == "gemini"
    assert gemini_state["has_gemini_api_key"] is True
    assert gemini_state["gemini_model"] == "gemini-2.5-flash"


def test_todoist_and_vault_save_independently_without_clearing_other_sections(client: TestClient, tmp_path: Path) -> None:
    """Saving one section should preserve previously saved values from other sections."""
    vault_dir = tmp_path / "vault"
    vault_dir.mkdir(parents=True, exist_ok=True)

    save_todoist = client.post("/config/save", json={"todoist_api_token": "todoist_token_placeholder"})
    assert save_todoist.status_code == 200
    todoist_state = save_todoist.json()["state"]
    assert todoist_state["has_todoist_token"] is True
    assert todoist_state["vault_paths_count"] == 0

    save_vault = client.post("/config/save", json={"vault_paths": [str(vault_dir)]})
    assert save_vault.status_code == 200
    merged_state = save_vault.json()["state"]
    assert merged_state["has_todoist_token"] is True
    assert merged_state["vault_paths_count"] == 1


def test_ai_test_endpoint_returns_provider_status(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
    """AI test route should proxy provider test result for config UI."""
    monkeypatch.setattr(
        config_routes.ai_provider_registry,
        "test_provider",
        lambda **kwargs: {"provider": "ollama", "ok": False, "detail": "not detected"},
    )
    response = client.post("/config/ai/test", json={"provider": "ollama", "ollama_model": "llama3.2:3b"})
    assert response.status_code == 200
    payload = response.json()
    assert payload["provider"] == "ollama"
    assert payload["ok"] is False


def test_ai_test_endpoint_forwards_gemini_model(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
    """AI test route must pass Gemini model from UI payload."""
    seen: dict[str, object] = {}

    def _fake_test_provider(**kwargs):
        seen.update(kwargs)
        return {"provider": "gemini", "ok": True, "detail": "ok"}

    monkeypatch.setattr(config_routes.ai_provider_registry, "test_provider", _fake_test_provider)
    response = client.post(
        "/config/ai/test",
        json={"provider": "gemini", "gemini_api_key": "key", "gemini_model": "gemini-2.5-flash"},
    )
    assert response.status_code == 200
    assert seen["provider_id"] == "gemini"
    assert seen["gemini_api_key"] == "key"
    assert seen["gemini_model"] == "gemini-2.5-flash"


def test_config_page_renders_with_saved_language(client: TestClient) -> None:
    """Config UI should render in the language persisted in runtime config."""
    save_en = client.post("/config/save", json={"language": "en"})
    assert save_en.status_code == 200

    en_page = client.get("/config")
    assert en_page.status_code == 200
    assert "Assistant Default Language" in en_page.text

    save_es = client.post("/config/save", json={"language": "es"})
    assert save_es.status_code == 200

    es_page = client.get("/config")
    assert es_page.status_code == 200
    assert "Idioma Predeterminado del Asistente" in es_page.text


def test_config_page_uses_per_card_result_areas_without_global_banner(client: TestClient) -> None:
    """Config UI should render card-scoped result areas instead of a shared bottom banner."""
    page = client.get("/config")
    assert page.status_code == 200
    html = page.text

    assert 'id="result-ai"' in html
    assert 'id="result-todoist"' in html
    assert 'id="result-vault_local"' in html
    assert 'id="result-google_tasks"' in html
    assert 'id="result-microsoft_todo"' in html
    assert 'id="result-trello"' in html
    assert 'id="result-github_issues"' in html
    assert 'id="result-notion"' in html
    assert 'id="gemini-model-input"' in html
    assert 'id="result-gemini-test"' in html
    assert 'id="result-ollama-test"' in html
    assert 'id="messages"' not in html

    embedded = client.get("/config?embedded=dashboard")
    assert embedded.status_code == 200
    assert 'id="result-ai"' in embedded.text
    assert 'id="messages"' not in embedded.text


def test_ui_templates_include_startup_overlay_failsafe(client: TestClient) -> None:
    """Assistant/config templates should include startup overlay hard-failsafe hooks."""
    config_page = client.get("/config")
    assert config_page.status_code == 200
    assert "__orionStartupReveal" in config_page.text
    assert "hard-failsafe" in config_page.text
    assert "unhandledrejection" in config_page.text

    assistant_page = client.get("/assistant/ui")
    assert assistant_page.status_code == 200
    assert "__orionStartupReveal" in assistant_page.text
    assert "hard-failsafe" in assistant_page.text
    assert "unhandledrejection" in assistant_page.text


def test_assistant_ui_settings_button_is_localized(client: TestClient) -> None:
    """Assistant settings button label/aria should follow configured language."""
    assert client.post("/config/save", json={"language": "en"}).status_code == 200
    ui_en = client.get("/assistant/ui")
    assert ui_en.status_code == 200
    assert "Settings" in ui_en.text
    assert "Open settings" in ui_en.text

    assert client.post("/config/save", json={"language": "es"}).status_code == 200
    ui_es = client.get("/assistant/ui")
    assert ui_es.status_code == 200
    assert "Configuración" in ui_es.text
    assert "Abrir configuración" in ui_es.text


def test_ui_mode_switch_pauses_and_resumes_voice_capture(client: TestClient) -> None:
    """UI mode endpoint should toggle voice pause state."""
    pause_response = client.post("/assistant/ui-mode", json={"mode": "config"})
    assert pause_response.status_code == 200
    assert pause_response.json()["mode"] == "config"
    assert pause_response.json()["voice_paused"] is True
    paused_state = client.get("/assistant/ui-mode")
    assert paused_state.status_code == 200
    assert paused_state.json()["mode"] == "config"
    assert paused_state.json()["voice_paused"] is True

    resume_response = client.post("/assistant/ui-mode", json={"mode": "assistant"})
    assert resume_response.status_code == 200
    assert resume_response.json()["mode"] == "assistant"
    assert resume_response.json()["voice_paused"] is False
    resumed_state = client.get("/assistant/ui-mode")
    assert resumed_state.status_code == 200
    assert resumed_state.json()["mode"] == "assistant"
    assert resumed_state.json()["voice_paused"] is False


def test_assistant_ui_render_after_config_forces_idle_unlock(client: TestClient) -> None:
    """Returning to assistant UI from config mode should clear paused lock."""
    assert client.post("/assistant/ui-mode", json={"mode": "config"}).status_code == 200
    page = client.get("/assistant/ui")
    assert page.status_code == 200

    query_response = client.post("/assistant/query", json={"message": "hello after config"})
    assert query_response.status_code == 200
    assert "response" in query_response.json()


def test_assistant_ui_prompt_and_status_routes(client: TestClient) -> None:
    """Assistant UI routes should expose voice worker status and prompt queue."""
    status_response = client.get("/assistant/ui/voice-status")
    assert status_response.status_code == 200
    payload = status_response.json()
    assert "state" in payload
    assert "backend" in payload
    assert "microphone_available" in payload
    assert payload["worker_active"] is False

    voice_runtime.set_worker_active(True)

    queue_response = client.post("/assistant/ui/prompt", json={"message": "hello from ui"})
    assert queue_response.status_code == 200
    assert queue_response.json()["ok"] is True
    assert voice_runtime.pop_typed_prompt() == "hello from ui"


def test_assistant_ui_prompt_rejects_when_runtime_is_not_running(client: TestClient) -> None:
    """Prompt queue endpoint must fail with explicit 503 when worker is not running."""
    queue_response = client.post("/assistant/ui/prompt", json={"message": "should fail"})
    assert queue_response.status_code == 503
    assert "runtime not running" in queue_response.json()["detail"]


def test_assistant_markdown_render_endpoint_formats_markdown(client: TestClient) -> None:
    """Assistant markdown renderer should convert markdown syntax into HTML."""
    payload = {
        "text": "# Heading\n\n**Bold text**\n- Item 1\n- Item 2\n\nLine A\nLine B\n\nInline `code`.\n\n```python\nprint('ok')\n```",
    }
    response = client.post("/assistant/render-markdown", json=payload)
    assert response.status_code == 200
    html = response.json()["html"]

    assert "<h1>Heading</h1>" in html
    assert "<strong>Bold text</strong>" in html
    assert "<ul>" in html
    assert "<li>Item 1</li>" in html
    assert "<br>" in html
    assert "<code>code</code>" in html
    assert "<pre><code" in html
    assert "**Bold text**" not in html


def test_assistant_markdown_render_endpoint_sanitizes_html(client: TestClient) -> None:
    """Assistant markdown renderer must strip unsafe HTML and protocols."""
    payload = {
        "text": "<script>alert('x')</script>\n[bad](javascript:alert(1))\n[good](https://example.com)",
    }
    response = client.post("/assistant/render-markdown", json=payload)
    assert response.status_code == 200
    html = response.json()["html"].lower()

    assert "<script" not in html
    assert "javascript:" not in html
    assert 'href="https://example.com"' in html


def test_assistant_ui_prompt_rejects_when_busy(client: TestClient) -> None:
    """Prompt queue endpoint must reject while assistant is responding/cooling down."""
    voice_runtime.set_worker_active(True)
    voice_runtime.update_status(state="speaking")
    voice_runtime.set_active_cycle("busy-cycle", state="speaking")

    queue_response = client.post("/assistant/ui/prompt", json={"message": "should not queue"})
    assert queue_response.status_code == 409
    assert "busy" in queue_response.json()["detail"].lower()
    assert voice_runtime.pop_typed_prompt() is None


def test_assistant_ui_stop_endpoint_is_idempotent(client: TestClient) -> None:
    """Stop endpoint should accept first request and ignore duplicates safely."""
    voice_runtime.set_active_cycle("cycle-stop-1", state="speaking")

    first = client.post("/assistant/ui/stop")
    assert first.status_code == 200
    first_payload = first.json()
    assert first_payload["ok"] is True
    assert first_payload["duplicate"] is False
    assert first_payload["cycle_id"] == "cycle-stop-1"

    second = client.post("/assistant/ui/stop")
    assert second.status_code == 200
    second_payload = second.json()
    assert second_payload["ok"] is True
    assert second_payload["duplicate"] is True
    assert second_payload["cycle_id"] == "cycle-stop-1"


def test_dev_only_assistant_routes_hidden_in_production(
    client: TestClient,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Debug/test assistant routes should be hidden in production mode."""
    monkeypatch.setattr(
        assistant_routes,
        "settings",
        replace(assistant_routes.settings, app_mode="production"),
    )
    assert client.post("/assistant/ui/test-event").status_code == 404
    assert client.get("/assistant/ws/clients").status_code == 404
    assert client.get("/assistant/ui/debug").status_code == 404
    assert client.get("/assistant/ws/state").status_code == 404
    assert client.get("/assistant/ui/audio-debug").status_code == 404


def test_assistant_ui_listen_requires_microphone(client: TestClient) -> None:
    """Manual listen endpoint should reject when microphone is unavailable."""
    voice_runtime.update_status(
        state="idle",
        backend="whisper",
        microphone_available=False,
        microphone_error="No microphone detected / input unavailable",
    )
    response = client.post("/assistant/ui/listen")
    assert response.status_code == 409
    assert "microphone" in response.json()["detail"].lower()


def test_assistant_ui_listen_and_retry_signals(client: TestClient) -> None:
    """Legacy listen endpoint and retry should signal runtime controller state."""
    voice_runtime.update_status(
        state="idle",
        backend="whisper",
        input_device_name="Built-in",
        microphone_available=True,
        microphone_error=None,
    )

    listen_response = client.post("/assistant/ui/listen")
    assert listen_response.status_code == 200
    assert listen_response.json()["ok"] is True
    assert voice_runtime.is_listening() is True

    retry_response = client.post("/assistant/ui/microphone/retry")
    assert retry_response.status_code == 200
    assert retry_response.json()["ok"] is True
    assert voice_runtime.consume_microphone_retry_request() is True


def test_assistant_ws_ptt_commands_toggle_listening(client: TestClient) -> None:
    """WS command channel should toggle PTT listening state without wake-word flow."""
    voice_runtime.update_status(
        state="idle",
        backend="whisper",
        input_device_name="Built-in",
        microphone_available=True,
        microphone_error=None,
    )

    with client.websocket_connect("/assistant/ws") as ws:
        # reconnect bootstrap event
        _ = ws.receive_json()

        def _receive_command_result(expected_command: str) -> dict[str, object]:
            for _ in range(6):
                payload = ws.receive_json()
                if payload.get("type") == "command_result" and payload.get("command") == expected_command:
                    return payload
            raise AssertionError(f"missing command_result for {expected_command}")

        ws.send_json({"type": "command", "command": "ptt_start"})
        result_start = _receive_command_result("ptt_start")
        assert result_start["ok"] is True
        assert voice_runtime.is_listening() is True

        ws.send_json({"type": "command", "command": "ptt_stop"})
        result_stop = _receive_command_result("ptt_stop")
        assert result_stop["ok"] is True
        assert voice_runtime.is_listening() is False


def test_assistant_ws_stop_response_command_is_idempotent(client: TestClient) -> None:
    """WS stop_response should interrupt once and ignore duplicate clicks."""
    voice_runtime.set_active_cycle("cycle-stop-ws", state="speaking")

    with client.websocket_connect("/assistant/ws") as ws:
        _ = ws.receive_json()  # reconnect bootstrap event

        def _receive_stop_result() -> dict[str, object]:
            for _ in range(8):
                payload = ws.receive_json()
                if payload.get("type") == "command_result" and payload.get("command") == "stop_response":
                    return payload
            raise AssertionError("missing command_result for stop_response")

        ws.send_json({"type": "command", "command": "stop_response"})
        first = _receive_stop_result()
        assert first["ok"] is True
        assert first["duplicate"] is False
        assert first["cycle_id"] == "cycle-stop-ws"

        ws.send_json({"type": "command", "command": "stop_response"})
        second = _receive_stop_result()
        assert second["ok"] is True
        assert second["duplicate"] is True
        assert second["cycle_id"] == "cycle-stop-ws"


def test_assistant_openapi_surface_matches_expected_routes(client: TestClient) -> None:
    """OpenAPI should expose real assistant HTTP routes and omit removed debug endpoints."""
    response = client.get("/openapi.json")
    assert response.status_code == 200
    paths = response.json().get("paths", {})
    assert "/assistant/query" in paths
    assert "/assistant/ui/prompt" in paths
    assert "/assistant/ui/voice-status" in paths
    assert "/assistant/ws/state" in paths
    assert "/assistant/ws/clients" not in paths
