"""Regression tests for the Settings → Backends web OAuth flow.

Pins the public surface added in PR #282 R5 so the Claude/Codex Settings
page can drive OAuth from the browser instead of asking users to copy a
``claude login`` command into a terminal. The state machine itself
(``WebAuthFlow``) and the four web methods on ``AgentAuthService`` are
exercised without spawning real subprocesses by injecting stub flows
into ``_web_flows`` and mocking ``_send_claude_callback``.
"""

from __future__ import annotations

import asyncio
import os
from unittest.mock import AsyncMock

import pytest

from core.agent_auth_service import AgentAuthService, WebAuthFlow
from modules.agents.opencode.message_processor import extract_opencode_response_text


class _Backend:
    cli_path = "/usr/bin/echo"  # any binary that exists is fine


class _Agents:
    claude = _Backend
    codex = _Backend
    opencode = _Backend


class _Config:
    agents = _Agents()
    language = "en"


class _StubController:
    """Minimal controller stand-in (see ``vibe/api.py::_WebControllerStub``)."""

    agent_service = None
    session_handler = None
    im_client = None
    config = _Config()


@pytest.fixture
def service() -> AgentAuthService:
    return AgentAuthService(_StubController())


def _run(coro):
    return asyncio.get_event_loop_policy().new_event_loop().run_until_complete(coro)


def test_opencode_message_text_extractor_ignores_non_text_by_default() -> None:
    message = {
        "parts": [
            {
                "type": "reasoning",
                "text": "internal chain-of-thought-ish content",
            }
        ]
    }

    assert extract_opencode_response_text(message) == ""
    assert (
        extract_opencode_response_text(message, allow_non_text_fallback=True)
        == "internal chain-of-thought-ish content"
    )


def test_unsupported_backend_raises(service: AgentAuthService) -> None:
    with pytest.raises(ValueError, match="unsupported_backend"):
        _run(service.start_web_setup("gemini"))


def test_opencode_requires_provider_id(service: AgentAuthService) -> None:
    """OpenCode auth is per-provider; ``start_web_setup`` must reject a
    bare backend name with no ``provider_id`` rather than crash deep in
    the OAuth bootstrap. (The error is surfaced as a failed flow record
    so the UI can render a clear sentence.)"""
    flow = _run(service.start_web_setup("opencode"))
    assert flow.state == "failed"
    assert flow.error == "opencode_provider_id_required"


def test_status_for_unknown_flow_returns_flow_not_found(service: AgentAuthService) -> None:
    result = service.get_web_flow_status("nonexistent")
    assert result == {"ok": False, "error": "flow_not_found"}


def test_submit_code_unknown_flow(service: AgentAuthService) -> None:
    result = _run(service.submit_web_code("nonexistent", "abc#def"))
    assert result == {"ok": False, "error": "flow_not_found"}


def test_submit_code_rejected_for_codex(service: AgentAuthService) -> None:
    # Codex device-auth never asks for a code; submitting one is a UI bug.
    flow = WebAuthFlow(flow_id="cdx1", backend="codex", state="awaiting_code", awaiting_code=True)
    service._web_flows[flow.flow_id] = flow
    result = _run(service.submit_web_code("cdx1", "abc#def"))
    assert result == {"ok": False, "error": "code_not_supported"}


def test_submit_code_rejected_when_not_awaiting(service: AgentAuthService) -> None:
    flow = WebAuthFlow(flow_id="cl1", backend="claude", state="verifying", awaiting_code=False)
    service._web_flows[flow.flow_id] = flow
    result = _run(service.submit_web_code("cl1", "abc#def"))
    assert result == {"ok": False, "error": "not_awaiting_code"}


def test_submit_code_invalid_format(service: AgentAuthService) -> None:
    flow = WebAuthFlow(
        flow_id="cl2",
        backend="claude",
        state="awaiting_code",
        awaiting_code=True,
        claude_client=object(),  # presence-check only
    )
    service._web_flows[flow.flow_id] = flow

    # Missing separator.
    assert _run(service.submit_web_code("cl2", "no-hash-here")) == {
        "ok": False,
        "error": "invalid_format",
    }
    # Empty left half.
    assert _run(service.submit_web_code("cl2", "#statehere")) == {
        "ok": False,
        "error": "invalid_format",
    }
    # Empty right half.
    assert _run(service.submit_web_code("cl2", "code#")) == {
        "ok": False,
        "error": "invalid_format",
    }


