"""Regression tests for issue #1362 — Codex OAuth from onboarding."""

from __future__ import annotations

import json
import os
import stat
import threading
import time
from pathlib import Path

import pytest

REPO = Path(__file__).resolve().parents[1]


def test_onboarding_codex_oauth_routes_use_post_start_cancel_and_get_poll():
    routes = (REPO / "api" / "routes.py").read_text(encoding="utf-8")
    get_idx = routes.find("def handle_get(")
    post_idx = routes.find("def handle_post(")
    assert get_idx != -1 and post_idx != -1
    get_body = routes[get_idx:post_idx]
    post_body = routes[post_idx:]

    assert '"/api/onboarding/oauth/poll"' in get_body
    assert '"/api/onboarding/oauth/start"' not in get_body
    assert '"/api/oauth/codex/start"' not in routes
    assert '"/api/oauth/codex/poll"' not in routes
    assert '"/api/onboarding/oauth/start"' in post_body
    assert '"/api/onboarding/oauth/cancel"' in post_body


def test_onboarding_oauth_rejects_unsupported_providers(monkeypatch):
    import api.oauth as oauth

    for provider in ("nous", "qwen-oauth", "copilot", "bogus"):
        with pytest.raises(ValueError):
            oauth.start_onboarding_oauth_flow({"provider": provider})


def test_start_payload_does_not_leak_provider_device_secrets(monkeypatch, tmp_path):
    import api.oauth as oauth

    oauth._OAUTH_FLOWS.clear()
    monkeypatch.setattr(oauth, "_get_active_hermes_home", lambda: tmp_path)
    monkeypatch.setattr(oauth, "_request_codex_user_code", lambda: {
        "device_auth_id": "device-secret",
        "user_code": "ABCD-EFGH",
        "interval": 3,
    })
    monkeypatch.setattr(oauth, "_spawn_codex_oauth_worker", lambda flow_id: None)

    payload = oauth.start_onboarding_oauth_flow({"provider": "openai-codex"})

    assert payload["ok"] is True
    assert payload["provider"] == "openai-codex"
    assert payload["status"] == "pending"
    assert payload["verification_uri"] == "https://auth.openai.com/codex/device"
    assert payload["user_code"] == "ABCD-EFGH"
    serialized = json.dumps(payload)
    for forbidden in (
        "device_auth_id",
        "device-secret",
        "authorization_code",
        "code_verifier",
        "access_token",
        "refresh_token",
    ):
        assert forbidden not in serialized


def test_poll_returns_high_level_status_only(monkeypatch, tmp_path):
    import api.oauth as oauth

    oauth._OAUTH_FLOWS.clear()
    flow_id = "flow-test"
    oauth._OAUTH_FLOWS[flow_id] = {
        "provider": "openai-codex",
        "status": "pending",
        "device_auth_id": "device-secret",
        "user_code": "ABCD-EFGH",
        "code_verifier": "verifier-secret",
        "authorization_code": "auth-secret",
        "expires_at": time.time() + 60,
        "poll_interval_seconds": 3,
        "hermes_home": tmp_path,
    }

    payload = oauth.poll_onboarding_oauth_flow(flow_id)

    assert payload == {"ok": True, "provider": "openai-codex", "flow_id": flow_id, "status": "pending"}
    serialized = json.dumps(payload)
    for forbidden in ("device_auth_id", "device-secret", "code_verifier", "authorization_code"):
        assert forbidden not in serialized


def test_cancel_marks_flow_cancelled_and_poll_stops(tmp_path):
    import api.oauth as oauth

    oauth._OAUTH_FLOWS.clear()
    flow_id = "flow-cancel"
    oauth._OAUTH_FLOWS[flow_id] = {
        "provider": "openai-codex",
        "status": "pending",
        "expires_at": time.time() + 60,
        "hermes_home": tmp_path,
    }

    cancelled = oauth.cancel_onboarding_oauth_flow({"flow_id": flow_id})
    polled = oauth.poll_onboarding_oauth_flow(flow_id)

    assert cancelled["status"] == "cancelled"
    assert polled["status"] == "cancelled"


