"""
glm-call.py 유닛 테스트

테스트 범위:
  - _parse_env_keys_file: .env 형식 파싱
  - load_api_key: 환경변수 우선순위 및 .env.keys 폴백
  - call_api: HTTP POST 호출, 재시도, 오류 처리
  - build_parser: argparse 구성 확인
  - main: CLI 통합 흐름

외부 의존성:
  - requests.post → unittest.mock.patch 로 모킹
  - time.sleep → unittest.mock.patch 로 모킹 (테스트 속도 보장)
  - 파일 I/O → pytest tmp_path fixture 사용
"""

from __future__ import annotations

import importlib
import json
import sys
import types
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest

# ---------------------------------------------------------------------------
# 모듈 임포트
# ---------------------------------------------------------------------------

# requests 가 설치돼 있지 않은 환경에서도 수집 단계가 통과하도록 guard
try:
    import importlib.util as _ilu

    _spec = _ilu.spec_from_file_location("glm_call", "/home/jay/workspace/tools/glm-call.py")
    glm_call = importlib.util.module_from_spec(_spec)  # type: ignore[arg-type]
    _spec.loader.exec_module(glm_call)  # type: ignore[union-attr]
except Exception as exc:  # pragma: no cover
    glm_call = types.ModuleType("glm_call_stub")
    _IMPORT_ERROR = exc
else:
    _IMPORT_ERROR = None

pytestmark = pytest.mark.skipif(
    _IMPORT_ERROR is not None,
    reason=f"glm-call.py 임포트 실패: {_IMPORT_ERROR}",
)


# ---------------------------------------------------------------------------
# 헬퍼
# ---------------------------------------------------------------------------


def _ok_response(content: str = "응답 텍스트") -> MagicMock:
    """정상 응답 mock 을 반환합니다."""
    mock_resp = MagicMock()
    mock_resp.raise_for_status.return_value = None
    mock_resp.json.return_value = {"choices": [{"message": {"role": "assistant", "content": content}}]}
    return mock_resp


# ---------------------------------------------------------------------------
# 1. _parse_env_keys_file
# ---------------------------------------------------------------------------


class TestParseEnvKeysFile:
    """_parse_env_keys_file 단위 테스트"""

    def test_basic_key_value(self, tmp_path: Path) -> None:
        """KEY="value" 형식을 올바르게 파싱한다."""
        env_file = tmp_path / ".env.keys"
        env_file.write_text('KEY="value"\n', encoding="utf-8")

        result = glm_call._parse_env_keys_file(str(env_file))

        assert result == {"KEY": "value"}

    def test_export_prefix(self, tmp_path: Path) -> None:
        """export KEY=value 형식에서 export 접두어를 제거한다."""
        env_file = tmp_path / ".env.keys"
        env_file.write_text("export MY_KEY=my_value\n", encoding="utf-8")

        result = glm_call._parse_env_keys_file(str(env_file))

        assert result == {"MY_KEY": "my_value"}

    def test_comment_lines_ignored(self, tmp_path: Path) -> None:
        """# 로 시작하는 주석 줄과 빈 줄은 무시한다."""
        env_file = tmp_path / ".env.keys"
        env_file.write_text("# 이것은 주석입니다\n\nVALID_KEY=hello\n", encoding="utf-8")

        result = glm_call._parse_env_keys_file(str(env_file))

        assert "VALID_KEY" in result
        assert len(result) == 1

    def test_double_quote_removal(self, tmp_path: Path) -> None:
        """큰따옴표로 감싼 값의 따옴표를 제거한다."""
        env_file = tmp_path / ".env.keys"
        env_file.write_text('TOKEN="abc123"\n', encoding="utf-8")

        result = glm_call._parse_env_keys_file(str(env_file))

        assert result["TOKEN"] == "abc123"

    def test_single_quote_removal(self, tmp_path: Path) -> None:
        """작은따옴표로 감싼 값의 따옴표를 제거한다."""
        env_file = tmp_path / ".env.keys"
        env_file.write_text("TOKEN='abc123'\n", encoding="utf-8")

        result = glm_call._parse_env_keys_file(str(env_file))

        assert result["TOKEN"] == "abc123"

    def test_file_not_found_returns_empty_dict(self) -> None:
        """파일이 존재하지 않으면 빈 dict 를 반환한다."""
        result = glm_call._parse_env_keys_file("/nonexistent/path/.env.keys")

        assert result == {}

    def test_line_without_equals_ignored(self, tmp_path: Path) -> None:
        """= 가 없는 줄은 무시한다."""
        env_file = tmp_path / ".env.keys"
        env_file.write_text("INVALID_LINE\nVALID=ok\n", encoding="utf-8")

        result = glm_call._parse_env_keys_file(str(env_file))

        assert "INVALID_LINE" not in result
        assert result.get("VALID") == "ok"

    def test_multiple_keys(self, tmp_path: Path) -> None:
        """여러 키를 동시에 파싱한다."""
        env_file = tmp_path / ".env.keys"
        env_file.write_text(
            'GLM_API_KEY="key-xxx"\nexport OTHER_KEY=other_val\n',
            encoding="utf-8",
        )

        result = glm_call._parse_env_keys_file(str(env_file))

        assert result["GLM_API_KEY"] == "key-xxx"
        assert result["OTHER_KEY"] == "other_val"

    def test_value_with_equals_sign(self, tmp_path: Path) -> None:
        """값 안에 = 가 포함된 경우 첫 번째 = 를 기준으로 분리한다."""
        env_file = tmp_path / ".env.keys"
        env_file.write_text("TOKEN=abc=def=ghi\n", encoding="utf-8")

        result = glm_call._parse_env_keys_file(str(env_file))

        assert result["TOKEN"] == "abc=def=ghi"

    def test_empty_file(self, tmp_path: Path) -> None:
        """빈 파일은 빈 dict 를 반환한다."""
        env_file = tmp_path / ".env.keys"
        env_file.write_text("", encoding="utf-8")

        result = glm_call._parse_env_keys_file(str(env_file))

        assert result == {}


