"""Tests for OpenClaw setup and device auth functions."""

import io
import json
import sys
import time
from contextlib import redirect_stdout
from pathlib import Path
from unittest.mock import patch, MagicMock, call

import pytest

import last30days as cli
from lib import setup_wizard


class TestRunOpenclawSetup:
    """Tests for run_openclaw_setup()."""

    @staticmethod
    def _patch_digg_noop(func):
        """Keep OpenClaw setup tests free of real digg-pp-cli / npx side effects."""
        return patch(
            "lib.setup_wizard._install_digg_cli",
            return_value=(False, "no_npx", "", ""),
        )(func)

    @_patch_digg_noop
    @patch("shutil.which")
    def test_all_tools_present_no_keys(self, mock_which, _mock_digg):
        """All CLI tools found, no API keys configured."""
        mock_which.side_effect = lambda cmd: f"/usr/bin/{cmd}"
        config = {}

        result = setup_wizard.run_openclaw_setup(config)

        assert result["yt_dlp"] is True
        assert result["node"] is True
        assert result["python3"] is True
        assert all(v is False for v in result["keys"].values())
        assert result["x_method"] is None
        assert result["digg_cli"] is False
        assert result["digg_action"] == "no_npx"

    @_patch_digg_noop
    @patch("shutil.which")
    def test_missing_tools(self, mock_which, _mock_digg):
        """Some CLI tools missing."""
        def which_side(cmd):
            if cmd == "node":
                return None
            return f"/usr/bin/{cmd}"
        mock_which.side_effect = which_side
        config = {}

        result = setup_wizard.run_openclaw_setup(config)

        assert result["yt_dlp"] is True
        assert result["node"] is False
        assert result["python3"] is True

    @_patch_digg_noop
    @patch("shutil.which")
    def test_keys_detected(self, mock_which, _mock_digg):
        """API keys in config are reported as present."""
        mock_which.return_value = None
        config = {
            "XAI_API_KEY": "xai-abc123",
            "BRAVE_API_KEY": "brav-xyz",
            "SCRAPECREATORS_API_KEY": "",  # empty = falsy
        }

        result = setup_wizard.run_openclaw_setup(config)

        assert result["keys"]["xai"] is True
        assert result["keys"]["brave"] is True
        assert result["keys"]["scrapecreators"] is False

    def test_openclaw_metadata_keeps_scrapecreators_optional(self):
        """OpenClaw metadata should not hard-require the ScrapeCreators key."""
        skill_md = Path(__file__).parent.parent / "skills" / "last30days" / "SKILL.md"
        text = skill_md.read_text(encoding="utf-8")
        assert "SCRAPECREATORS_API_KEY" in text
        expected = (
            "requires:\n"
            "      env: []\n"
            "      optionalEnv:\n"
            "        - SCRAPECREATORS_API_KEY"
        )
        assert expected in text

    @_patch_digg_noop
    @patch("shutil.which")
    def test_x_method_xai(self, mock_which, _mock_digg):
        """x_method is 'xai' when XAI_API_KEY is set."""
        mock_which.return_value = None
        config = {"XAI_API_KEY": "xai-key"}

        result = setup_wizard.run_openclaw_setup(config)

        assert result["x_method"] == "xai"

    @_patch_digg_noop
    @patch("shutil.which")
    def test_x_method_cookies(self, mock_which, _mock_digg):
        """x_method is 'cookies' when AUTH_TOKEN + CT0 are set."""
        mock_which.return_value = None
        config = {"AUTH_TOKEN": "tok", "CT0": "ct0val"}

        result = setup_wizard.run_openclaw_setup(config)

        assert result["x_method"] == "cookies"

    @_patch_digg_noop
    @patch("shutil.which")
    def test_x_method_xai_over_cookies(self, mock_which, _mock_digg):
        """XAI takes priority over cookies for x_method."""
        mock_which.return_value = None
        config = {"XAI_API_KEY": "xai-key", "AUTH_TOKEN": "tok", "CT0": "ct0val"}

        result = setup_wizard.run_openclaw_setup(config)

        assert result["x_method"] == "xai"

    @_patch_digg_noop
    @patch("shutil.which")
    def test_x_method_null_when_nothing(self, mock_which, _mock_digg):
        """x_method is None when no X access configured."""
        mock_which.return_value = None
        config = {}

        result = setup_wizard.run_openclaw_setup(config)

        assert result["x_method"] is None

    @_patch_digg_noop
    @patch("shutil.which")
    def test_output_is_json_serializable(self, mock_which, _mock_digg):
        """Result can be serialized to JSON without errors."""
        mock_which.return_value = "/usr/bin/something"
        config = {"XAI_API_KEY": "k", "OPENAI_API_KEY": "ok"}

        result = setup_wizard.run_openclaw_setup(config)
        serialized = json.dumps(result)
        parsed = json.loads(serialized)

        assert parsed["yt_dlp"] is True
        assert parsed["keys"]["xai"] is True

    @patch("lib.setup_wizard._install_digg_cli")
    @patch("shutil.which")
    def test_digg_cli_on_path(self, mock_which, mock_digg_install):
        """OpenClaw JSON reports digg_cli when PATH resolves digg-pp-cli."""
        mock_which.side_effect = lambda cmd: f"/usr/bin/{cmd}"
        mock_digg_install.return_value = (True, "already_installed", "", "")

        result = setup_wizard.run_openclaw_setup({})

        assert result["digg_cli"] is True
        assert result["digg_action"] == "already_installed"
        assert "digg_path" not in result

    @patch("lib.setup_wizard._install_digg_cli")
    @patch("shutil.which")
    def test_digg_cli_off_path(self, mock_which, mock_digg_install):
        """OpenClaw JSON surfaces off-PATH installs from prior pp-digg setup."""
        mock_which.return_value = None
        mock_digg_install.return_value = (
            False,
            "installed_off_path",
            "",
            "/Users/me/.local/bin/digg-pp-cli",
        )

        result = setup_wizard.run_openclaw_setup({})

        assert result["digg_cli"] is False
        assert result["digg_action"] == "installed_off_path"
        assert result["digg_path"] == "/Users/me/.local/bin/digg-pp-cli"