def test_cancel_during_token_exchange_does_not_persist_credentials(monkeypatch, tmp_path):
    """Cancel arriving while the worker is mid-network-call must win.

    Without the post-exchange status re-check, the worker would proceed to
    persist credentials to auth.json AND override the cancelled status with
    "success" — silently storing tokens the user explicitly aborted.
    """
    import threading
    import api.oauth as oauth

    oauth._OAUTH_FLOWS.clear()

    poll_started = threading.Event()
    poll_continue = threading.Event()

    def _slow_poll(device_auth_id, user_code):
        poll_started.set()
        assert poll_continue.wait(timeout=5)
        return {"authorization_code": "auth-code", "code_verifier": "verifier"}

    def _exchange(authorization_code, code_verifier):
        return {"access_token": "ACCESS", "refresh_token": "REFRESH"}

    monkeypatch.setattr(oauth, "_poll_codex_authorization", _slow_poll)
    monkeypatch.setattr(oauth, "_exchange_codex_authorization", _exchange)

    flow_id = "race-flow"
    oauth._OAUTH_FLOWS[flow_id] = {
        "provider": "openai-codex",
        "status": "pending",
        "device_auth_id": "device-secret",
        "user_code": "ABCD-EFGH",
        "expires_at": time.time() + 600,
        "poll_interval_seconds": 1,
        "hermes_home": str(tmp_path),
        "created_at": time.time(),
        "updated_at": time.time(),
    }

    worker = threading.Thread(target=oauth._run_codex_oauth_worker, args=(flow_id,), daemon=True)
    worker.start()
    assert poll_started.wait(timeout=5)

    oauth.cancel_onboarding_oauth_flow({"flow_id": flow_id})
    assert oauth._OAUTH_FLOWS[flow_id]["status"] == "cancelled"

    poll_continue.set()
    worker.join(timeout=5)
    assert not worker.is_alive()

    assert oauth._OAUTH_FLOWS[flow_id]["status"] == "cancelled"
    assert not (tmp_path / "auth.json").exists()


def test_expired_flow_reports_expired_and_drops_sensitive_lifecycle(tmp_path):
    import api.oauth as oauth

    oauth._OAUTH_FLOWS.clear()
    flow_id = "flow-expired"
    oauth._OAUTH_FLOWS[flow_id] = {
        "provider": "openai-codex",
        "status": "pending",
        "device_auth_id": "device-secret",
        "expires_at": time.time() - 1,
        "hermes_home": tmp_path,
    }

    payload = oauth.poll_onboarding_oauth_flow(flow_id)

    assert payload["status"] == "expired"
    assert oauth._OAUTH_FLOWS[flow_id]["status"] == "expired"
    assert "device_auth_id" not in oauth._OAUTH_FLOWS[flow_id]


def test_codex_credentials_written_to_active_profile_auth_json(monkeypatch, tmp_path):
    import api.oauth as oauth
    from api.onboarding import _provider_oauth_authenticated

    active_home = tmp_path / "active-profile"
    realish_home = tmp_path / "process-home"
    active_home.mkdir()
    realish_home.mkdir()
    monkeypatch.setattr(Path, "home", lambda: realish_home)

    auth_path = oauth._persist_codex_credentials(
        active_home,
        {"access_token": "access-secret", "refresh_token": "refresh-secret"},
    )

    assert auth_path == active_home / "auth.json"
    assert auth_path.exists()
    assert not (realish_home / ".hermes" / "auth.json").exists()
    mode = stat.S_IMODE(auth_path.stat().st_mode)
    assert mode == 0o600
    store = json.loads(auth_path.read_text(encoding="utf-8"))
    entry = store["credential_pool"]["openai-codex"][0]
    assert entry["auth_type"] == "oauth"
    assert entry["source"] == "manual:device_code"
    assert entry["base_url"] == "https://chatgpt.com/backend-api/codex"
    assert _provider_oauth_authenticated("openai-codex", active_home) is True


