import subprocess
import sys
from unittest.mock import MagicMock, patch
from urllib.error import HTTPError
from urllib.request import urlopen

from arcade_cli.authn import OAuthCallbackServer, _open_browser, oauth_callback_server


def test_oauth_callback_server_success() -> None:
    state = "test-state"
    with oauth_callback_server(state, port=0) as server:
        url = f"{server.get_redirect_uri()}?code=abc123&state={state}"
        with urlopen(url) as response:
            assert response.status == 200
            response.read()
        assert server.wait_for_result(timeout=1.0) is True

    assert server.result["code"] == "abc123"


def test_oauth_callback_server_timeout() -> None:
    state = "test-timeout"
    with oauth_callback_server(state, port=0) as server:
        assert server.wait_for_result(timeout=0.05) is False

    assert "Timed out" in server.result["error"]


def test_oauth_callback_server_binds_to_loopback() -> None:
    """The callback server must bind to 127.0.0.1 (loopback) to avoid
    Windows Firewall prompts and keep redirect host aligned with bind host."""
    state = "test-bind"
    with oauth_callback_server(state, port=0) as server:
        assert server.httpd is not None
        host, _port = server.httpd.server_address
        assert host == "127.0.0.1", f"Expected 127.0.0.1 but got {host}"
        # Also confirm the redirect URI host matches the bound loopback host.
        redirect = server.get_redirect_uri()
        assert redirect.startswith("http://127.0.0.1:")
        server.shutdown_server()


def test_oauth_callback_server_state_mismatch() -> None:
    """Requests with a mismatched state parameter should return an error."""
    state = "correct-state"
    with oauth_callback_server(state, port=0) as server:
        url = f"{server.get_redirect_uri()}?code=abc&state=wrong-state"
        try:
            with urlopen(url) as response:
                response.read()
        except HTTPError:
            pass  # Expected — handler returns 400 for state mismatch.
        server.wait_for_result(timeout=1.0)

    assert "error" in server.result


def test_oauth_callback_server_missing_code() -> None:
    """Requests without a code parameter should produce an error result."""
    state = "no-code-state"
    with oauth_callback_server(state, port=0) as server:
        url = f"{server.get_redirect_uri()}?state={state}"
        try:
            with urlopen(url) as response:
                response.read()
        except HTTPError:
            pass  # Expected — handler returns 400 for missing code.
        server.wait_for_result(timeout=1.0)

    assert "error" in server.result


def test_oauth_callback_server_wait_until_ready() -> None:
    """wait_until_ready() should return True once the server is listening."""
    state = "ready-test"
    server = OAuthCallbackServer(state, port=0)

    import threading

    t = threading.Thread(target=server.run_server, daemon=True)
    t.start()

    assert server.wait_until_ready(timeout=5.0) is True
    assert server.httpd is not None
    assert server.port != 0  # Ephemeral port was assigned.

    server.shutdown_server()
    t.join(timeout=2)


def test_oauth_callback_server_wait_until_ready_timeout() -> None:
    """wait_until_ready() should return False if the server never starts."""
    state = "ready-timeout"
    server = OAuthCallbackServer(state, port=0)
    # Don't start the server — ready_event never gets set.
    assert server.wait_until_ready(timeout=0.05) is False


def test_perform_oauth_login_hides_auth_url_when_browser_succeeds() -> None:
    """When browser launch succeeds, status output should not include auth URL."""
    status_messages: list[str] = []

    def capture_status(msg: str) -> None:
        status_messages.append(msg)

    # We need to mock the entire OAuth flow since we can't hit a real coordinator.
    with (
        patch("arcade_cli.authn.fetch_cli_config") as mock_config,
        patch("arcade_cli.authn.create_oauth_client"),
        patch("arcade_cli.authn.generate_authorization_url") as mock_gen_url,
        patch("arcade_cli.authn._open_browser") as mock_browser,
        patch("arcade_cli.authn.oauth_callback_server") as mock_server_ctx,
    ):
        mock_config.return_value = MagicMock()
        mock_gen_url.return_value = ("https://example.com/auth?state=abc", "verifier123")
        mock_browser.return_value = True

        mock_server = MagicMock()
        mock_server.get_redirect_uri.return_value = "http://localhost:9999/callback"
        mock_server.result = {"error": "timeout for test"}
        mock_server.wait_for_result.return_value = False
        mock_server_ctx.return_value.__enter__ = MagicMock(return_value=mock_server)
        mock_server_ctx.return_value.__exit__ = MagicMock(return_value=False)

        from arcade_cli.authn import OAuthLoginError, perform_oauth_login

        try:
            perform_oauth_login(
                "https://fake-coordinator.example.com",
                on_status=capture_status,
                callback_timeout_seconds=1,
            )
        except OAuthLoginError:
            pass

    url_messages = [m for m in status_messages if "https://example.com/auth" in m]
    assert len(url_messages) == 0, (
        "Auth URL should be hidden when browser launch succeeds. "
        f"Got status messages: {status_messages}"
    )


