"""Unit tests for Boss CLI commands using Click's test runner and monkeypatch."""

from __future__ import annotations

import json
from unittest.mock import MagicMock, patch

import pytest
from click.testing import CliRunner

from boss_cli.cli import cli

runner = CliRunner()


# ── CLI Basics ──────────────────────────────────────────────────────


class TestCliBasic:
    """Test CLI basics without requiring cookies or network."""

    def test_version(self):
        result = runner.invoke(cli, ["--version"])
        assert result.exit_code == 0
        assert "0." in result.output

    def test_help(self):
        result = runner.invoke(cli, ["--help"])
        assert result.exit_code == 0
        assert "BOSS 直聘" in result.output

    def test_all_commands_registered(self):
        result = runner.invoke(cli, ["--help"])
        expected = [
            "login", "status", "logout", "me",
            "search", "recommend", "cities", "detail", "show", "export", "history",
            "applied", "interviews",
            "chat", "greet", "batch-greet",
        ]
        for cmd in expected:
            assert cmd in result.output, f"Command '{cmd}' not found in CLI help"


class TestCommandHelp:
    """Verify every command has --help without errors."""

    @pytest.mark.parametrize("cmd", [
        "login", "logout", "status", "me",
        "search", "recommend", "cities", "detail", "show", "export", "history",
        "applied", "interviews",
        "chat", "greet", "batch-greet",
    ])
    def test_help(self, cmd: str):
        result = runner.invoke(cli, [cmd, "--help"])
        assert result.exit_code == 0, f"{cmd} --help failed: {result.output}"

    def test_search_has_filter_options(self):
        result = runner.invoke(cli, ["search", "--help"])
        assert "--city" in result.output
        assert "--salary" in result.output
        assert "--exp" in result.output
        assert "--degree" in result.output

    def test_me_has_output_options(self):
        result = runner.invoke(cli, ["me", "--help"])
        assert "--json" in result.output
        assert "--yaml" in result.output

    def test_batch_greet_has_options(self):
        result = runner.invoke(cli, ["batch-greet", "--help"])
        assert "--dry-run" in result.output
        assert "--count" in result.output or "-n" in result.output
        assert "--yes" in result.output or "-y" in result.output

    def test_greet_has_output_options(self):
        result = runner.invoke(cli, ["greet", "--help"])
        assert "--json" in result.output
        assert "--yaml" in result.output

    def test_login_has_cookie_source(self):
        result = runner.invoke(cli, ["login", "--help"])
        assert "--cookie-source" in result.output
        assert "--qrcode" in result.output

    def test_history_has_options(self):
        result = runner.invoke(cli, ["history", "--help"])
        assert "--page" in result.output or "-p" in result.output
        assert "--json" in result.output


# ── Auth commands (mocked) ──────────────────────────────────────────


class TestAuthCommands:
    """Test auth commands with mocked credentials."""

    def test_status_without_auth(self):
        with patch("boss_cli.auth.get_credential", return_value=None):
            result = runner.invoke(cli, ["status"])
            assert result.exit_code == 0
            assert "未登录" in result.output

    def test_status_with_auth(self):
        mock_cred = MagicMock()
        mock_cred.cookies = {"a": "1", "b": "2"}
        with patch("boss_cli.auth.get_credential", return_value=mock_cred):
            result = runner.invoke(cli, ["status"])
            assert result.exit_code == 0
            assert "已登录" in result.output

    def test_status_json(self):
        mock_cred = MagicMock()
        mock_cred.cookies = {"a": "1"}
        with patch("boss_cli.auth.get_credential", return_value=mock_cred):
            result = runner.invoke(cli, ["status", "--json"])
            assert result.exit_code == 0
            data = json.loads(result.output)
            assert data["authenticated"] is True

    def test_me_without_auth(self):
        with patch("boss_cli.commands._common.get_credential", return_value=None):
            result = runner.invoke(cli, ["me"])
            assert result.exit_code == 1
            assert "未登录" in result.output

    def test_logout(self):
        with patch("boss_cli.auth.clear_credential"):
            result = runner.invoke(cli, ["logout"])
            assert result.exit_code == 0
            assert "已退出" in result.output


# ── Personal commands (mocked) ──────────────────────────────────────