def test_frontend_uses_onboarding_oauth_endpoints_and_no_secret_poll_url():
    js = (REPO / "static" / "onboarding.js").read_text(encoding="utf-8")
    assert "/api/onboarding/oauth/start" in js
    assert "/api/onboarding/oauth/poll" in js
    assert "/api/onboarding/oauth/cancel" in js
    assert "window.open(verification_uri" not in js
    assert "device_code=" not in js
    assert "device_code" not in js
    assert "flow_id" in js
    assert "copyCodexOAuthCode" in js
    assert "cancelCodexOAuth" in js


def test_unsupported_note_mentions_codex_and_claude_as_in_app():
    src = (REPO / "api" / "onboarding.py").read_text(encoding="utf-8")
    start = src.find("_UNSUPPORTED_PROVIDER_NOTE")
    body = src[start:start + 500]
    assert "OpenAI Codex, and GitHub" not in body
    assert "OpenAI Codex" in body and "authenticated in this onboarding flow" in body
    assert "Claude" in body or "Anthropic" in body


# ── Claude / Anthropic OAuth slice ─────────────────────────────────────────


def test_claude_provider_aliases_normalize_to_anthropic(monkeypatch, tmp_path):
    import api.oauth as oauth

    oauth._OAUTH_FLOWS.clear()
    monkeypatch.setattr(oauth, "_get_active_hermes_home", lambda: tmp_path)
    monkeypatch.setattr(oauth, "_read_claude_code_credentials", lambda: None)
    monkeypatch.setattr(oauth, "_spawn_anthropic_credential_worker", lambda fid: None)

    for alias in ("anthropic", "claude", "claude-code"):
        payload = oauth.start_onboarding_oauth_flow({"provider": alias})
        assert payload["ok"] is True
        assert payload["provider"] == "anthropic"
        assert payload["status"] == "pending"


def test_anthropic_immediate_success_when_credentials_exist(monkeypatch, tmp_path):
    import api.oauth as oauth

    oauth._OAUTH_FLOWS.clear()
    monkeypatch.setattr(oauth, "_get_active_hermes_home", lambda: tmp_path)
    monkeypatch.setattr(oauth, "_read_claude_code_credentials", lambda: {
        "accessToken": "cc-access-secret",
        "refreshToken": "cc-refresh-secret",
        "expiresAt": 9999999999999,
    })
    linked = []
    monkeypatch.setattr(oauth, "_link_anthropic_credentials", lambda hh: linked.append(str(hh)))

    payload = oauth.start_onboarding_oauth_flow({"provider": "anthropic"})

    assert payload["status"] == "success"
    assert payload["provider"] == "anthropic"
    assert linked == [str(tmp_path)]
    serialized = json.dumps(payload)
    for forbidden in ("cc-access-secret", "cc-refresh-secret", "accessToken", "refreshToken", "access_token", "refresh_token"):
        assert forbidden not in serialized


def test_anthropic_pending_payload_is_action_only_and_secret_free(monkeypatch, tmp_path):
    import api.oauth as oauth

    oauth._OAUTH_FLOWS.clear()
    monkeypatch.setattr(oauth, "_get_active_hermes_home", lambda: tmp_path)
    monkeypatch.setattr(oauth, "_read_claude_code_credentials", lambda: None)
    monkeypatch.setattr(oauth, "_spawn_anthropic_credential_worker", lambda fid: None)

    payload = oauth.start_onboarding_oauth_flow({"provider": "anthropic"})

    assert payload["status"] == "pending"
    assert payload["provider"] == "anthropic"
    assert payload["flow_id"]
    assert "action_required" in payload
    assert "claude" in payload["action_required"].lower()
    serialized = json.dumps(payload)
    for forbidden in (
        "access_token", "refresh_token", "accessToken", "refreshToken",
        ".credentials.json", ".claude", "hermes_home", str(tmp_path),
        "ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN",
    ):
        assert forbidden not in serialized