# ---------------------------------------------------------------------------
# 2. load_api_key
# ---------------------------------------------------------------------------


class TestLoadApiKey:
    """load_api_key 단위 테스트"""

    def test_env_var_takes_priority_over_file(self, tmp_path: Path) -> None:
        """환경변수가 .env.keys 파일보다 우선한다."""
        env_file = tmp_path / ".env.keys"
        env_file.write_text('GLM_API_KEY="file-key"\n', encoding="utf-8")

        with patch.dict("os.environ", {"GLM_API_KEY": "env-key"}):
            with patch.object(glm_call, "ENV_KEYS_PATH", str(env_file)):
                result = glm_call.load_api_key()

        assert result == "env-key"

    def test_env_var_only(self) -> None:
        """환경변수만 존재할 때 정상적으로 반환한다."""
        with patch.dict("os.environ", {"GLM_API_KEY": "my-api-key"}):
            result = glm_call.load_api_key()

        assert result == "my-api-key"

    def test_fallback_to_env_keys_file(self, tmp_path: Path) -> None:
        """환경변수가 없을 때 .env.keys 파일에서 키를 읽는다."""
        env_file = tmp_path / ".env.keys"
        env_file.write_text('GLM_API_KEY="file-api-key"\n', encoding="utf-8")

        env_without_key = {k: v for k, v in __import__("os").environ.items() if k != "GLM_API_KEY"}
        with patch.dict("os.environ", env_without_key, clear=True):
            with patch.object(glm_call, "ENV_KEYS_PATH", str(env_file)):
                result = glm_call.load_api_key()

        assert result == "file-api-key"

    def test_no_key_anywhere_exits(self, tmp_path: Path) -> None:
        """환경변수도 없고 파일도 없으면 sys.exit(1) 을 호출한다."""
        missing_path = str(tmp_path / "nonexistent.keys")

        env_without_key = {k: v for k, v in __import__("os").environ.items() if k != "GLM_API_KEY"}
        with patch.dict("os.environ", env_without_key, clear=True):
            with patch.object(glm_call, "ENV_KEYS_PATH", missing_path):
                with pytest.raises(SystemExit) as exc_info:
                    glm_call.load_api_key()

        assert exc_info.value.code == 1

    def test_empty_env_var_falls_back_to_file(self, tmp_path: Path) -> None:
        """환경변수가 빈 문자열이면 파일 폴백을 사용한다."""
        env_file = tmp_path / ".env.keys"
        env_file.write_text('GLM_API_KEY="from-file"\n', encoding="utf-8")

        with patch.dict("os.environ", {"GLM_API_KEY": ""}):
            with patch.object(glm_call, "ENV_KEYS_PATH", str(env_file)):
                result = glm_call.load_api_key()

        assert result == "from-file"