class TestPersonalCommands:
    """Test personal center commands with mocked client."""

    def test_me_render(self):
        mock_cred = MagicMock()
        mock_cred.cookies = {"wt2": "x"}

        mock_data = {
            "name": "张三", "gender": 1, "age": "25岁",
            "degreeCategory": "本科", "account": "138****1234",
        }

        with patch("boss_cli.commands._common.get_credential", return_value=mock_cred), \
             patch("boss_cli.commands._common.BossClient") as MockClient:
            mock_instance = MagicMock()
            mock_instance.get_resume_baseinfo.return_value = mock_data
            mock_instance.__enter__ = MagicMock(return_value=mock_instance)
            mock_instance.__exit__ = MagicMock(return_value=False)
            MockClient.return_value = mock_instance

            result = runner.invoke(cli, ["me"])
            assert result.exit_code == 0
            # CliRunner is non-TTY so auto-outputs JSON
            assert "张三" in result.output

    def test_me_json(self):
        mock_cred = MagicMock()
        mock_cred.cookies = {"wt2": "x"}

        mock_data = {"name": "张三", "gender": 1, "age": "25岁"}

        with patch("boss_cli.commands._common.get_credential", return_value=mock_cred), \
             patch("boss_cli.commands._common.BossClient") as MockClient:
            mock_instance = MagicMock()
            mock_instance.get_resume_baseinfo.return_value = mock_data
            mock_instance.__enter__ = MagicMock(return_value=mock_instance)
            mock_instance.__exit__ = MagicMock(return_value=False)
            MockClient.return_value = mock_instance

            result = runner.invoke(cli, ["me", "--json"])
            assert result.exit_code == 0
            data = json.loads(result.output)
            assert data["ok"] is True
            assert data["data"]["name"] == "张三"

    def test_applied_without_auth(self):
        with patch("boss_cli.commands._common.get_credential", return_value=None):
            result = runner.invoke(cli, ["applied"])
            assert result.exit_code == 1

    def test_interviews_without_auth(self):
        with patch("boss_cli.commands._common.get_credential", return_value=None):
            result = runner.invoke(cli, ["interviews"])
            assert result.exit_code == 1


# ── Cities ──────────────────────────────────────────────────────────


class TestCities:
    """Test cities command output."""

    def test_cities_output(self):
        result = runner.invoke(cli, ["cities"])
        assert result.exit_code == 0
        assert "北京" in result.output
        assert "上海" in result.output
        assert "杭州" in result.output
        assert "101010100" in result.output


# ── City resolution logic ───────────────────────────────────────────


class TestCityResolution:
    """Test city name to code resolution."""

    def test_resolve_known_city(self):
        from boss_cli.client import resolve_city
        assert resolve_city("北京") == "101010100"
        assert resolve_city("上海") == "101020100"
        assert resolve_city("杭州") == "101210100"

    def test_resolve_unknown_city_returns_nationwide(self):
        from boss_cli.client import resolve_city
        assert resolve_city("不存在的城市") == "100010000"

    def test_resolve_code_passthrough(self):
        from boss_cli.client import resolve_city
        assert resolve_city("101010100") == "101010100"

    def test_list_cities(self):
        from boss_cli.client import list_cities
        cities = list_cities()
        assert len(cities) > 30
        assert "北京" in cities
        assert "杭州" in cities


# ── Constants ───────────────────────────────────────────────────────


class TestConstants:
    """Test constants are properly defined."""

    def test_salary_codes(self):
        from boss_cli.constants import SALARY_CODES
        assert len(SALARY_CODES) >= 8
        assert "20-30K" in SALARY_CODES

    def test_exp_codes(self):
        from boss_cli.constants import EXP_CODES
        assert len(EXP_CODES) >= 7
        assert "3-5年" in EXP_CODES

    def test_degree_codes(self):
        from boss_cli.constants import DEGREE_CODES
        assert len(DEGREE_CODES) >= 5
        assert "本科" in DEGREE_CODES

    def test_api_urls_defined(self):
        from boss_cli import constants
        assert constants.JOB_SEARCH_URL
        assert constants.JOB_RECOMMEND_URL
        assert constants.JOB_DETAIL_URL
        assert constants.DELIVER_LIST_URL
        assert constants.INTERVIEW_DATA_URL
        assert constants.FRIEND_LIST_URL
        assert constants.USER_INFO_URL
        assert constants.RESUME_BASEINFO_URL


# ── Credential ──────────────────────────────────────────────────────


class TestCredential:
    """Test credential management."""

    def test_credential_creation(self):
        from boss_cli.auth import Credential
        cred = Credential(cookies={"foo": "bar", "baz": "qux"})
        assert cred.is_valid
        assert cred.cookies == {"foo": "bar", "baz": "qux"}

    def test_credential_empty(self):
        from boss_cli.auth import Credential
        cred = Credential(cookies={})
        assert not cred.is_valid

    def test_credential_serialization(self):
        from boss_cli.auth import Credential
        cred = Credential(cookies={"a": "1"})
        data = cred.to_dict()
        assert "cookies" in data
        assert "saved_at" in data

        cred2 = Credential.from_dict(data)
        assert cred2.cookies == cred.cookies

    def test_cookie_header(self):
        from boss_cli.auth import Credential
        cred = Credential(cookies={"a": "1", "b": "2"})
        header = cred.as_cookie_header()
        assert "a=1" in header
        assert "b=2" in header