def test_perform_oauth_login_shows_url_when_browser_fails() -> None:
    """When _open_browser fails, the URL should still be shown."""
    status_messages: list[str] = []

    def capture_status(msg: str) -> None:
        status_messages.append(msg)

    with (
        patch("arcade_cli.authn.fetch_cli_config") as mock_config,
        patch("arcade_cli.authn.create_oauth_client"),
        patch("arcade_cli.authn.generate_authorization_url") as mock_gen_url,
        patch("arcade_cli.authn._open_browser") as mock_browser,
        patch("arcade_cli.authn.oauth_callback_server") as mock_server_ctx,
    ):
        mock_config.return_value = MagicMock()
        mock_gen_url.return_value = ("https://example.com/auth?state=xyz", "verifier456")
        mock_browser.return_value = False  # Browser failed

        mock_server = MagicMock()
        mock_server.get_redirect_uri.return_value = "http://localhost:9999/callback"
        mock_server.result = {"error": "timeout for test"}
        mock_server.wait_for_result.return_value = False
        mock_server_ctx.return_value.__enter__ = MagicMock(return_value=mock_server)
        mock_server_ctx.return_value.__exit__ = MagicMock(return_value=False)

        from arcade_cli.authn import OAuthLoginError, perform_oauth_login

        try:
            perform_oauth_login(
                "https://fake-coordinator.example.com",
                on_status=capture_status,
                callback_timeout_seconds=1,
            )
        except OAuthLoginError:
            pass

    url_messages = [m for m in status_messages if "https://example.com/auth" in m]
    assert len(url_messages) >= 1
    assert any("Open this link to log in" in m for m in status_messages)
    # When browser fails, the message should say "Could not open a browser"
    browser_fail_msgs = [m for m in status_messages if "Could not open a browser" in m]
    assert len(browser_fail_msgs) >= 1


def test_perform_oauth_login_timeout_clamps_negative() -> None:
    """Negative --timeout values should be clamped to the default."""
    from arcade_cli.authn import DEFAULT_OAUTH_TIMEOUT_SECONDS

    status_messages: list[str] = []

    def capture_status(msg: str) -> None:
        status_messages.append(msg)

    with (
        patch("arcade_cli.authn.fetch_cli_config") as mock_config,
        patch("arcade_cli.authn.create_oauth_client"),
        patch("arcade_cli.authn.generate_authorization_url") as mock_gen_url,
        patch("arcade_cli.authn._open_browser") as mock_browser,
        patch("arcade_cli.authn.oauth_callback_server") as mock_server_ctx,
    ):
        mock_config.return_value = MagicMock()
        mock_gen_url.return_value = ("https://example.com/auth", "v")
        mock_browser.return_value = True

        mock_server = MagicMock()
        mock_server.get_redirect_uri.return_value = "http://localhost:9999/callback"
        mock_server.result = {"error": "timeout"}
        mock_server.wait_for_result.return_value = False
        mock_server_ctx.return_value.__enter__ = MagicMock(return_value=mock_server)
        mock_server_ctx.return_value.__exit__ = MagicMock(return_value=False)

        from arcade_cli.authn import OAuthLoginError, perform_oauth_login

        try:
            perform_oauth_login(
                "https://fake.example.com",
                on_status=capture_status,
                callback_timeout_seconds=-5,
            )
        except OAuthLoginError:
            pass

    # The timeout message should show the default, not -5.
    timeout_msgs = [m for m in status_messages if "timeout:" in m.lower()]
    assert any(str(DEFAULT_OAUTH_TIMEOUT_SECONDS) in m for m in timeout_msgs), (
        f"Expected default timeout {DEFAULT_OAUTH_TIMEOUT_SECONDS} in messages: {timeout_msgs}"
    )