def test_anthropic_poll_and_cancel_return_high_level_status(tmp_path):
    import api.oauth as oauth

    oauth._OAUTH_FLOWS.clear()
    flow_id = "claude-flow-test"
    oauth._OAUTH_FLOWS[flow_id] = {
        "provider": "anthropic",
        "status": "pending",
        "expires_at": time.time() + 60,
        "poll_interval_seconds": 5,
        "hermes_home": str(tmp_path),
    }

    assert oauth.poll_onboarding_oauth_flow(flow_id) == {
        "ok": True,
        "provider": "anthropic",
        "flow_id": flow_id,
        "status": "pending",
    }
    assert oauth.cancel_onboarding_oauth_flow({"flow_id": flow_id}) == {
        "ok": True,
        "provider": "anthropic",
        "flow_id": flow_id,
        "status": "cancelled",
    }


def test_anthropic_worker_detects_credentials_and_cancel_wins(monkeypatch, tmp_path):
    import threading
    import api.oauth as oauth

    oauth._OAUTH_FLOWS.clear()
    started = threading.Event()
    proceed = threading.Event()
    linked = []

    def _slow_read_creds():
        started.set()
        assert proceed.wait(timeout=5)
        return {"accessToken": "cc-access-secret", "refreshToken": "cc-refresh-secret"}

    monkeypatch.setattr(oauth, "_read_claude_code_credentials", _slow_read_creds)
    monkeypatch.setattr(oauth, "_link_anthropic_credentials", lambda hh: linked.append(str(hh)))

    flow_id = "claude-race-flow"
    oauth._OAUTH_FLOWS[flow_id] = {
        "provider": "anthropic",
        "status": "pending",
        "expires_at": time.time() + 600,
        "poll_interval_seconds": 1,
        "hermes_home": str(tmp_path),
        "created_at": time.time(),
        "updated_at": time.time(),
    }
    worker = threading.Thread(target=oauth._run_anthropic_credential_worker, args=(flow_id,), daemon=True)
    worker.start()
    assert started.wait(timeout=5)
    oauth.cancel_onboarding_oauth_flow({"flow_id": flow_id})
    proceed.set()
    worker.join(timeout=5)

    assert oauth._OAUTH_FLOWS[flow_id]["status"] == "cancelled"
    assert not linked


def test_anthropic_cancel_during_link_keeps_flow_cancelled(monkeypatch, tmp_path):
    import threading
    import api.oauth as oauth
    from api.onboarding import _provider_oauth_authenticated

    oauth._OAUTH_FLOWS.clear()
    link_started = threading.Event()
    link_continue = threading.Event()
    monkeypatch.setattr(oauth.time, "sleep", lambda _seconds: None)
    monkeypatch.setattr(oauth, "_read_claude_code_credentials", lambda: {"accessToken": "cc-access-secret", "refreshToken": "cc-refresh-secret"})

    def _slow_clear(_home):
        link_started.set()
        assert link_continue.wait(timeout=5)

    monkeypatch.setattr(oauth, "_clear_anthropic_env_values", _slow_clear)
    flow_id = "claude-link-cancel-race"
    oauth._OAUTH_FLOWS[flow_id] = {
        "provider": "anthropic",
        "status": "pending",
        "expires_at": time.time() + 60,
        "poll_interval_seconds": 1,
        "hermes_home": str(tmp_path),
        "created_at": time.time(),
        "updated_at": time.time(),
    }

    worker = threading.Thread(target=oauth._run_anthropic_credential_worker, args=(flow_id,), daemon=True)
    worker.start()
    assert link_started.wait(timeout=5)
    assert oauth.cancel_onboarding_oauth_flow({"flow_id": flow_id})["status"] == "cancelled"
    link_continue.set()
    worker.join(timeout=5)

    assert not worker.is_alive()
    assert oauth._OAUTH_FLOWS[flow_id]["status"] == "cancelled"
    assert _provider_oauth_authenticated("anthropic", tmp_path) is False