# ── Exceptions ──────────────────────────────────────────────────────


class TestExceptions:
    """Test custom exception hierarchy."""

    def test_boss_api_error(self):
        from boss_cli.exceptions import BossApiError
        err = BossApiError("test error", code=42, response={"a": 1})
        assert err.code == 42
        assert err.response == {"a": 1}
        assert "test error" in str(err)

    def test_session_expired_error(self):
        from boss_cli.exceptions import SessionExpiredError
        err = SessionExpiredError()
        assert err.code == 37
        assert "stoken" in str(err)

    def test_auth_required_error(self):
        from boss_cli.exceptions import AuthRequiredError
        err = AuthRequiredError()
        assert "登录" in str(err)

    def test_rate_limit_error(self):
        from boss_cli.exceptions import RateLimitError
        err = RateLimitError()
        assert "频繁" in str(err)

    def test_param_error(self):
        from boss_cli.exceptions import ParamError
        err = ParamError("missing field", code=17)
        assert err.code == 17

    def test_error_code_mapping(self):
        from boss_cli.exceptions import (
            AuthRequiredError,
            BossApiError,
            RateLimitError,
            SessionExpiredError,
            error_code_for_exception,
        )
        assert error_code_for_exception(SessionExpiredError()) == "not_authenticated"
        assert error_code_for_exception(AuthRequiredError()) == "not_authenticated"
        assert error_code_for_exception(RateLimitError()) == "rate_limited"
        assert error_code_for_exception(BossApiError("test")) == "api_error"
        assert error_code_for_exception(ValueError("test")) == "unknown_error"


# ── Client ──────────────────────────────────────────────────────────


class TestClient:
    """Test client initialization and helpers."""

    def test_client_context_manager(self):
        from boss_cli.auth import Credential
        from boss_cli.client import BossClient

        cred = Credential(cookies={"test": "value"})
        with BossClient(cred) as client:
            assert client.client is not None
            assert client._request_count == 0

    def test_client_not_initialized_error(self):
        from boss_cli.client import BossClient
        client = BossClient()
        with pytest.raises(RuntimeError, match="Client not initialized"):
            _ = client.client

    def test_handle_response_success(self):
        from boss_cli.auth import Credential
        from boss_cli.client import BossClient

        cred = Credential(cookies={})
        with BossClient(cred) as client:
            data = {"code": 0, "zpData": {"key": "value"}}
            result = client._handle_response(data, "test")
            assert result == {"key": "value"}

    def test_handle_response_session_expired(self):
        from boss_cli.auth import Credential
        from boss_cli.client import BossClient
        from boss_cli.exceptions import SessionExpiredError

        cred = Credential(cookies={})
        with BossClient(cred) as client:
            data = {"code": 37, "message": "env error"}
            with pytest.raises(SessionExpiredError):
                client._handle_response(data, "test")

    def test_handle_response_param_error(self):
        from boss_cli.auth import Credential
        from boss_cli.client import BossClient
        from boss_cli.exceptions import ParamError

        cred = Credential(cookies={})
        with BossClient(cred) as client:
            data = {"code": 17, "message": "missing param"}
            with pytest.raises(ParamError):
                client._handle_response(data, "test")

    def test_handle_response_generic_error(self):
        from boss_cli.auth import Credential
        from boss_cli.client import BossClient
        from boss_cli.exceptions import BossApiError

        cred = Credential(cookies={})
        with BossClient(cred) as client:
            data = {"code": 999, "message": "unknown"}
            with pytest.raises(BossApiError):
                client._handle_response(data, "test")


# ── Index Cache ─────────────────────────────────────────────────────