# ---------------------------------------------------------------------------
# _open_browser() — CMD-window suppression on Windows
# ---------------------------------------------------------------------------


class TestOpenBrowser:
    """Tests for the _open_browser helper that suppresses CMD flash on Windows.

    On Windows the priority order is:
      1. ctypes ShellExecuteW (direct Win32 API, no console)
      2. rundll32 url.dll (GUI binary, no console)
      3. webbrowser.open (stdlib fallback)

    os.startfile is intentionally omitted: it is a thin CPython wrapper around
    ShellExecuteExW, making it redundant with step 1.
    """

    def test_delegates_to_webbrowser_on_non_windows(self) -> None:
        """On non-Windows, _open_browser should use webbrowser.open."""
        with (
            patch.object(sys, "platform", "linux"),
            patch("arcade_cli.authn.webbrowser") as mock_wb,
        ):
            mock_wb.open.return_value = True
            result = _open_browser("https://example.com")
            assert result is True
            mock_wb.open.assert_called_once_with("https://example.com")

    def test_tries_ctypes_shellexecute_first_on_windows(self) -> None:
        """On Windows, _open_browser should try ctypes ShellExecuteW first."""
        import ctypes

        # On non-Windows, ctypes.windll doesn't exist; provide a mock
        mock_shell32 = MagicMock()
        mock_shell32.ShellExecuteW = MagicMock(return_value=42)
        mock_windll = MagicMock()
        mock_windll.shell32 = mock_shell32

        with patch.object(sys, "platform", "win32"), patch.object(
            ctypes, "windll", mock_windll, create=True
        ):
            result = _open_browser("https://example.com")
            assert result is True

    def test_falls_back_to_rundll32_on_windows(self) -> None:
        """If ctypes fails, try rundll32 url.dll."""
        import ctypes

        mock_shell32 = MagicMock()
        mock_shell32.ShellExecuteW = MagicMock(side_effect=Exception("ctypes failed"))
        mock_windll = MagicMock()
        mock_windll.shell32 = mock_shell32

        with (
            patch.object(sys, "platform", "win32"),
            patch.object(ctypes, "windll", mock_windll, create=True),
            patch("arcade_cli.authn.subprocess.Popen") as mock_popen,
            patch("arcade_cli.authn.subprocess.STARTUPINFO", create=True) as mock_si_cls,
            patch("arcade_cli.authn.subprocess.STARTF_USESHOWWINDOW", 1, create=True),
            patch("arcade_cli.authn.subprocess.DEVNULL", subprocess.DEVNULL),
        ):
            mock_si = MagicMock()
            mock_si.dwFlags = 0
            mock_si_cls.return_value = mock_si
            mock_popen.return_value = MagicMock()

            result = _open_browser("https://example.com")
            assert result is True
            mock_popen.assert_called_once()
            cmd = mock_popen.call_args[0][0]
            assert cmd[0] == "rundll32"

    def test_falls_back_to_webbrowser_if_all_else_fails_on_windows(self) -> None:
        """If ctypes and rundll32 both fail, use webbrowser.open (step 3)."""
        import ctypes

        mock_shell32 = MagicMock()
        mock_shell32.ShellExecuteW = MagicMock(side_effect=Exception("ctypes failed"))
        mock_windll = MagicMock()
        mock_windll.shell32 = mock_shell32

        with (
            patch.object(sys, "platform", "win32"),
            patch.object(ctypes, "windll", mock_windll, create=True),
            patch("arcade_cli.authn.subprocess.Popen", side_effect=Exception("fail")),
            patch("arcade_cli.authn.subprocess.STARTUPINFO", create=True, return_value=MagicMock()),
            patch("arcade_cli.authn.subprocess.STARTF_USESHOWWINDOW", 1, create=True),
            patch("arcade_cli.authn.subprocess.DEVNULL", -1),
            patch("arcade_cli.authn.webbrowser") as mock_wb,
        ):
            mock_wb.open.return_value = True
            result = _open_browser("https://example.com")
            assert result is True
            mock_wb.open.assert_called_once()

    def test_returns_false_if_everything_fails(self) -> None:
        """If all methods fail, _open_browser should return False."""
        with (
            patch.object(sys, "platform", "linux"),
            patch("arcade_cli.authn.webbrowser") as mock_wb,
        ):
            mock_wb.open.side_effect = Exception("no browser")
            result = _open_browser("https://example.com")
            assert result is False
