"""Tests for device auth flow (specter login via browser + OTP).

Tests the integration spec from cli-login-integration.md:
  1. POST /api/auth/cli/initiate → session_id, browser_url, expires_in
  2. User opens browser, clicks Authorize, gets OTP code
  3. POST /api/auth/cli/poll with session_id + otp → api_key
  4. Save api_key to ~/.specter/auth.json

Design decisions (per __daem0n__):
  - No --key flag. Use SPECTER_API_KEY env var for programmatic auth.
  - Base URL hardcoded to https://specter.host (auth API).
  - login_with_key() removed — env var replaces it.

All HTTP calls are mocked. Edge cases cover OTP validation, error
responses (404/401/410), network failures, and retry logic.
"""

from __future__ import annotations

from unittest.mock import patch

import httpx
import pytest

from specter_cli.config import get_token, load_auth, save_auth

# ── Fixtures ──


@pytest.fixture(autouse=True)
def _isolated_config(tmp_path, monkeypatch):
    """Redirect config to temp directory via SPECTER_HOME."""
    sd = tmp_path / ".specter"
    sd.mkdir()
    monkeypatch.setenv("SPECTER_HOME", str(sd))
    # Ensure SPECTER_API_KEY is not set (tests that need it set it explicitly)
    monkeypatch.delenv("SPECTER_API_KEY", raising=False)


@pytest.fixture
def mock_initiate_success():
    """Mock a successful /api/auth/cli/initiate response."""
    return httpx.Response(
        200,
        json={
            "session_id": "abc123def456",
            "browser_url": "https://specter.host/cli-auth?session=abc123def456",
            "expires_in": 600,
        },
    )


@pytest.fixture
def mock_poll_success():
    """Mock a successful /api/auth/cli/poll response."""
    return httpx.Response(
        200,
        json={
            "status": "authorized",
            "api_key": "sp_live_" + "a1b2c3d4" * 8,
        },
    )


@pytest.fixture
def mock_poll_invalid_session():
    """Mock a 404 invalid_session response."""
    return httpx.Response(
        404,
        json={
            "error": "invalid_session",
            "message": "No authorized session found. Please try again.",
        },
    )


@pytest.fixture
def mock_poll_invalid_otp():
    """Mock a 401 invalid_otp response."""
    return httpx.Response(
        401,
        json={
            "error": "invalid_otp",
            "message": "Invalid code. Please check and try again.",
        },
    )


@pytest.fixture
def mock_poll_expired():
    """Mock a 410 expired response."""
    return httpx.Response(
        410,
        json={
            "error": "expired",
            "message": "Session expired.",
        },
    )


# ── Test: Initiate endpoint ──


class TestInitiateSession:
    """Tests for the initiate step of device auth."""

    def test_initiate_returns_session_info(self, mock_initiate_success):
        """Successful initiate returns session_id, browser_url, expires_in."""
        from specter_cli.auth import _initiate_session

        with patch("specter_cli.auth.httpx.post", return_value=mock_initiate_success):
            result = _initiate_session("https://specter.host")

        assert result["session_id"] == "abc123def456"
        assert "cli-auth?session=" in result["browser_url"]
        assert result["expires_in"] == 600

    def test_initiate_network_error(self):
        """Network failure during initiate raises a clear error."""
        from specter_cli.auth import _initiate_session

        with (
            patch(
                "specter_cli.auth.httpx.post",
                side_effect=httpx.ConnectError("Connection refused"),
            ),
            pytest.raises((httpx.ConnectError, SystemExit, Exception)),
        ):
            _initiate_session("https://specter.host")

    def test_initiate_server_error(self):
        """500 from server during initiate raises an error."""
        from specter_cli.auth import AuthError, _initiate_session

        resp = httpx.Response(500, json={"error": "internal_error"})
        with patch("specter_cli.auth.httpx.post", return_value=resp), pytest.raises(AuthError):
            _initiate_session("https://specter.host")

    def test_initiate_uses_correct_endpoint(self, mock_initiate_success):
        """Initiate calls POST /api/auth/cli/initiate."""
        from specter_cli.auth import _initiate_session

        with patch("specter_cli.auth.httpx.post", return_value=mock_initiate_success) as mock_post:
            _initiate_session("https://specter.host")

        mock_post.assert_called_once()
        call_url = (
            mock_post.call_args[0][0] if mock_post.call_args[0] else str(mock_post.call_args)
        )
        assert "/api/auth/cli/initiate" in str(call_url)

    def test_initiate_timeout(self):
        """Timeout during initiate raises an error."""
        from specter_cli.auth import _initiate_session

        with (
            patch(
                "specter_cli.auth.httpx.post",
                side_effect=httpx.TimeoutException("timed out"),
            ),
            pytest.raises((httpx.TimeoutException, SystemExit, Exception)),
        ):
            _initiate_session("https://specter.host")