class TestRunDeviceAuth:
    """Tests for run_device_auth()."""

    @patch("lib.setup_wizard.urlopen")
    def test_success(self, mock_urlopen):
        """Successful device code request returns tuple."""
        resp_data = {
            "device_code": "dc-123",
            "user_code": "ABCD-1234",
            "verification_uri": "https://github.com/login/device",
            "interval": 5,
        }
        mock_resp = MagicMock()
        mock_resp.read.return_value = json.dumps(resp_data).encode()
        mock_resp.__enter__ = lambda s: s
        mock_resp.__exit__ = MagicMock(return_value=False)
        mock_urlopen.return_value = mock_resp

        result = setup_wizard.run_device_auth()

        assert result is not None
        device_code, user_code, verification_uri, interval = result
        assert device_code == "dc-123"
        assert user_code == "ABCD-1234"
        assert verification_uri == "https://github.com/login/device"
        assert interval == 5

    @patch("lib.setup_wizard.urlopen")
    def test_http_error_returns_none(self, mock_urlopen):
        """HTTP error during code request returns None."""
        from urllib.error import HTTPError
        mock_urlopen.side_effect = HTTPError(
            "https://example.com", 500, "Server Error", {}, None
        )

        result = setup_wizard.run_device_auth()
        assert result is None

    @patch("lib.setup_wizard.urlopen")
    def test_missing_device_code_returns_none(self, mock_urlopen):
        """Incomplete response (no device_code) returns None."""
        resp_data = {"user_code": "ABCD-1234"}
        mock_resp = MagicMock()
        mock_resp.read.return_value = json.dumps(resp_data).encode()
        mock_resp.__enter__ = lambda s: s
        mock_resp.__exit__ = MagicMock(return_value=False)
        mock_urlopen.return_value = mock_resp

        result = setup_wizard.run_device_auth()
        assert result is None