def test_anthropic_cancel_missing_flow_keeps_requested_provider():
    import api.oauth as oauth

    oauth._OAUTH_FLOWS.clear()

    assert oauth.cancel_onboarding_oauth_flow({"flow_id": "missing", "provider": "claude-code"}) == {
        "ok": True,
        "provider": "anthropic",
        "flow_id": "missing",
        "status": "cancelled",
    }


def test_anthropic_worker_expires_flow(tmp_path):
    import api.oauth as oauth

    oauth._OAUTH_FLOWS.clear()
    flow_id = "claude-expired-worker-flow"
    oauth._OAUTH_FLOWS[flow_id] = {
        "provider": "anthropic",
        "status": "pending",
        "expires_at": time.time() - 1,
        "poll_interval_seconds": 1,
        "hermes_home": str(tmp_path),
        "created_at": time.time(),
        "updated_at": time.time(),
    }

    oauth._run_anthropic_credential_worker(flow_id)

    assert oauth._OAUTH_FLOWS[flow_id]["status"] == "expired"


def test_anthropic_worker_reports_link_errors(monkeypatch, tmp_path):
    import api.oauth as oauth

    oauth._OAUTH_FLOWS.clear()
    monkeypatch.setattr(oauth.time, "sleep", lambda _seconds: None)
    monkeypatch.setattr(oauth, "_read_claude_code_credentials", lambda: {"accessToken": "cc-access-secret", "refreshToken": "cc-refresh-secret"})

    def _raise_link_error(_home):
        raise RuntimeError("link failed without secrets")

    monkeypatch.setattr(oauth, "_link_anthropic_credentials", _raise_link_error)
    flow_id = "claude-link-error-flow"
    oauth._OAUTH_FLOWS[flow_id] = {
        "provider": "anthropic",
        "status": "pending",
        "expires_at": time.time() + 60,
        "poll_interval_seconds": 1,
        "hermes_home": str(tmp_path),
        "created_at": time.time(),
        "updated_at": time.time(),
    }

    oauth._run_anthropic_credential_worker(flow_id)

    assert oauth._OAUTH_FLOWS[flow_id]["status"] == "error"
    assert "link failed" in oauth._OAUTH_FLOWS[flow_id]["error"]
    payload = oauth.poll_onboarding_oauth_flow(flow_id)
    assert payload == {
        "ok": True,
        "provider": "anthropic",
        "flow_id": flow_id,
        "status": "error",
        "error": "Claude Code credential linking failed. Check server logs.",
    }


def test_anthropic_link_clears_env_and_writes_secret_free_marker(monkeypatch, tmp_path):
    import api.oauth as oauth
    from api.onboarding import _provider_oauth_authenticated

    env_path = tmp_path / ".env"
    env_path.write_text("ANTHROPIC_TOKEN=old-token\nANTHROPIC_API_KEY=old-key\nOTHER=value\n", encoding="utf-8")
    monkeypatch.setenv("ANTHROPIC_TOKEN", "old-token")
    monkeypatch.setenv("ANTHROPIC_API_KEY", "old-key")

    oauth._link_anthropic_credentials(tmp_path)

    env_text = env_path.read_text(encoding="utf-8")
    assert "ANTHROPIC_TOKEN" not in env_text
    assert "ANTHROPIC_API_KEY" not in env_text
    assert "OTHER=value" in env_text
    assert "ANTHROPIC_TOKEN" not in os.environ
    assert "ANTHROPIC_API_KEY" not in os.environ
    auth = json.loads((tmp_path / "auth.json").read_text(encoding="utf-8"))
    marker = auth["credential_pool"]["anthropic"][0]
    assert marker["auth_type"] == "oauth"
    assert marker["source"] == "claude_code_linked"
    assert "access_token" not in marker
    assert "refresh_token" not in marker
    assert _provider_oauth_authenticated("anthropic", tmp_path) is True
    assert _provider_oauth_authenticated("claude-code", tmp_path) is True