# ── Test: Poll endpoint ──


class TestPollSession:
    """Tests for the poll/verify step of device auth."""

    def test_poll_success_returns_api_key(self, mock_poll_success):
        """Successful poll returns api_key with sp_live_ prefix."""
        from specter_cli.auth import _poll_for_key

        with patch("specter_cli.auth.httpx.post", return_value=mock_poll_success):
            result = _poll_for_key("https://specter.host", "abc123", "AXHK-3V9M")

        assert result["api_key"].startswith("sp_live_")
        assert result["status"] == "authorized"

    def test_poll_sends_correct_payload(self, mock_poll_success):
        """Poll sends session_id and otp in the request body."""
        from specter_cli.auth import _poll_for_key

        with patch("specter_cli.auth.httpx.post", return_value=mock_poll_success) as mock_post:
            _poll_for_key("https://specter.host", "sess123", "AXHK-3V9M")

        call_kwargs = mock_post.call_args
        json_body = call_kwargs[1].get("json") if call_kwargs[1] else None
        if json_body is None and len(call_kwargs[0]) > 1:
            json_body = call_kwargs[0][1]
        assert json_body["session_id"] == "sess123"
        # OTP should be present (may be normalized)
        assert "otp" in json_body

    def test_poll_uses_correct_endpoint(self, mock_poll_success):
        """Poll calls POST /api/auth/cli/poll."""
        from specter_cli.auth import _poll_for_key

        with patch("specter_cli.auth.httpx.post", return_value=mock_poll_success) as mock_post:
            _poll_for_key("https://specter.host", "abc123", "AXHK-3V9M")

        call_url = str(mock_post.call_args)
        assert "/api/auth/cli/poll" in call_url

    def test_poll_invalid_session_404(self, mock_poll_invalid_session):
        """404 from poll (user hasn't authorized yet) raises appropriate error."""
        from specter_cli.auth import _poll_for_key

        with patch("specter_cli.auth.httpx.post", return_value=mock_poll_invalid_session):
            with pytest.raises(Exception) as exc_info:
                _poll_for_key("https://specter.host", "abc123", "AXHK-3V9M")
            err = str(exc_info.value).lower()
            assert any(x in err for x in ("invalid_session", "404", "authorize", "not found"))

    def test_poll_invalid_otp_401(self, mock_poll_invalid_otp):
        """401 from poll (wrong OTP code) raises appropriate error."""
        from specter_cli.auth import _poll_for_key

        with patch("specter_cli.auth.httpx.post", return_value=mock_poll_invalid_otp):
            with pytest.raises(Exception) as exc_info:
                _poll_for_key("https://specter.host", "abc123", "WRONG-CODE")
            err = str(exc_info.value).lower()
            assert any(x in err for x in ("invalid_otp", "401", "invalid", "wrong"))

    def test_poll_expired_410(self, mock_poll_expired):
        """410 from poll (session expired) raises appropriate error."""
        from specter_cli.auth import _poll_for_key

        with patch("specter_cli.auth.httpx.post", return_value=mock_poll_expired):
            with pytest.raises(Exception) as exc_info:
                _poll_for_key("https://specter.host", "abc123", "AXHK-3V9M")
            err = str(exc_info.value).lower()
            assert any(x in err for x in ("expired", "410"))