def test_submit_code_happy_path_transitions_to_verifying(
    service: AgentAuthService, monkeypatch: pytest.MonkeyPatch
) -> None:
    mock_send = AsyncMock()
    monkeypatch.setattr(service, "_send_claude_callback", mock_send)

    fake_client = object()
    flow = WebAuthFlow(
        flow_id="cl3",
        backend="claude",
        state="awaiting_code",
        awaiting_code=True,
        claude_client=fake_client,
        url="https://claude.ai/oauth/authorize?...",
    )
    service._web_flows[flow.flow_id] = flow

    result = _run(service.submit_web_code("cl3", "  authcode  #  state-token  "))
    assert result == {"ok": True}
    mock_send.assert_awaited_once_with(fake_client, "authcode", "state-token")
    assert flow.state == "verifying"
    assert flow.awaiting_code is False


def test_status_returns_serializable_snapshot(service: AgentAuthService) -> None:
    flow = WebAuthFlow(
        flow_id="cdx2",
        backend="codex",
        state="awaiting_code",
        url="https://auth.openai.com/codex/device",
        device_code="ABCD-EFGH",
        awaiting_code=False,
    )
    service._web_flows[flow.flow_id] = flow
    result = service.get_web_flow_status("cdx2")
    assert result == {
        "ok": True,
        "flow_id": "cdx2",
        "backend": "codex",
        "state": "awaiting_code",
        "url": "https://auth.openai.com/codex/device",
        "device_code": "ABCD-EFGH",
        "awaiting_code": False,
        "error": None,
    }


def test_cancel_unknown_flow(service: AgentAuthService) -> None:
    result = _run(service.cancel_web_flow("nope"))
    assert result == {"ok": False, "error": "flow_not_found"}


def test_cancel_removes_flow_and_marks_state(service: AgentAuthService) -> None:
    flow = WebAuthFlow(flow_id="any", backend="codex", state="awaiting_code")
    service._web_flows[flow.flow_id] = flow
    result = _run(service.cancel_web_flow("any"))
    assert result == {"ok": True}
    assert "any" not in service._web_flows
    assert flow.state == "cancelled"


def test_post_web_success_hook_invocation_when_set(
    service: AgentAuthService,
) -> None:
    """Hook fires once after a successful flow; absence is a no-op."""
    calls: list[str] = []

    def hook(backend: str) -> None:
        calls.append(backend)

    service._post_web_success_hook = hook
    _run(service._invoke_post_web_success_hook("codex"))
    assert calls == ["codex"]