class TestPollDeviceAuth:
    """Tests for poll_device_auth()."""

    @patch("lib.setup_wizard.time")
    @patch("lib.setup_wizard.urlopen")
    def test_success_on_second_poll(self, mock_urlopen, mock_time):
        """Returns access_token after initial pending then success."""
        # First call: time check (within deadline), second: after sleep, etc.
        mock_time.time = MagicMock(side_effect=[0, 0, 0, 0])
        mock_time.sleep = MagicMock()

        pending_resp = MagicMock()
        pending_resp.read.return_value = json.dumps({"error": "authorization_pending"}).encode()
        pending_resp.__enter__ = lambda s: s
        pending_resp.__exit__ = MagicMock(return_value=False)

        success_resp = MagicMock()
        success_resp.read.return_value = json.dumps({"access_token": "gho_abc123"}).encode()
        success_resp.__enter__ = lambda s: s
        success_resp.__exit__ = MagicMock(return_value=False)

        mock_urlopen.side_effect = [pending_resp, success_resp]

        result = setup_wizard.poll_device_auth("dc-123", interval=1, timeout=300)
        assert result == "gho_abc123"

    @patch("lib.setup_wizard.time")
    @patch("lib.setup_wizard.urlopen")
    def test_timeout_returns_none(self, mock_urlopen, mock_time):
        """Returns None when timeout is exceeded."""
        # poll_device_auth captures started_at once, derives deadline + last_reminder
        # from it, then checks time.time() in the while-loop. Two values: started_at,
        # then a value past the deadline so the loop exits immediately.
        mock_time.time = MagicMock(side_effect=[0, 301])
        mock_time.sleep = MagicMock()

        result = setup_wizard.poll_device_auth("dc-123", interval=5, timeout=300)
        assert result is None

    @patch("lib.setup_wizard.time")
    @patch("lib.setup_wizard.urlopen")
    def test_expired_token_returns_none(self, mock_urlopen, mock_time):
        """Returns None on expired_token error."""
        # Loop terminates via urlopen response, not the clock — pin time to 0
        # so the deadline check stays a non-event regardless of call count.
        mock_time.time = MagicMock(return_value=0)
        mock_time.sleep = MagicMock()

        expired_resp = MagicMock()
        expired_resp.read.return_value = json.dumps({"error": "expired_token"}).encode()
        expired_resp.__enter__ = lambda s: s
        expired_resp.__exit__ = MagicMock(return_value=False)

        mock_urlopen.return_value = expired_resp

        result = setup_wizard.poll_device_auth("dc-123", interval=1, timeout=300)
        assert result is None

    @patch("lib.setup_wizard.time")
    @patch("lib.setup_wizard.urlopen")
    def test_http_400_continues_polling(self, mock_urlopen, mock_time):
        """HTTP 400 during polling continues (authorization pending)."""
        from urllib.error import HTTPError

        mock_time.time = MagicMock(return_value=0)
        mock_time.sleep = MagicMock()

        success_resp = MagicMock()
        success_resp.read.return_value = json.dumps({"access_token": "gho_ok"}).encode()
        success_resp.__enter__ = lambda s: s
        success_resp.__exit__ = MagicMock(return_value=False)

        mock_urlopen.side_effect = [
            HTTPError("url", 400, "Bad Request", {}, None),
            success_resp,
        ]

        result = setup_wizard.poll_device_auth("dc-123", interval=1, timeout=300)
        assert result == "gho_ok"


class TestFetchApiKey:
    """Tests for fetch_api_key()."""

    @patch("lib.setup_wizard.urlopen")
    def test_success(self, mock_urlopen):
        """Returns api_key from profile response."""
        resp_data = {"api_key": "sc-key-abc123", "username": "testuser"}
        mock_resp = MagicMock()
        mock_resp.read.return_value = json.dumps(resp_data).encode()
        mock_resp.__enter__ = lambda s: s
        mock_resp.__exit__ = MagicMock(return_value=False)
        mock_urlopen.return_value = mock_resp

        result = setup_wizard.fetch_api_key("gho_token")
        assert result == "sc-key-abc123"

    @patch("lib.setup_wizard.urlopen")
    def test_no_api_key_in_response(self, mock_urlopen):
        """Returns None when api_key is not in the response."""
        resp_data = {"username": "testuser"}
        mock_resp = MagicMock()
        mock_resp.read.return_value = json.dumps(resp_data).encode()
        mock_resp.__enter__ = lambda s: s
        mock_resp.__exit__ = MagicMock(return_value=False)
        mock_urlopen.return_value = mock_resp

        result = setup_wizard.fetch_api_key("gho_token")
        assert result is None

    @patch("lib.setup_wizard.urlopen")
    def test_http_error_returns_none(self, mock_urlopen):
        """HTTP error returns None."""
        from urllib.error import HTTPError
        mock_urlopen.side_effect = HTTPError(
            "https://example.com", 401, "Unauthorized", {}, None
        )

        result = setup_wizard.fetch_api_key("bad_token")
        assert result is None