# ── Test: OTP normalization ──


class TestOTPNormalization:
    """OTP normalization: strip non-alphanumeric, uppercase, return 8 chars.

    _normalize_otp() returns the stripped format (AXHK3V9M) for the API.
    Display formatting (AXHK-3V9M) is the caller's responsibility.
    """

    def test_normalize_uppercase(self):
        """Lowercase OTP should be uppercased."""
        from specter_cli.auth import _normalize_otp

        assert _normalize_otp("axhk3v9m") == "AXHK3V9M"

    def test_normalize_strips_hyphens(self):
        """Hyphens should be stripped."""
        from specter_cli.auth import _normalize_otp

        assert _normalize_otp("AXHK-3V9M") == "AXHK3V9M"

    def test_normalize_strips_spaces(self):
        """Spaces should be stripped."""
        from specter_cli.auth import _normalize_otp

        assert _normalize_otp("AXHK 3V9M") == "AXHK3V9M"

    def test_normalize_strips_extra_hyphens(self):
        """Extra hyphens should be stripped."""
        from specter_cli.auth import _normalize_otp

        assert _normalize_otp("--AXHK--3V9M--") == "AXHK3V9M"

    def test_normalize_mixed_case_and_whitespace(self):
        """Mixed case + whitespace + hyphens should all be normalized."""
        from specter_cli.auth import _normalize_otp

        assert _normalize_otp("  axhk - 3v9m  ") == "AXHK3V9M"

    def test_normalize_already_stripped(self):
        """Already stripped uppercase OTP should pass through unchanged."""
        from specter_cli.auth import _normalize_otp

        assert _normalize_otp("AXHK3V9M") == "AXHK3V9M"

    def test_normalize_lowercase_with_hyphen(self):
        """Common user input: lowercase with hyphen."""
        from specter_cli.auth import _normalize_otp

        assert _normalize_otp("axhk-3v9m") == "AXHK3V9M"

    def test_normalize_result_is_8_chars(self):
        """Normalized OTP should be exactly 8 alphanumeric characters."""
        from specter_cli.auth import _normalize_otp

        result = _normalize_otp("AXHK-3V9M")
        assert len(result) == 8
        assert result.isalnum()


# ── Test: Full login flow ──