class TestIndexCache:
    """Test short-index cache system."""

    def test_save_and_get(self, tmp_path, monkeypatch):
        from boss_cli import index_cache
        monkeypatch.setattr(index_cache, "INDEX_CACHE_FILE", tmp_path / "index_cache.json")
        monkeypatch.setattr(index_cache, "CONFIG_DIR", tmp_path)

        jobs = [
            {"securityId": "abc123", "jobName": "Go Dev", "brandName": "Company A"},
            {"securityId": "def456", "jobName": "Python Dev", "brandName": "Company B"},
        ]
        index_cache.save_index(jobs, source="test")

        result = index_cache.get_job_by_index(1)
        assert result is not None
        assert result["securityId"] == "abc123"
        assert result["jobName"] == "Go Dev"

        result2 = index_cache.get_job_by_index(2)
        assert result2 is not None
        assert result2["securityId"] == "def456"

    def test_get_out_of_range(self, tmp_path, monkeypatch):
        from boss_cli import index_cache
        monkeypatch.setattr(index_cache, "INDEX_CACHE_FILE", tmp_path / "index_cache.json")
        monkeypatch.setattr(index_cache, "CONFIG_DIR", tmp_path)

        jobs = [{"securityId": "abc", "jobName": "Test"}]
        index_cache.save_index(jobs, source="test")
        assert index_cache.get_job_by_index(99) is None

    def test_get_no_cache(self, tmp_path, monkeypatch):
        from boss_cli import index_cache
        monkeypatch.setattr(index_cache, "INDEX_CACHE_FILE", tmp_path / "nonexistent.json")
        assert index_cache.get_job_by_index(1) is None

    def test_get_index_info(self, tmp_path, monkeypatch):
        from boss_cli import index_cache
        monkeypatch.setattr(index_cache, "INDEX_CACHE_FILE", tmp_path / "index_cache.json")
        monkeypatch.setattr(index_cache, "CONFIG_DIR", tmp_path)

        info = index_cache.get_index_info()
        assert not info["exists"]

        jobs = [{"securityId": "x", "jobName": "T"}]
        index_cache.save_index(jobs)
        info = index_cache.get_index_info()
        assert info["exists"]
        assert info["count"] == 1

    def test_zero_and_negative_index(self, tmp_path, monkeypatch):
        from boss_cli import index_cache
        monkeypatch.setattr(index_cache, "INDEX_CACHE_FILE", tmp_path / "index_cache.json")
        assert index_cache.get_job_by_index(0) is None
        assert index_cache.get_job_by_index(-1) is None


# ── Show command ────────────────────────────────────────────────────


class TestShowCommand:
    """Test show command behavior without network."""

    def test_show_no_cache(self, tmp_path, monkeypatch):
        from boss_cli import index_cache
        monkeypatch.setattr(index_cache, "INDEX_CACHE_FILE", tmp_path / "nonexistent.json")
        result = runner.invoke(cli, ["show", "1"])
        assert result.exit_code == 0
        assert "暂无缓存" in result.output

    def test_show_out_of_range(self, tmp_path, monkeypatch):
        from boss_cli import index_cache
        monkeypatch.setattr(index_cache, "INDEX_CACHE_FILE", tmp_path / "index_cache.json")
        monkeypatch.setattr(index_cache, "CONFIG_DIR", tmp_path)
        index_cache.save_index([{"securityId": "x", "jobName": "T"}])
        result = runner.invoke(cli, ["show", "99"])
        assert result.exit_code == 0
        assert "超出范围" in result.output


# ── Detail / Export help ────────────────────────────────────────────


class TestNewCommandHelp:
    """Test help output for new commands."""

    def test_detail_help(self):
        result = runner.invoke(cli, ["detail", "--help"])
        assert result.exit_code == 0
        assert "securityId" in result.output
        assert "--json" in result.output

    def test_show_help(self):
        result = runner.invoke(cli, ["show", "--help"])
        assert result.exit_code == 0
        assert "编号" in result.output

    def test_export_help(self):
        result = runner.invoke(cli, ["export", "--help"])
        assert result.exit_code == 0
        assert "--format" in result.output
        assert "csv" in result.output
        assert "--output" in result.output

    def test_history_help(self):
        result = runner.invoke(cli, ["history", "--help"])
        assert result.exit_code == 0
        assert "浏览历史" in result.output


# ── Search mock test ────────────────────────────────────────────────