class TestRunFullDeviceAuth:
    """Tests for run_full_device_auth()."""

    @patch("lib.setup_wizard.fetch_api_key")
    @patch("lib.setup_wizard.poll_device_auth")
    @patch("lib.setup_wizard.run_device_auth")
    @patch("webbrowser.open")
    def test_happy_path(self, mock_browser, mock_start, mock_poll, mock_fetch):
        """Full flow succeeds: start -> poll -> fetch -> return api_key."""
        mock_start.return_value = ("dev123", "ABCD-1234", "https://example.com/device", 5)
        mock_poll.return_value = "access_tok"
        mock_fetch.return_value = "sc_live_abc123"

        result = setup_wizard.run_full_device_auth(timeout=10)

        assert result["status"] == "success"
        assert result["api_key"] == "sc_live_abc123"
        assert result["user_code"] == "ABCD-1234"
        mock_browser.assert_called_once_with("https://example.com/device")

    @patch("lib.setup_wizard.run_device_auth")
    def test_start_fails(self, mock_start):
        """Device code request fails -> error status."""
        mock_start.return_value = None

        result = setup_wizard.run_full_device_auth()

        assert result["status"] == "error"
        assert "Failed to start" in result["message"]

    @patch("lib.setup_wizard.poll_device_auth")
    @patch("lib.setup_wizard.run_device_auth")
    @patch("webbrowser.open")
    def test_poll_timeout(self, mock_browser, mock_start, mock_poll):
        """Poll times out -> timeout status with user_code."""
        mock_start.return_value = ("dev123", "WXYZ-5678", "https://example.com/device", 5)
        mock_poll.return_value = None

        result = setup_wizard.run_full_device_auth(timeout=10)

        assert result["status"] == "timeout"
        assert result["user_code"] == "WXYZ-5678"

    @patch("lib.setup_wizard.fetch_api_key")
    @patch("lib.setup_wizard.poll_device_auth")
    @patch("lib.setup_wizard.run_device_auth")
    @patch("webbrowser.open")
    def test_fetch_fails_after_auth(self, mock_browser, mock_start, mock_poll, mock_fetch):
        """Auth succeeds but profile fetch fails -> error status."""
        mock_start.return_value = ("dev123", "CODE-1111", "https://example.com/device", 5)
        mock_poll.return_value = "access_tok"
        mock_fetch.return_value = None

        result = setup_wizard.run_full_device_auth(timeout=10)

        assert result["status"] == "error"
        assert "failed to fetch" in result["message"].lower()

    @patch("lib.setup_wizard.run_device_auth")
    @patch("webbrowser.open")
    def test_browser_open_fails_gracefully(self, mock_browser, mock_start):
        """webbrowser.open raises -> flow continues without crashing."""
        mock_start.return_value = ("dev123", "CODE-2222", "https://example.com/device", 5)
        mock_browser.side_effect = Exception("no display")

        with patch("lib.setup_wizard.poll_device_auth", return_value=None):
            result = setup_wizard.run_full_device_auth(timeout=1)

        # Should not crash, just timeout
        assert result["status"] == "timeout"

    @patch("lib.setup_wizard.run_device_auth")
    @patch("webbrowser.open")
    def test_no_verification_uri_skips_browser(self, mock_browser, mock_start):
        """Empty verification_uri -> browser not opened."""
        mock_start.return_value = ("dev123", "CODE-3333", "", 5)

        with patch("lib.setup_wizard.poll_device_auth", return_value=None):
            setup_wizard.run_full_device_auth(timeout=1)

        mock_browser.assert_not_called()