class TestDeviceAuthFlow:
    """End-to-end device auth flow (mocked HTTP + input)."""

    def test_successful_login_saves_key(self, mock_initiate_success, mock_poll_success):
        """Full flow: initiate → browser → OTP → poll → key saved."""
        from specter_cli.auth import login_with_browser

        with (
            patch("specter_cli.auth.httpx.post") as mock_post,
            patch("specter_cli.auth.webbrowser.open") as mock_browser,
            patch("builtins.input", return_value="AXHK-3V9M"),
        ):
            mock_post.side_effect = [mock_initiate_success, mock_poll_success]
            login_with_browser()

        # Key should be saved
        token = get_token()
        assert token is not None
        assert token.startswith("sp_live_")

        # Browser should have been opened
        mock_browser.assert_called_once()
        call_url = mock_browser.call_args[0][0]
        assert "cli-auth?session=" in call_url

    def test_login_stores_method(self, mock_initiate_success, mock_poll_success):
        """Auth method should be recorded in auth.json."""
        from specter_cli.auth import login_with_browser

        with (
            patch("specter_cli.auth.httpx.post") as mock_post,
            patch("specter_cli.auth.webbrowser.open"),
            patch("builtins.input", return_value="AXHK-3V9M"),
        ):
            mock_post.side_effect = [mock_initiate_success, mock_poll_success]
            login_with_browser()

        auth_data = load_auth()
        assert auth_data.get("method") in ("device_auth", "browser", "oauth")

    def test_login_browser_open_fails_shows_url(
        self, mock_initiate_success, mock_poll_success, capsys
    ):
        """If browser can't open, URL should be displayed for manual copy."""
        from specter_cli.auth import login_with_browser

        with (
            patch("specter_cli.auth.httpx.post") as mock_post,
            patch("specter_cli.auth.webbrowser.open", side_effect=Exception("no display")),
            patch("builtins.input", return_value="AXHK-3V9M"),
        ):
            mock_post.side_effect = [mock_initiate_success, mock_poll_success]
            login_with_browser()

        captured = capsys.readouterr()
        # URL should be printed for manual access
        combined = captured.out + captured.err
        assert "cli-auth?session=" in combined or "specter.host" in combined

    def test_login_empty_otp_cancels(self, mock_initiate_success):
        """Empty OTP input should cancel login gracefully."""
        import contextlib

        from specter_cli.auth import login_with_browser

        with (
            patch("specter_cli.auth.httpx.post", return_value=mock_initiate_success),
            patch("specter_cli.auth.webbrowser.open"),
            patch("builtins.input", return_value=""),
            contextlib.suppress(SystemExit),
        ):
            login_with_browser()

        assert get_token() is None

    def test_login_ctrl_c_during_input(self, mock_initiate_success):
        """Ctrl+C during OTP input should return cleanly, no token saved."""
        from specter_cli.auth import login_with_browser

        with (
            patch("specter_cli.auth.httpx.post", return_value=mock_initiate_success),
            patch("specter_cli.auth.webbrowser.open"),
            patch("builtins.input", side_effect=KeyboardInterrupt),
        ):
            login_with_browser()  # should return, not raise

        assert get_token() is None

    def test_login_eof_during_input(self, mock_initiate_success):
        """EOF during OTP input (piped stdin) should return cleanly."""
        from specter_cli.auth import login_with_browser

        with (
            patch("specter_cli.auth.httpx.post", return_value=mock_initiate_success),
            patch("specter_cli.auth.webbrowser.open"),
            patch("builtins.input", side_effect=EOFError),
        ):
            login_with_browser()  # should return, not raise

        assert get_token() is None

    def test_login_network_failure_during_initiate(self):
        """Network failure during initiate should show connection error."""
        from specter_cli.auth import login_with_browser

        with (
            patch(
                "specter_cli.auth.httpx.post",
                side_effect=httpx.ConnectError("Connection refused"),
            ),
            pytest.raises((httpx.ConnectError, SystemExit, Exception)),
        ):
            login_with_browser()

        assert get_token() is None

    def test_login_network_failure_during_poll(self, mock_initiate_success):
        """Network failure during poll should not save a token."""
        from specter_cli.auth import login_with_browser

        with (
            patch("specter_cli.auth.httpx.post") as mock_post,
            patch("specter_cli.auth.webbrowser.open"),
            patch("builtins.input", return_value="AXHK-3V9M"),
        ):
            mock_post.side_effect = [
                mock_initiate_success,
                httpx.ConnectError("Connection lost"),
            ]
            with pytest.raises((httpx.ConnectError, SystemExit, Exception)):
                login_with_browser()

        assert get_token() is None

    def test_two_http_calls_made(self, mock_initiate_success, mock_poll_success):
        """Login flow should make exactly 2 HTTP POST calls."""
        from specter_cli.auth import login_with_browser

        with (
            patch("specter_cli.auth.httpx.post") as mock_post,
            patch("specter_cli.auth.webbrowser.open"),
            patch("builtins.input", return_value="AXHK-3V9M"),
        ):
            mock_post.side_effect = [mock_initiate_success, mock_poll_success]
            login_with_browser()

        assert mock_post.call_count == 2
        # First call: initiate
        assert "/api/auth/cli/initiate" in str(mock_post.call_args_list[0])
        # Second call: poll
        assert "/api/auth/cli/poll" in str(mock_post.call_args_list[1])