# ---------------------------------------------------------------------------
# 3. call_api
# ---------------------------------------------------------------------------


class TestCallApi:
    """call_api 단위 테스트 (requests.post 모킹)"""

    _DEFAULTS = dict(
        api_key="test-key",
        model="glm-5",
        system_prompt="system",
        user_message="hello",
        max_tokens=100,
    )

    def test_successful_response(self) -> None:
        """정상 응답에서 assistant content 를 반환한다."""
        with patch("requests.post", return_value=_ok_response("정상 응답")) as mock_post:
            result = glm_call.call_api(**self._DEFAULTS)

        assert result == "정상 응답"
        mock_post.assert_called_once()

    def test_request_includes_authorization_header(self) -> None:
        """Authorization 헤더에 Bearer 토큰이 포함된다."""
        with patch("requests.post", return_value=_ok_response()) as mock_post:
            glm_call.call_api(**self._DEFAULTS)

        call_kwargs = mock_post.call_args
        headers = call_kwargs.kwargs.get("headers") or call_kwargs[1].get("headers", {})
        assert headers.get("Authorization") == "Bearer test-key"

    def test_timeout_triggers_retry(self) -> None:
        """타임아웃 발생 시 MAX_RETRIES 만큼 재시도 후 exit(1) 한다."""
        import requests as _req

        side_effects = [_req.exceptions.Timeout("timed out")] * (glm_call.MAX_RETRIES + 1)

        with patch("requests.post", side_effect=side_effects):
            with patch("time.sleep"):
                with pytest.raises(SystemExit) as exc_info:
                    glm_call.call_api(**self._DEFAULTS)

        assert exc_info.value.code == 1

    def test_timeout_retries_correct_number_of_times(self) -> None:
        """타임아웃 시 총 호출 횟수가 MAX_RETRIES + 1 이다."""
        import requests as _req

        side_effects = [_req.exceptions.Timeout("timed out")] * (glm_call.MAX_RETRIES + 1)

        with patch("requests.post", side_effect=side_effects) as mock_post:
            with patch("time.sleep"):
                with pytest.raises(SystemExit):
                    glm_call.call_api(**self._DEFAULTS)

        assert mock_post.call_count == glm_call.MAX_RETRIES + 1

    def test_timeout_retry_succeeds_on_second_attempt(self) -> None:
        """첫 번째 타임아웃 후 두 번째 시도에서 성공한다."""
        import requests as _req

        side_effects = [_req.exceptions.Timeout("timed out"), _ok_response("성공")]

        with patch("requests.post", side_effect=side_effects):
            with patch("time.sleep"):
                result = glm_call.call_api(**self._DEFAULTS)

        assert result == "성공"

    def test_http_error_triggers_retry(self) -> None:
        """HTTP 에러 발생 시 재시도 후 exit(1) 한다."""
        import requests as _req

        mock_resp = MagicMock()
        mock_resp.status_code = 500
        mock_resp.text = "Internal Server Error"
        http_exc = _req.exceptions.HTTPError(response=mock_resp)

        side_effects = [http_exc] * (glm_call.MAX_RETRIES + 1)

        with patch("requests.post", side_effect=side_effects):
            with patch("time.sleep"):
                with pytest.raises(SystemExit) as exc_info:
                    glm_call.call_api(**self._DEFAULTS)

        assert exc_info.value.code == 1

    def test_response_parsing_error(self) -> None:
        """choices 가 없는 응답은 파싱 오류로 처리하고 재시도 후 exit(1) 한다."""
        mock_resp = MagicMock()
        mock_resp.raise_for_status.return_value = None
        mock_resp.json.return_value = {"choices": []}  # 빈 choices

        side_effects = [mock_resp] * (glm_call.MAX_RETRIES + 1)

        with patch("requests.post", side_effect=side_effects):
            with patch("time.sleep"):
                with pytest.raises(SystemExit) as exc_info:
                    glm_call.call_api(**self._DEFAULTS)

        assert exc_info.value.code == 1

    def test_content_none_returns_empty_string(self) -> None:
        """응답 content 가 None 이면 빈 문자열을 반환한다."""
        mock_resp = MagicMock()
        mock_resp.raise_for_status.return_value = None
        mock_resp.json.return_value = {"choices": [{"message": {"role": "assistant", "content": None}}]}

        with patch("requests.post", return_value=mock_resp):
            result = glm_call.call_api(**self._DEFAULTS)

        assert result == ""

    def test_sleep_called_on_retry(self) -> None:
        """재시도 시 time.sleep 이 RETRY_DELAY 초로 호출된다."""
        import requests as _req

        side_effects = [
            _req.exceptions.Timeout("timed out"),
            _ok_response("ok"),
        ]

        with patch("requests.post", side_effect=side_effects):
            with patch("time.sleep") as mock_sleep:
                glm_call.call_api(**self._DEFAULTS)

        mock_sleep.assert_called_once_with(glm_call.RETRY_DELAY)

    def test_no_sleep_on_first_attempt(self) -> None:
        """첫 번째 시도(재시도 없음)에서는 time.sleep 을 호출하지 않는다."""
        with patch("requests.post", return_value=_ok_response()):
            with patch("time.sleep") as mock_sleep:
                glm_call.call_api(**self._DEFAULTS)

        mock_sleep.assert_not_called()

    def test_json_decode_error_retries(self) -> None:
        """JSON 디코딩 오류 시 재시도 후 exit(1) 한다."""
        mock_resp = MagicMock()
        mock_resp.raise_for_status.return_value = None
        mock_resp.json.side_effect = json.JSONDecodeError("err", "", 0)

        side_effects = [mock_resp] * (glm_call.MAX_RETRIES + 1)

        with patch("requests.post", side_effect=side_effects):
            with patch("time.sleep"):
                with pytest.raises(SystemExit) as exc_info:
                    glm_call.call_api(**self._DEFAULTS)

        assert exc_info.value.code == 1