class TestClipboardDeviceAuth:
    """Tests for clipboard-first behavior in run_full_device_auth()."""

    @patch("lib.setup_wizard.run_device_auth")
    @patch("lib.setup_wizard.poll_device_auth", return_value=None)
    @patch("webbrowser.open")
    @patch("subprocess.run")
    def test_pbcopy_called_on_macos(self, mock_subproc, mock_browser, mock_poll, mock_start):
        """On macOS, pbcopy is called with the user code before browser opens."""
        mock_start.return_value = ("dev123", "CLIP-CODE", "https://github.com/login/device", 5)

        with patch("sys.platform", "darwin"):
            setup_wizard.run_full_device_auth(timeout=1)

        mock_subproc.assert_called_once()
        call_args = mock_subproc.call_args
        assert call_args[0][0] == ["pbcopy"]
        assert call_args[1]["input"] == b"CLIP-CODE"

    @patch("lib.setup_wizard.run_device_auth")
    @patch("lib.setup_wizard.poll_device_auth", return_value=None)
    @patch("webbrowser.open")
    @patch("subprocess.run")
    def test_no_pbcopy_on_linux(self, mock_subproc, mock_browser, mock_poll, mock_start):
        """On Linux, subprocess.run (pbcopy) is not called."""
        mock_start.return_value = ("dev123", "CLIP-CODE", "https://github.com/login/device", 5)

        with patch("sys.platform", "linux"):
            setup_wizard.run_full_device_auth(timeout=1)

        mock_subproc.assert_not_called()

    @patch("lib.setup_wizard.run_device_auth")
    @patch("lib.setup_wizard.poll_device_auth", return_value=None)
    @patch("webbrowser.open")
    @patch("subprocess.run", side_effect=Exception("pbcopy not found"))
    def test_pbcopy_failure_continues(self, mock_subproc, mock_browser, mock_poll, mock_start):
        """pbcopy failing -> flow continues, browser still opens."""
        mock_start.return_value = ("dev123", "CLIP-CODE", "https://github.com/login/device", 5)

        with patch("sys.platform", "darwin"):
            result = setup_wizard.run_full_device_auth(timeout=1)

        # Should not crash, browser still called
        mock_browser.assert_called_once()
        assert result["status"] == "timeout"


class TestRunGithubAuth:
    """Tests for run_github_auth() — device flow only."""

    @patch("lib.setup_wizard.run_full_device_auth")
    def test_goes_to_device_flow(self, mock_device):
        """Setup never forwards a local gh PAT to ScrapeCreators."""
        mock_device.return_value = {
            "status": "success", "method": "device",
            "api_key": "sc_live_deviceOnly",
        }

        result = setup_wizard.run_github_auth(timeout=10)

        assert result["status"] == "success"
        assert result["method"] == "device"
        mock_device.assert_called_once_with(timeout=10)

    @patch("lib.setup_wizard.run_full_device_auth")
    @patch("subprocess.run", side_effect=AssertionError("must not read gh auth token"))
    def test_does_not_shell_out_for_gh_token(self, mock_subproc, mock_device):
        mock_device.return_value = {"status": "timeout", "user_code": "ABCD-1234"}
        result = setup_wizard.run_github_auth(timeout=1)
        assert result["status"] == "timeout"
        mock_subproc.assert_not_called()


class TestSetupGithubCliWiring:
    """Tests for the `setup --github` CLI branch: persist + mask the key."""

    def _run_setup_github(self, tmp_path, monkeypatch):
        """Invoke `setup --github` in-process, return (parsed_json, env_path)."""
        env_path = tmp_path / ".env"
        monkeypatch.setattr(cli.env, "CONFIG_FILE", env_path)
        monkeypatch.setattr(sys, "argv", ["last30days", "setup", "--github"])
        buf = io.StringIO()
        with redirect_stdout(buf):
            rc = cli.main()
        assert rc == 0
        return json.loads(buf.getvalue()), env_path

    @patch("lib.setup_wizard.run_github_auth")
    def test_success_persists_and_masks(self, mock_auth, tmp_path, monkeypatch):
        """Success -> key written to .env, stdout JSON masked, persisted true."""
        mock_auth.return_value = {
            "status": "success", "method": "device",
            "api_key": "sc_live_supersecret9999", "user_code": "ABCD-1234",
        }

        payload, env_path = self._run_setup_github(tmp_path, monkeypatch)

        # Key persisted to disk with the real value
        assert "SCRAPECREATORS_API_KEY=sc_live_supersecret9999" in env_path.read_text()
        # JSON reports persistence and the raw secret never appears in stdout
        assert payload["persisted"] is True
        assert payload["status"] == "success"
        assert payload["api_key"] != "sc_live_supersecret9999"
        assert "supersecret9999" not in json.dumps(payload)
        # Useful non-secret fields survive
        assert payload["user_code"] == "ABCD-1234"

    @patch("lib.setup_wizard.run_github_auth")
    def test_timeout_persists_nothing(self, mock_auth, tmp_path, monkeypatch):
        """Timeout -> no key on disk, persisted false."""
        mock_auth.return_value = {"status": "timeout", "user_code": "WXYZ-5678"}

        payload, env_path = self._run_setup_github(tmp_path, monkeypatch)

        assert payload["persisted"] is False
        assert not env_path.exists()
        assert payload["status"] == "timeout"