# ── Test: SPECTER_API_KEY env var ──


class TestApiKeyEnvVar:
    """SPECTER_API_KEY env var should bypass browser auth."""

    def test_env_var_takes_precedence(self, monkeypatch):
        """SPECTER_API_KEY should be returned by require_auth()."""
        from specter_cli.auth import require_auth

        monkeypatch.setenv("SPECTER_API_KEY", "sp_live_env_key_12345")
        assert require_auth() == "sp_live_env_key_12345"

    def test_env_var_over_stored_token(self, monkeypatch):
        """Env var should take precedence over stored auth.json token."""
        save_auth("sp_live_stored_key", method="device_auth")
        monkeypatch.setenv("SPECTER_API_KEY", "sp_live_env_key")

        from specter_cli.auth import require_auth

        assert require_auth() == "sp_live_env_key"

    def test_no_env_var_uses_stored_token(self):
        """Without env var, require_auth() falls back to auth.json."""
        save_auth("sp_live_stored_key", method="device_auth")

        from specter_cli.auth import require_auth

        assert require_auth() == "sp_live_stored_key"

    def test_no_env_var_no_stored_token_exits(self):
        """Without env var or stored token, require_auth() exits."""
        from specter_cli.auth import require_auth

        with pytest.raises(SystemExit) as exc_info:
            require_auth()
        assert exc_info.value.code == 1

    def test_empty_env_var_ignored(self, monkeypatch):
        """Empty SPECTER_API_KEY should be treated as not set."""
        from specter_cli.auth import require_auth

        monkeypatch.setenv("SPECTER_API_KEY", "")
        # No stored token either → should exit
        with pytest.raises(SystemExit):
            require_auth()


# ── Test: Credential file security ──


class TestCredentialSecurity:
    """Auth file should have restricted permissions."""

    def test_auth_file_permissions_after_login(self, mock_initiate_success, mock_poll_success):
        """Auth file should be created with 0o600 permissions."""
        from specter_cli.auth import login_with_browser
        from specter_cli.config import auth_file

        with (
            patch("specter_cli.auth.httpx.post") as mock_post,
            patch("specter_cli.auth.webbrowser.open"),
            patch("builtins.input", return_value="AXHK-3V9M"),
        ):
            mock_post.side_effect = [mock_initiate_success, mock_poll_success]
            login_with_browser()

        af = auth_file()
        assert af.exists()
        mode = af.stat().st_mode & 0o777
        assert mode == 0o600, f"Auth file should be 0o600, got {oct(mode)}"


# ── Test: Login overwrites old credentials ──


class TestAuthOverwrite:
    """New login should replace previously stored credentials."""

    def test_new_login_overwrites_old_key(self, mock_initiate_success, mock_poll_success):
        """A new device auth login should replace the old stored key."""
        # Store an old key manually
        save_auth("sp_live_old_key_data", method="device_auth")
        assert get_token() == "sp_live_old_key_data"

        from specter_cli.auth import login_with_browser

        with (
            patch("specter_cli.auth.httpx.post") as mock_post,
            patch("specter_cli.auth.webbrowser.open"),
            patch("builtins.input", return_value="AXHK-3V9M"),
        ):
            mock_post.side_effect = [mock_initiate_success, mock_poll_success]
            login_with_browser()

        token = get_token()
        assert token.startswith("sp_live_")
        assert token != "sp_live_old_key_data"


# ── Test: Logout ──


class TestLogout:
    """specter logout should clear stored credentials."""

    def test_logout_clears_token(self):
        """After logout, get_token() should return None."""
        save_auth("sp_live_some_key", method="device_auth")
        assert get_token() is not None

        from specter_cli.auth import logout

        logout()
        assert get_token() is None

    def test_logout_when_not_logged_in(self):
        """Logout when not logged in should not raise."""
        from specter_cli.auth import logout

        # Should not raise
        logout()
        assert get_token() is None