# ---------------------------------------------------------------------------
# 4. build_parser
# ---------------------------------------------------------------------------


class TestBuildParser:
    """build_parser 단위 테스트"""

    def test_task_and_task_file_are_mutually_exclusive(self) -> None:
        """--task 와 --task-file 은 동시에 사용할 수 없다."""
        parser = glm_call.build_parser()

        with pytest.raises(SystemExit):
            parser.parse_args(["--task", "hello", "--task-file", "some.txt"])

    def test_one_of_task_or_task_file_required(self) -> None:
        """--task 와 --task-file 중 하나는 반드시 필요하다."""
        parser = glm_call.build_parser()

        with pytest.raises(SystemExit):
            parser.parse_args([])

    def test_default_role_is_general(self) -> None:
        """--role 기본값은 general 이다."""
        parser = glm_call.build_parser()
        args = parser.parse_args(["--task", "hello"])

        assert args.role == "general"

    def test_default_model_is_glm5(self) -> None:
        """--model 기본값은 glm-5 이다."""
        parser = glm_call.build_parser()
        args = parser.parse_args(["--task", "hello"])

        assert args.model == "glm-5"

    def test_default_max_tokens_is_8192(self) -> None:
        """--max-tokens 기본값은 8192 이다."""
        parser = glm_call.build_parser()
        args = parser.parse_args(["--task", "hello"])

        assert args.max_tokens == 8192

    def test_valid_roles(self) -> None:
        """유효한 role 목록이 SYSTEM_PROMPTS 키와 일치한다."""
        parser = glm_call.build_parser()
        expected_roles = set(glm_call.SYSTEM_PROMPTS.keys())

        for role in expected_roles:
            args = parser.parse_args(["--task", "hello", "--role", role])
            assert args.role == role

    def test_invalid_role_exits(self) -> None:
        """정의되지 않은 role 을 지정하면 exit 한다."""
        parser = glm_call.build_parser()

        with pytest.raises(SystemExit):
            parser.parse_args(["--task", "hello", "--role", "nonexistent_role"])

    def test_valid_models(self) -> None:
        """유효한 model 목록이 VALID_MODELS 와 일치한다."""
        parser = glm_call.build_parser()

        for model in glm_call.VALID_MODELS:
            args = parser.parse_args(["--task", "hello", "--model", model])
            assert args.model == model

    def test_invalid_model_exits(self) -> None:
        """정의되지 않은 model 을 지정하면 exit 한다."""
        parser = glm_call.build_parser()

        with pytest.raises(SystemExit):
            parser.parse_args(["--task", "hello", "--model", "gpt-999"])

    def test_task_file_dest(self) -> None:
        """--task-file 은 args.task_file 에 저장된다."""
        parser = glm_call.build_parser()
        args = parser.parse_args(["--task-file", "my_task.txt"])

        assert args.task_file == "my_task.txt"
        assert args.task is None

    def test_output_argument(self) -> None:
        """--output 인수가 올바르게 파싱된다."""
        parser = glm_call.build_parser()
        args = parser.parse_args(["--task", "hello", "--output", "/tmp/out.md"])

        assert args.output == "/tmp/out.md"

    def test_output_default_is_none(self) -> None:
        """--output 을 지정하지 않으면 None 이다."""
        parser = glm_call.build_parser()
        args = parser.parse_args(["--task", "hello"])

        assert args.output is None

    def test_system_prompts_has_expected_keys(self) -> None:
        """SYSTEM_PROMPTS 는 backend, frontend, uxui, tester, general 을 포함한다."""
        expected = {"backend", "frontend", "uxui", "tester", "general"}
        assert expected == set(glm_call.SYSTEM_PROMPTS.keys())

    def test_valid_models_constant(self) -> None:
        """VALID_MODELS 상수가 4개의 모델을 포함한다."""
        assert "glm-5" in glm_call.VALID_MODELS
        assert "glm-4.7" in glm_call.VALID_MODELS
        assert "glm-4.7-flash" in glm_call.VALID_MODELS
        assert "glm-4.7-flashx" in glm_call.VALID_MODELS