def test_anthropic_env_clear_waits_for_chat_env_read_lock(monkeypatch, tmp_path):
    import api.oauth as oauth
    import api.providers as providers
    from api.streaming import _ENV_LOCK

    monkeypatch.setenv("ANTHROPIC_TOKEN", "old-token")
    monkeypatch.setenv("ANTHROPIC_API_KEY", "old-key")

    def _fail_before_env_lock(_env_path, _updates):
        raise RuntimeError("env write failed before process-env clear")

    monkeypatch.setattr(providers, "_write_env_file", _fail_before_env_lock)

    started = threading.Event()
    done = threading.Event()
    errors = []

    def _onboarding_clear():
        started.set()
        try:
            oauth._clear_anthropic_env_values(tmp_path)
        except Exception as exc:  # pragma: no cover - assertion below reports it
            errors.append(exc)
        finally:
            done.set()

    with _ENV_LOCK:
        worker = threading.Thread(target=_onboarding_clear)
        worker.start()
        assert started.wait(timeout=1)
        assert not done.wait(timeout=0.1)
        assert os.environ["ANTHROPIC_TOKEN"] == "old-token"
        assert os.environ["ANTHROPIC_API_KEY"] == "old-key"

    worker.join(timeout=1)
    assert done.is_set()
    assert errors == []
    assert "ANTHROPIC_TOKEN" not in os.environ
    assert "ANTHROPIC_API_KEY" not in os.environ


def test_runtime_provider_reads_use_anthropic_env_lock():
    streaming_src = (REPO / "api" / "streaming.py").read_text(encoding="utf-8")
    routes_src = (REPO / "api" / "routes.py").read_text(encoding="utf-8")

    assert "resolve_runtime_provider_with_anthropic_env_lock" in streaming_src
    assert "resolve_runtime_provider_with_anthropic_env_lock" in routes_src


def test_anthropic_onboarding_setup_allows_linked_oauth_without_api_key(monkeypatch, tmp_path):
    import api.onboarding as onboarding

    # apply_onboarding_setup() short-circuits when HERMES_WEBUI_SKIP_ONBOARDING
    # is set in the environment (hosting providers like Agent37 use it to ship
    # a pre-configured WebUI). Local test runs may also set it for the same
    # reason. The test exercises the file-writing branch, so delete the var
    # for the test's scope. monkeypatch.delenv is a no-op if the var is unset.
    monkeypatch.delenv("HERMES_WEBUI_SKIP_ONBOARDING", raising=False)

    cfg_path = tmp_path / "config.yaml"
    home = tmp_path / "home"
    home.mkdir()
    (home / "auth.json").write_text(json.dumps({
        "credential_pool": {"anthropic": [{"auth_type": "oauth", "source": "claude_code_linked"}]}
    }), encoding="utf-8")
    monkeypatch.setattr(onboarding, "_get_config_path", lambda: cfg_path)
    monkeypatch.setattr(onboarding, "_get_active_hermes_home", lambda: home)
    monkeypatch.setattr(onboarding, "get_onboarding_status", lambda: {"ok": True})
    monkeypatch.setattr(onboarding, "reload_config", lambda: None)

    result = onboarding.apply_onboarding_setup({"provider": "anthropic", "model": "claude-sonnet-4.6"})

    assert result == {"ok": True}
    saved = cfg_path.read_text(encoding="utf-8")
    assert "provider: anthropic" in saved
    assert "default: claude-sonnet-4.6" in saved


def test_frontend_has_anthropic_oauth_support():
    js = (REPO / "static" / "onboarding.js").read_text(encoding="utf-8")
    assert "startAnthropicOAuth" in js
    assert "cancelAnthropicOAuth" in js
    assert "anthropicOAuthBtn" in js
    assert "Login with Claude Code" in js
    assert "Anthropic API key" in js
    assert "Claude Code subscription" in js
    assert "not the same as an Anthropic API key" in js
    assert "/api/onboarding/oauth/start" in js
    assert "/api/onboarding/oauth/poll" in js
    assert "/api/onboarding/oauth/cancel" in js
    assert "window.open(" not in js[js.find("startAnthropicOAuth"):]
    assert "accessToken" not in js[js.find("startAnthropicOAuth"):]
    assert "refreshToken" not in js[js.find("startAnthropicOAuth"):]