class TestSearchMock:
    """Test search command with mocked client."""

    def test_search_json(self):
        mock_cred = MagicMock()
        mock_cred.cookies = {"wt2": "x"}

        mock_data = {"jobList": [{"jobName": "Go Dev", "securityId": "abc"}], "hasMore": False}

        with patch("boss_cli.commands._common.get_credential", return_value=mock_cred), \
             patch("boss_cli.commands._common.BossClient") as MockClient:
            mock_instance = MagicMock()
            mock_instance.search_jobs.return_value = mock_data
            mock_instance.__enter__ = MagicMock(return_value=mock_instance)
            mock_instance.__exit__ = MagicMock(return_value=False)
            MockClient.return_value = mock_instance

            result = runner.invoke(cli, ["search", "golang", "--json"])
            assert result.exit_code == 0
            data = json.loads(result.output)
            assert data["ok"] is True
            assert "data" in data

    def test_search_new_filter_help(self):
        """Verify new filter options appear in search --help."""
        result = runner.invoke(cli, ["search", "--help"])
        assert result.exit_code == 0
        for opt in ("--industry", "--scale", "--stage", "--job-type"):
            assert opt in result.output, f"{opt} missing from search --help"

    def test_search_mock_with_filters(self):
        """Verify new filter params are forwarded to client.search_jobs()."""
        mock_cred = MagicMock()
        mock_cred.cookies = {"wt2": "x"}

        mock_data = {"jobList": [{"jobName": "Eng", "securityId": "x"}], "hasMore": False}

        with patch("boss_cli.commands._common.get_credential", return_value=mock_cred), \
             patch("boss_cli.commands._common.BossClient") as MockClient:
            mock_instance = MagicMock()
            mock_instance.search_jobs.return_value = mock_data
            mock_instance.__enter__ = MagicMock(return_value=mock_instance)
            mock_instance.__exit__ = MagicMock(return_value=False)
            MockClient.return_value = mock_instance

            result = runner.invoke(cli, [
                "search", "Python", "--json",
                "--industry", "互联网",
                "--scale", "1000-9999人",
                "--stage", "已上市",
                "--job-type", "全职",
            ])
            assert result.exit_code == 0
            # Verify the call was made with the new params
            call_kwargs = mock_instance.search_jobs.call_args
            assert call_kwargs.kwargs.get("industry") == "100020"
            assert call_kwargs.kwargs.get("scale") == "305"
            assert call_kwargs.kwargs.get("stage") == "807"
            assert call_kwargs.kwargs.get("job_type") == "1901"

    def test_export_new_filter_help(self):
        """Verify new filter options appear in export --help."""
        result = runner.invoke(cli, ["export", "--help"])
        assert result.exit_code == 0
        for opt in ("--industry", "--scale", "--stage", "--job-type"):
            assert opt in result.output, f"{opt} missing from export --help"


# ── Constants test ──────────────────────────────────────────────────


class TestNewConstants:
    """Test that new filter code mappings are correct."""

    def test_industry_codes_present(self):
        from boss_cli.constants import INDUSTRY_CODES
        assert len(INDUSTRY_CODES) >= 10
        assert "互联网" in INDUSTRY_CODES
        assert "人工智能" in INDUSTRY_CODES

    def test_scale_codes_present(self):
        from boss_cli.constants import SCALE_CODES
        assert len(SCALE_CODES) >= 6
        assert "1000-9999人" in SCALE_CODES

    def test_stage_codes_present(self):
        from boss_cli.constants import STAGE_CODES
        assert len(STAGE_CODES) >= 8
        assert "已上市" in STAGE_CODES
        assert "A轮" in STAGE_CODES

    def test_job_type_codes_present(self):
        from boss_cli.constants import JOB_TYPE_CODES
        assert len(JOB_TYPE_CODES) >= 3
        assert "全职" in JOB_TYPE_CODES


class TestSchemaEnvelope:
    """Test that structured output uses the schema envelope."""

    def test_status_json_has_no_envelope(self):
        """status uses direct output, not envelope (for backward compat)."""
        mock_cred = MagicMock()
        mock_cred.cookies = {"a": "1"}
        with patch("boss_cli.auth.get_credential", return_value=mock_cred):
            result = runner.invoke(cli, ["status", "--json"])
            assert result.exit_code == 0
            data = json.loads(result.output)
            assert data["authenticated"] is True

    def test_me_json_has_envelope(self):
        """me command uses handle_command, should have envelope."""
        mock_cred = MagicMock()
        mock_cred.cookies = {"wt2": "x"}

        mock_data = {"name": "张三", "gender": 1}

        with patch("boss_cli.commands._common.get_credential", return_value=mock_cred), \
             patch("boss_cli.commands._common.BossClient") as MockClient:
            mock_instance = MagicMock()
            mock_instance.get_resume_baseinfo.return_value = mock_data
            mock_instance.__enter__ = MagicMock(return_value=mock_instance)
            mock_instance.__exit__ = MagicMock(return_value=False)
            MockClient.return_value = mock_instance

            result = runner.invoke(cli, ["me", "--json"])
            assert result.exit_code == 0
            data = json.loads(result.output)
            assert data["ok"] is True
            assert data["schema_version"] == "1"
            assert data["data"]["name"] == "张三"