# ---------------------------------------------------------------------------
# 5. main 통합 테스트
# ---------------------------------------------------------------------------


class TestMain:
    """main() CLI 통합 테스트 (모킹 기반)"""

    def _run_main(self, argv: list[str]) -> None:
        """sys.argv 를 패치하여 main() 을 실행한다."""
        with patch.object(sys, "argv", ["glm-call"] + argv):
            glm_call.main()

    def test_inline_task_stdout_output(self, capsys: pytest.CaptureFixture) -> None:
        """--task 인라인 입력이 stdout 으로 출력된다."""
        with patch.dict("os.environ", {"GLM_API_KEY": "test-key"}):
            with patch("requests.post", return_value=_ok_response("inline 결과")):
                self._run_main(["--task", "테스트 태스크"])

        captured = capsys.readouterr()
        assert "inline 결과" in captured.out

    def test_task_file_input_stdout_output(self, tmp_path: Path, capsys: pytest.CaptureFixture) -> None:
        """--task-file 파일 입력이 stdout 으로 출력된다."""
        task_file = tmp_path / "task.txt"
        task_file.write_text("파일에서 읽은 태스크", encoding="utf-8")

        with patch.dict("os.environ", {"GLM_API_KEY": "test-key"}):
            with patch("requests.post", return_value=_ok_response("file 결과")):
                self._run_main(["--task-file", str(task_file)])

        captured = capsys.readouterr()
        assert "file 결과" in captured.out

    def test_output_file_saved(self, tmp_path: Path) -> None:
        """--output 지정 시 결과가 파일에 저장된다."""
        output_file = tmp_path / "result.md"

        with patch.dict("os.environ", {"GLM_API_KEY": "test-key"}):
            with patch("requests.post", return_value=_ok_response("저장될 내용")):
                self._run_main(["--task", "태스크", "--output", str(output_file)])

        assert output_file.exists()
        content = output_file.read_text(encoding="utf-8")
        assert "저장될 내용" in content

    def test_nonexistent_task_file_exits(self, tmp_path: Path) -> None:
        """존재하지 않는 --task-file 을 지정하면 exit(1) 한다."""
        missing_file = str(tmp_path / "missing.txt")

        with patch.dict("os.environ", {"GLM_API_KEY": "test-key"}):
            with pytest.raises(SystemExit) as exc_info:
                self._run_main(["--task-file", missing_file])

        assert exc_info.value.code == 1

    def test_empty_task_exits(self) -> None:
        """빈 --task 를 지정하면 exit(1) 한다."""
        with patch.dict("os.environ", {"GLM_API_KEY": "test-key"}):
            with pytest.raises(SystemExit) as exc_info:
                self._run_main(["--task", "   "])

        assert exc_info.value.code == 1

    def test_empty_task_file_exits(self, tmp_path: Path) -> None:
        """내용이 비어 있는 --task-file 은 exit(1) 한다."""
        empty_file = tmp_path / "empty.txt"
        empty_file.write_text("   \n", encoding="utf-8")

        with patch.dict("os.environ", {"GLM_API_KEY": "test-key"}):
            with pytest.raises(SystemExit) as exc_info:
                self._run_main(["--task-file", str(empty_file)])

        assert exc_info.value.code == 1

    def test_role_passed_to_call_api(self) -> None:
        """--role 인수가 call_api 의 system_prompt 에 반영된다."""
        with patch.dict("os.environ", {"GLM_API_KEY": "test-key"}):
            with patch.object(glm_call, "call_api", return_value="mock result") as mock_call:
                self._run_main(["--task", "태스크", "--role", "backend"])

        mock_call.assert_called_once()
        call_kwargs = mock_call.call_args
        system_prompt = call_kwargs.kwargs.get("system_prompt") or call_kwargs[1].get("system_prompt")
        assert system_prompt == glm_call.SYSTEM_PROMPTS["backend"]

    def test_model_passed_to_call_api(self) -> None:
        """--model 인수가 call_api 에 전달된다."""
        with patch.dict("os.environ", {"GLM_API_KEY": "test-key"}):
            with patch.object(glm_call, "call_api", return_value="mock result") as mock_call:
                self._run_main(["--task", "태스크", "--model", "glm-4.7"])

        call_kwargs = mock_call.call_args
        model = call_kwargs.kwargs.get("model") or call_kwargs[1].get("model")
        assert model == "glm-4.7"

    def test_output_newline_appended_if_missing(self, tmp_path: Path) -> None:
        """결과에 개행 문자가 없으면 파일 저장 시 추가된다."""
        output_file = tmp_path / "out.txt"
        # 개행 없는 응답
        with patch.dict("os.environ", {"GLM_API_KEY": "test-key"}):
            with patch("requests.post", return_value=_ok_response("no newline")):
                self._run_main(["--task", "태스크", "--output", str(output_file)])

        content = output_file.read_text(encoding="utf-8")
        assert content.endswith("\n")

    def test_output_stdout_newline_appended(self, capsys: pytest.CaptureFixture) -> None:
        """결과에 개행 문자가 없어도 stdout 출력 시 개행이 추가된다."""
        with patch.dict("os.environ", {"GLM_API_KEY": "test-key"}):
            with patch("requests.post", return_value=_ok_response("no newline")):
                self._run_main(["--task", "태스크"])

        captured = capsys.readouterr()
        assert captured.out.endswith("\n")

    def test_max_tokens_passed_to_call_api(self) -> None:
        """--max-tokens 인수가 call_api 에 전달된다."""
        with patch.dict("os.environ", {"GLM_API_KEY": "test-key"}):
            with patch.object(glm_call, "call_api", return_value="mock result") as mock_call:
                self._run_main(["--task", "태스크", "--max-tokens", "512"])

        call_kwargs = mock_call.call_args
        max_tokens = call_kwargs.kwargs.get("max_tokens") or call_kwargs[1].get("max_tokens")
        assert max_tokens == 512