def test_post_web_success_hook_swallows_exceptions(
    service: AgentAuthService,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """A misbehaving hook must not surface into the flow waiter."""

    def hook(_backend: str) -> None:
        raise RuntimeError("boom")

    monkeypatch.setattr("vibe.claude_config.apply_claude_auth", lambda **_kwargs: None)
    service._post_web_success_hook = hook
    # Should NOT raise.
    _run(service._invoke_post_web_success_hook("claude"))


def test_claude_oauth_settings_cleanup_failure_fails_web_flow(
    service: AgentAuthService,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    fake_client = object()
    flow = WebAuthFlow(
        flow_id="claude-cleanup-fails",
        backend="claude",
        claude_client=fake_client,
    )
    service._send_claude_control_request = AsyncMock(return_value={})
    service._verify_web_login = AsyncMock(return_value=(True, '{"loggedIn": true}'))
    service._refresh_backend_runtime = AsyncMock()
    service._disconnect_claude_client = AsyncMock()

    def fail_cleanup(**_kwargs):
        raise OSError("settings locked")

    monkeypatch.setattr("vibe.claude_config.apply_claude_auth", fail_cleanup)

    _run(service._wait_for_claude_completion_web(flow))

    assert flow.state == "failed"
    assert "Failed to clear Claude Code settings env" in (flow.error or "")
    service._refresh_backend_runtime.assert_not_awaited()
    service._disconnect_claude_client.assert_awaited_once_with(fake_client)


def test_post_web_success_hook_unset_is_safe(
    service: AgentAuthService,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    monkeypatch.setattr("vibe.claude_config.apply_claude_auth", lambda **_kwargs: None)
    service._post_web_success_hook = None
    _run(service._invoke_post_web_success_hook("claude"))


# ---------------------------------------------------------------------------
# OpenCode per-provider OAuth web flow
# ---------------------------------------------------------------------------


class _FakeOpencodeServer:
    """In-memory stub of ``OpenCodeServer`` for the OAuth path.

    ``start_provider_oauth`` returns the authorize-stub the test sets up
    via ``next_authorize``; ``wait_provider_oauth`` blocks on a future
    the test resolves manually so we can drive completion deterministically.
    """

    def __init__(self) -> None:
        self.next_authorize: dict = {}
        self.auth_map: dict = {}
        self.wait_future: asyncio.Future = asyncio.get_event_loop_policy().new_event_loop().create_future()
        self.start_calls: list[tuple[str, int, dict]] = []
        self.wait_calls: list[tuple[str, int, dict]] = []
        self.catalog: dict = {}
        self.created_session: dict = {"info": {"id": "sess_probe"}}
        self.messages: list[dict] = []
        self.sent_messages: list[tuple[str, str, str, dict | None]] = []
        self.abort_calls: list[tuple[str, str]] = []
        self.message_sent = False

    async def get_provider_auth(self):
        return self.auth_map

    async def get_available_models(self, _directory):
        return self.catalog

    async def create_session(self, _directory, *, title):
        return self.created_session

    async def list_messages(self, _session_id, _directory):
        return self.messages if self.message_sent else []

    async def send_message(self, session_id, directory, content, *, model=None):
        self.sent_messages.append((session_id, directory, content, model))
        self.message_sent = True

    async def abort_session(self, session_id, directory):
        self.abort_calls.append((session_id, directory))

    async def start_provider_oauth(self, provider_id, *, method, prompt_answers):
        self.start_calls.append((provider_id, method, prompt_answers))
        return self.next_authorize

    async def wait_provider_oauth(self, provider_id, *, method, prompt_answers, timeout):
        self.wait_calls.append((provider_id, method, prompt_answers))
        return await self.wait_future


def test_start_web_setup_opencode_extracts_url_and_device_code(
    service: AgentAuthService, monkeypatch: pytest.MonkeyPatch
) -> None:
    fake = _FakeOpencodeServer()
    fake.auth_map = {
        "openai": [
            {"type": "oauth", "label": "ChatGPT Pro/Plus (browser)"},
            {"type": "oauth", "label": "ChatGPT Pro/Plus (headless)"},
            {"type": "api", "label": "Manually enter API Key"},
        ]
    }
    fake.next_authorize = {
        "url": "https://auth.openai.com/codex/device",
        "instructions": "Enter code: YR8I-QJJUH",
    }
    monkeypatch.setattr(service, "_opencode_server", AsyncMock(return_value=fake))

    flow = _run(service.start_web_setup("opencode", provider_id="openai"))

    # Headless variant (index 1) wins because the resolver walks the
    # auth list in reverse — important for remote sessions where the
    # localhost-callback "browser" variant (index 0) can't complete.
    assert fake.start_calls == [("openai", 1, {})]
    assert flow.state == "awaiting_code"
    assert flow.url == "https://auth.openai.com/codex/device"
    assert flow.device_code == "YR8I-QJJUH"
    # OpenCode device flow auto-completes; UI must not show a code-submit input.
    assert flow.awaiting_code is False


def test_start_web_setup_opencode_github_copilot_passes_prompt_answer(
    service: AgentAuthService, monkeypatch: pytest.MonkeyPatch
) -> None:
    """github-copilot's first method has a ``deploymentType`` prompt; the
    resolver pre-fills ``github.com`` so the user doesn't have to pick
    enterprise vs public on first sign-in."""
    fake = _FakeOpencodeServer()
    fake.auth_map = {
        "github-copilot": [
            {"type": "oauth", "label": "Login with GitHub Copilot"},
        ]
    }
    fake.next_authorize = {
        "url": "https://github.com/login/device",
        "instructions": "Enter code: 335B-09BE",
    }
    monkeypatch.setattr(service, "_opencode_server", AsyncMock(return_value=fake))

    _run(service.start_web_setup("opencode", provider_id="github-copilot"))

    assert fake.start_calls == [
        ("github-copilot", 0, {"deploymentType": "github.com"}),
    ]


def test_start_web_setup_opencode_url_only_flow_has_no_device_code(
    service: AgentAuthService, monkeypatch: pytest.MonkeyPatch
) -> None:
    """Browser-redirect flows (gitlab, poe) return ``url`` only — no
    "Enter code: XXX" line. ``device_code`` must stay ``None`` so the UI
    skips the device-code block."""
    fake = _FakeOpencodeServer()
    fake.auth_map = {"gitlab": [{"type": "oauth", "label": "GitLab OAuth"}]}
    fake.next_authorize = {
        "url": "https://gitlab.com/oauth/authorize?...",
        "instructions": "Your browser will open for authentication.",
    }
    monkeypatch.setattr(service, "_opencode_server", AsyncMock(return_value=fake))

    flow = _run(service.start_web_setup("opencode", provider_id="gitlab"))
    assert flow.state == "awaiting_code"
    assert flow.url is not None
    assert flow.device_code is None


def test_start_web_setup_opencode_surfaces_server_failure(
    service: AgentAuthService, monkeypatch: pytest.MonkeyPatch
) -> None:
    """When the OpenCode daemon isn't reachable, the flow lands in
    ``failed`` with a typed error string so the UI can render an
    actionable sentence rather than ``cli_failed``."""
    monkeypatch.setattr(service, "_opencode_server", AsyncMock(return_value=None))
    flow = _run(service.start_web_setup("opencode", provider_id="openai"))
    assert flow.state == "failed"
    assert flow.error == "opencode_server_unavailable"


def test_opencode_oauth_success_clears_provider_options_key(
    service: AgentAuthService, monkeypatch: pytest.MonkeyPatch
) -> None:
    fake = _FakeOpencodeServer()
    fake.auth_map = {"openai": [{"type": "oauth", "label": "ChatGPT Pro/Plus"}]}
    fake.next_authorize = {
        "url": "https://auth.openai.com/codex/device",
        "instructions": "Enter code: YR8I-QJJUH",
    }
    fake.wait_provider_oauth = AsyncMock(return_value=True)
    monkeypatch.setattr(service, "_opencode_server", AsyncMock(return_value=fake))
    clear_key = AsyncMock()
    monkeypatch.setattr(service, "_clear_opencode_provider_options_key_for_oauth", clear_key)
    hook_calls: list[str] = []
    service._post_web_success_hook = lambda b: hook_calls.append(b)

    async def run_flow():
        flow = await service.start_web_setup("opencode", provider_id="openai")
        await flow.waiter_task
        return flow

    flow = _run(run_flow())

    clear_key.assert_awaited_once_with("openai")
    assert hook_calls == ["opencode"]
    assert flow.state == "success"


def test_opencode_provider_test_returns_excerpt_from_non_text_part(
    service: AgentAuthService, monkeypatch: pytest.MonkeyPatch
) -> None:
    fake = _FakeOpencodeServer()
    fake.catalog = {
        "providers": [
            {
                "id": "anthropic",
                "models": {"claude-opus-4.8": {}},
            }
        ]
    }
    fake.messages = [
        {
            "info": {
                "id": "msg_assistant",
                "role": "assistant",
                "time": {"completed": 123},
            },
            "parts": [
                {
                    "type": "reasoning",
                    "id": "part_reasoning",
                    "text": "Hello from a non-text OpenCode part",
                }
            ],
        }
    ]
    monkeypatch.setattr(service, "_opencode_server", AsyncMock(return_value=fake))

    result = _run(service.test_opencode_provider("anthropic"))

    assert result["ok"] is True
    assert result["model"] == "claude-opus-4.8"
    assert result["excerpt"] == "Hello from a non-text OpenCode part"
    assert fake.sent_messages[-1][3] == {
        "providerID": "anthropic",
        "modelID": "claude-opus-4.8",
    }
    assert fake.abort_calls == [("sess_probe", os.path.expanduser("~"))]


def test_remove_web_auth_rejects_unsupported_backend(service: AgentAuthService) -> None:
    # OpenCode joined ``WEB_BACKENDS`` for OAuth, but ``remove_web_auth``
    # is claude / codex specific — opencode providers use the
    # per-provider DELETE endpoint instead. Both ``opencode`` and any
    # other name are rejected here.
    assert _run(service.remove_web_auth("opencode")) == {"ok": False, "error": "unsupported_backend"}
    assert _run(service.remove_web_auth("gemini")) == {"ok": False, "error": "unsupported_backend"}


def test_remove_web_auth_runs_logout_and_returns_ok(
    service: AgentAuthService, monkeypatch: pytest.MonkeyPatch
) -> None:
    # ``_run_utility_command`` now returns ``(ok, error_excerpt)`` so
    # ``remove_web_auth`` can surface a partial failure when ``codex
    # logout`` / ``claude auth logout`` exits non-zero. The success
    # path mocks must yield ``(True, None)``.
    run_cmd = AsyncMock(return_value=(True, None))
    monkeypatch.setattr(service, "_run_utility_command", run_cmd)
    monkeypatch.setattr("vibe.claude_config.apply_claude_auth", lambda **_kwargs: None)
    hook_calls: list[str] = []
    service._post_web_success_hook = lambda b: hook_calls.append(b)

    result = _run(service.remove_web_auth("claude"))
    assert result == {"ok": True}
    # Claude logout subcommand is ``claude auth logout``.
    run_cmd.assert_awaited_once()
    args = run_cmd.call_args.args
    assert "auth" in args and "logout" in args
    # Hook fires so the live controller can refresh.
    assert hook_calls == ["claude"]


def test_remove_web_auth_codex_uses_logout_subcommand(
    service: AgentAuthService, monkeypatch: pytest.MonkeyPatch
) -> None:
    run_cmd = AsyncMock(return_value=(True, None))
    monkeypatch.setattr(service, "_run_utility_command", run_cmd)
    result = _run(service.remove_web_auth("codex"))
    assert result == {"ok": True}
    # Codex uses just ``codex logout`` (no nested ``auth`` subcommand).
    args = run_cmd.call_args.args
    assert "logout" in args and "auth" not in args


def test_remove_web_auth_surfaces_logout_failure(
    service: AgentAuthService, monkeypatch: pytest.MonkeyPatch
) -> None:
    # Codex P2: a failed ``logout`` previously got swallowed and the
    # API returned ``ok: true``, misleading the UI into showing a
    # green sign-out toast while the backend creds remained intact.
    # Now the failure rides back as ``partial`` + ``warning`` so the
    # frontend can show a warning toast and the on-disk state can be
    # cleaned up manually.
    run_cmd = AsyncMock(return_value=(False, "exit 1: not logged in"))
    monkeypatch.setattr(service, "_run_utility_command", run_cmd)
    monkeypatch.setattr("vibe.claude_config.apply_claude_auth", lambda **_kwargs: None)
    result = _run(service.remove_web_auth("claude"))
    assert result["ok"] is True
    assert result["partial"] is True
    assert result["warning"] == "logout_failed"
    assert "exit 1" in result["detail"]


def test_remove_web_auth_surfaces_claude_settings_cleanup_failure(
    service: AgentAuthService, monkeypatch: pytest.MonkeyPatch
) -> None:
    run_cmd = AsyncMock(return_value=(True, None))
    monkeypatch.setattr(service, "_run_utility_command", run_cmd)

    def fail_cleanup(**_kwargs):
        raise OSError("settings locked")

    monkeypatch.setattr("vibe.claude_config.apply_claude_auth", fail_cleanup)

    result = _run(service.remove_web_auth("claude"))

    assert result["ok"] is True
    assert result["partial"] is True
    assert result["warning"] == "settings_cleanup_failed"
    assert "Failed to clear Claude Code settings env" in result["detail"]


def test_test_web_auth_rejects_unsupported_backend(service: AgentAuthService) -> None:
    # OpenCode joins ``WEB_BACKENDS`` for OAuth start; ``test_web_auth``
    # still rejects it (probe is run by the OpenCode daemon itself).
    assert _run(service.test_web_auth("opencode")) == {"ok": False, "error": "unsupported_backend"}
    assert _run(service.test_web_auth("gemini")) == {"ok": False, "error": "unsupported_backend"}


def test_test_web_auth_surfaces_cli_not_found(
    service: AgentAuthService, monkeypatch: pytest.MonkeyPatch
) -> None:
    async def _spawn(*_args, **_kwargs):
        raise FileNotFoundError("no such cli")

    monkeypatch.setattr(asyncio, "create_subprocess_exec", _spawn)
    result = _run(service.test_web_auth("codex"))
    assert result["ok"] is False
    assert result["error"] == "cli_not_found"


def test_test_web_auth_happy_path_returns_excerpt(
    service: AgentAuthService, monkeypatch: pytest.MonkeyPatch
) -> None:
    """A passing probe surfaces the first non-blank stdout line + duration."""

    class _FakeProcess:
        returncode = 0

        async def communicate(self):
            return (b"\nHello from the model\nmore text", b"")

    async def _spawn(*_args, **_kwargs):
        return _FakeProcess()

    monkeypatch.setattr(asyncio, "create_subprocess_exec", _spawn)
    result = _run(service.test_web_auth("codex"))
    assert result["ok"] is True
    assert result["excerpt"] == "Hello from the model"
    assert isinstance(result["duration_ms"], int)


def test_test_web_auth_failure_surfaces_stderr(
    service: AgentAuthService, monkeypatch: pytest.MonkeyPatch
) -> None:
    class _FakeProcess:
        returncode = 7

        async def communicate(self):
            return (b"", b"Authentication failed: no credentials configured")

    async def _spawn(*_args, **_kwargs):
        return _FakeProcess()

    monkeypatch.setattr(asyncio, "create_subprocess_exec", _spawn)
    result = _run(service.test_web_auth("claude"))
    assert result["ok"] is False
    # The classifier turns "Authentication failed" stderr into the
    # specific ``invalid_credentials`` code so the UI can render the
    # actionable "Replace your API key or re-authenticate" sentence.
    assert result["error"] == "invalid_credentials"
    assert result["exit_code"] == 7
    assert "Authentication failed" in (result.get("detail") or "")
