import io
import json
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, call, patch

import ida_analyze_bin


def _tool_result(payload):
    return SimpleNamespace(content=[SimpleNamespace(text=json.dumps(payload))])


class TestQuitIdaGracefully(unittest.IsolatedAsyncioTestCase):
    async def test_quit_ida_gracefully_async_quits_and_waits_for_process(self) -> None:
        process = MagicMock()
        process.poll.return_value = None
        process.wait.return_value = 0

        with patch.object(
            ida_analyze_bin,
            "quit_ida_via_mcp",
            AsyncMock(return_value=True),
        ) as quit_ida_via_mcp:
            await ida_analyze_bin.quit_ida_gracefully_async(
                process,
                "127.0.0.1",
                13337,
                debug=False,
            )

        quit_ida_via_mcp.assert_awaited_once_with("127.0.0.1", 13337)
        process.wait.assert_called_once_with(timeout=10)
        process.kill.assert_not_called()

    async def test_quit_ida_gracefully_rejects_running_loop(self) -> None:
        process = MagicMock()
        process.poll.return_value = None

        with self.assertRaisesRegex(
            RuntimeError,
            "use await quit_ida_gracefully_async\\(\\) instead",
        ):
            ida_analyze_bin.quit_ida_gracefully(
                process,
                "127.0.0.1",
                13337,
                debug=False,
            )


class TestQuitIdaGracefullySyncWrapper(unittest.TestCase):
    def test_quit_ida_gracefully_runs_async_helper_from_sync_context(self) -> None:
        process = MagicMock()
        process.poll.return_value = None

        with patch.object(
            ida_analyze_bin,
            "quit_ida_gracefully_async",
            AsyncMock(),
        ) as quit_ida_gracefully_async:
            ida_analyze_bin.quit_ida_gracefully(
                process,
                "127.0.0.1",
                13337,
                debug=True,
            )

        quit_ida_gracefully_async.assert_awaited_once_with(
            process,
            "127.0.0.1",
            13337,
            debug=True,
        )


class TestSurveyBinaryViaSession(unittest.IsolatedAsyncioTestCase):
    async def test_survey_binary_via_session_falls_back_to_current_idb_path(self) -> None:
        session = MagicMock()
        session.call_tool = AsyncMock(
            side_effect=[
                RuntimeError("Invalid structured content returned by tool survey_binary"),
                _tool_result(
                    {
                        "result": json.dumps(
                            {
                                "metadata": {
                                    "path": "/mnt/d/CS2_VibeSignatures/bin/14141c/server/libserver.so.i64"
                                }
                            }
                        ),
                        "stdout": "",
                        "stderr": "",
                    }
                ),
            ]
        )

        result = await ida_analyze_bin.survey_binary_via_session(session, detail_level="minimal")

        self.assertEqual(
            {"metadata": {"path": "/mnt/d/CS2_VibeSignatures/bin/14141c/server/libserver.so.i64"}},
            result,
        )
        self.assertEqual(
            [
                call(name="survey_binary", arguments={"detail_level": "minimal"}),
                call(name="py_eval", arguments={"code": ida_analyze_bin.SURVEY_CURRENT_IDB_PATH_PY_EVAL}),
            ],
            session.call_tool.await_args_list,
        )

    async def test_survey_binary_via_session_prefers_current_idb_path_over_stale_binary_path(self) -> None:
        session = MagicMock()
        session.call_tool = AsyncMock(
            side_effect=[
                _tool_result(
                    {
                        "metadata": {
                            "path": "/old/location/bin/14141c/server/libserver.so",
                            "module": "libserver.so",
                        },
                        "statistics": {"total_functions": 123},
                    }
                ),
                _tool_result(
                    {
                        "result": json.dumps(
                            {
                                "metadata": {
                                    "path": "/new/location/bin/14141c/server/libserver.so.i64"
                                }
                            }
                        ),
                        "stdout": "",
                        "stderr": "",
                    }
                ),
            ]
        )

        result = await ida_analyze_bin.survey_binary_via_session(session, detail_level="minimal")

        self.assertEqual(
            {
                "metadata": {
                    "path": "/new/location/bin/14141c/server/libserver.so.i64",
                    "module": "libserver.so",
                },
                "statistics": {"total_functions": 123},
            },
            result,
        )
        self.assertEqual(
            [
                call(name="survey_binary", arguments={"detail_level": "minimal"}),
                call(name="py_eval", arguments={"code": ida_analyze_bin.SURVEY_CURRENT_IDB_PATH_PY_EVAL}),
            ],
            session.call_tool.await_args_list,
        )


class TestResolveArtifactPath(unittest.TestCase):
    def test_resolve_artifact_path_keeps_current_module_artifacts_local(self) -> None:
        binary_dir = str(Path('/tmp/bin/14141/networksystem'))

        resolved = ida_analyze_bin.resolve_artifact_path(
            binary_dir,
            'CNetChan_vtable.{platform}.yaml',
            'linux',
        )

        self.assertEqual(
            str(Path('/tmp/bin/14141/networksystem/CNetChan_vtable.linux.yaml').resolve()),
            resolved,
        )

    def test_resolve_artifact_path_supports_sibling_module_reference(self) -> None:
        binary_dir = str(Path('/tmp/bin/14141/networksystem'))

        resolved = ida_analyze_bin.resolve_artifact_path(
            binary_dir,
            '../server/CFlattenedSerializers_CreateFieldChangedEventQueue.{platform}.yaml',
            'windows',
        )

        self.assertEqual(
            str(
                Path(
                    '/tmp/bin/14141/server/'
                    'CFlattenedSerializers_CreateFieldChangedEventQueue.windows.yaml'
                ).resolve()
            ),
            resolved,
        )

    def test_resolve_artifact_path_rejects_escape_outside_gamever_root(self) -> None:
        binary_dir = str(Path('/tmp/bin/14141/networksystem'))

        with self.assertRaises(ValueError):
            ida_analyze_bin.resolve_artifact_path(
                binary_dir,
                '../../outside/secret.{platform}.yaml',
                'windows',
            )


class TestResolveArtifactPathIntegration(unittest.TestCase):
    def test_expand_expected_paths_delegates_to_resolver(self) -> None:
        binary_dir = str(Path('/tmp/bin/14141/networksystem'))
        expected_paths = [
            'CNetChan_vtable.{platform}.yaml',
            '../server/CFlattenedSerializers_CreateFieldChangedEventQueue.{platform}.yaml',
        ]

        with patch.object(
            ida_analyze_bin,
            'resolve_artifact_path',
            side_effect=['/tmp/resolved-a.yaml', '/tmp/resolved-b.yaml'],
        ) as mock_resolver:
            resolved = ida_analyze_bin.expand_expected_paths(binary_dir, expected_paths, 'linux')

        self.assertEqual(['/tmp/resolved-a.yaml', '/tmp/resolved-b.yaml'], resolved)
        mock_resolver.assert_has_calls(
            [
                call(binary_dir, expected_paths[0], 'linux'),
                call(binary_dir, expected_paths[1], 'linux'),
            ]
        )

    def test_expand_skill_output_paths_includes_platform_expected_outputs(self) -> None:
        binary_dir = str(Path("/tmp/bin/14141/engine"))
        skill = {
            "expected_output": ["Common.{platform}.yaml"],
            "expected_output_windows": ["WindowsOnly.{platform}.yaml"],
            "expected_output_linux": ["LinuxOnly.{platform}.yaml"],
            "optional_output": ["Optional.{platform}.yaml"],
        }

        required, optional, preprocess = ida_analyze_bin.expand_skill_output_paths(
            binary_dir,
            skill,
            "windows",
        )

        self.assertEqual(
            [
                str(Path("/tmp/bin/14141/engine/Common.windows.yaml").resolve()),
                str(Path("/tmp/bin/14141/engine/WindowsOnly.windows.yaml").resolve()),
            ],
            required,
        )
        self.assertEqual(
            [str(Path("/tmp/bin/14141/engine/Optional.windows.yaml").resolve())],
            optional,
        )
        self.assertEqual(required + optional, preprocess)


class TestParseConfig(unittest.TestCase):
    def test_parse_config_reads_skip_if_exists(self) -> None:
        with TemporaryDirectory() as temp_dir:
            config_path = Path(temp_dir) / "config.yaml"
            config_path.write_text(
                """
modules:
  - name: engine
    path_windows: game/bin/win64/engine2.dll
    path_linux: game/bin/linuxsteamrt64/libengine2.so
    skills:
      - name: find-CEngineServiceMgr_DeactivateLoop
        expected_output:
          - CEngineServiceMgr_DeactivateLoop.{platform}.yaml
        expected_input:
          - CEngineServiceMgr__MainLoop.{platform}.yaml
        skip_if_exists:
          - CLoopTypeBase_DeallocateLoopMode.{platform}.yaml
""".strip()
                + "\n",
                encoding="utf-8",
            )

            modules = ida_analyze_bin.parse_config(str(config_path))

        self.assertEqual(
            ["CLoopTypeBase_DeallocateLoopMode.{platform}.yaml"],
            modules[0]["skills"][0]["skip_if_exists"],
        )

    def test_parse_config_defaults_skip_if_exists_to_empty_list(self) -> None:
        with TemporaryDirectory() as temp_dir:
            config_path = Path(temp_dir) / "config.yaml"
            config_path.write_text(
                """
modules:
  - name: engine
    path_windows: game/bin/win64/engine2.dll
    path_linux: game/bin/linuxsteamrt64/libengine2.so
    skills:
      - name: find-CEngineServiceMgr_DeactivateLoop
        expected_output:
          - CEngineServiceMgr_DeactivateLoop.{platform}.yaml
        expected_input:
          - CEngineServiceMgr__MainLoop.{platform}.yaml
""".strip()
                + "\n",
                encoding="utf-8",
            )

            modules = ida_analyze_bin.parse_config(str(config_path))

        self.assertEqual(
            [],
            modules[0]["skills"][0]["skip_if_exists"],
        )

    def test_parse_config_reads_optional_output(self) -> None:
        with TemporaryDirectory() as temp_dir:
            config_path = Path(temp_dir) / "config.yaml"
            config_path.write_text(
                """
modules:
  - name: engine
    path_windows: game/bin/win64/engine2.dll
    path_linux: game/bin/linuxsteamrt64/libengine2.so
    skills:
      - name: find-CEngineServiceMgr_DeactivateLoop
        optional_output:
          - CEngineServiceMgr_DeactivateLoop.{platform}.yaml
        expected_input:
          - CEngineServiceMgr__MainLoop.{platform}.yaml
""".strip()
                + "\n",
                encoding="utf-8",
            )

            modules = ida_analyze_bin.parse_config(str(config_path))

        self.assertEqual(
            ["CEngineServiceMgr_DeactivateLoop.{platform}.yaml"],
            modules[0]["skills"][0]["optional_output"],
        )

    def test_parse_config_reads_platform_expected_outputs(self) -> None:
        with TemporaryDirectory() as temp_dir:
            config_path = Path(temp_dir) / "config.yaml"
            config_path.write_text(
                """
modules:
  - name: engine
    path_windows: game/bin/win64/engine2.dll
    path_linux: game/bin/linuxsteamrt64/libengine2.so
    skills:
      - name: find-g_pInterfaceGlobals_ppGlobal
        expected_output:
          - Common.{platform}.yaml
        expected_output_windows:
          - WindowsOnly.{platform}.yaml
        expected_output_linux:
          - LinuxOnly.{platform}.yaml
""".strip()
                + "\n",
                encoding="utf-8",
            )

            modules = ida_analyze_bin.parse_config(str(config_path))

        skill = modules[0]["skills"][0]
        self.assertEqual(["Common.{platform}.yaml"], skill["expected_output"])
        self.assertEqual(
            ["WindowsOnly.{platform}.yaml"],
            skill["expected_output_windows"],
        )
        self.assertEqual(
            ["LinuxOnly.{platform}.yaml"],
            skill["expected_output_linux"],
        )

    def test_parse_config_defaults_optional_output_to_empty_list(self) -> None:
        with TemporaryDirectory() as temp_dir:
            config_path = Path(temp_dir) / "config.yaml"
            config_path.write_text(
                """
modules:
  - name: engine
    path_windows: game/bin/win64/engine2.dll
    path_linux: game/bin/linuxsteamrt64/libengine2.so
    skills:
      - name: find-CEngineServiceMgr_DeactivateLoop
        expected_input:
          - CEngineServiceMgr__MainLoop.{platform}.yaml
""".strip()
                + "\n",
                encoding="utf-8",
            )

            modules = ida_analyze_bin.parse_config(str(config_path))

        self.assertEqual([], modules[0]["skills"][0]["optional_output"])
        self.assertEqual([], modules[0]["skills"][0]["expected_output_windows"])
        self.assertEqual([], modules[0]["skills"][0]["expected_output_linux"])


class TestSkillOrdering(unittest.TestCase):
    def test_topological_sort_skills_keeps_ilooptype_after_deactivateloop(
        self,
    ) -> None:
        skills = [
            {
                "name": "find-CLoopTypeBase_DeallocateLoopMode",
                "expected_output": ["CLoopTypeBase_DeallocateLoopMode.{platform}.yaml"],
                "expected_input": ["CEngineServiceMgr__MainLoop.{platform}.yaml"],
                "prerequisite": ["find-CEngineServiceMgr_DeactivateLoop"],
            },
            {
                "name": "find-CEngineServiceMgr_DeactivateLoop",
                "expected_output": ["CEngineServiceMgr_DeactivateLoop.{platform}.yaml"],
                "expected_input": ["CEngineServiceMgr__MainLoop.{platform}.yaml"],
                "skip_if_exists": ["CLoopTypeBase_DeallocateLoopMode.{platform}.yaml"],
            },
            {
                "name": "find-CEngineServiceMgr__MainLoop",
                "expected_output": ["CEngineServiceMgr__MainLoop.{platform}.yaml"],
            },
        ]

        ordered = ida_analyze_bin.topological_sort_skills(skills)

        self.assertLess(
            ordered.index("find-CEngineServiceMgr_DeactivateLoop"),
            ordered.index("find-CLoopTypeBase_DeallocateLoopMode"),
        )

    def test_topological_sort_skills_ignores_optional_output(self) -> None:
        skills = [
            {
                "name": "consumer",
                "expected_output": ["Consumer.{platform}.yaml"],
                "expected_input": ["OptionalOnly.{platform}.yaml"],
            },
            {
                "name": "optional_producer",
                "optional_output": ["OptionalOnly.{platform}.yaml"],
            },
        ]

        ordered = ida_analyze_bin.topological_sort_skills(skills)

        self.assertEqual(["consumer", "optional_producer"], ordered)


class TestPostProcessActionCollection(unittest.TestCase):
    def test_collect_post_process_yaml_mappings_skips_missing_invalid_and_duplicates(
        self,
    ) -> None:
        with TemporaryDirectory() as temp_dir:
            binary_dir = Path(temp_dir) / "bin" / "14141" / "server"
            binary_dir.mkdir(parents=True, exist_ok=True)
            valid_path = binary_dir / "Valid.windows.yaml"
            invalid_path = binary_dir / "Invalid.windows.yaml"
            scalar_path = binary_dir / "Scalar.windows.yaml"
            valid_path.write_text("func_name: Valid\nfunc_va: '0x180100000'\n", encoding="utf-8")
            invalid_path.write_text("func_name: [\n", encoding="utf-8")
            scalar_path.write_text("- item\n", encoding="utf-8")

            result = ida_analyze_bin._collect_post_process_yaml_mappings(
                str(binary_dir),
                ["skill-a", "skill-b"],
                {
                    "skill-a": {
                        "name": "skill-a",
                        "expected_output": [
                            "Valid.{platform}.yaml",
                            "Missing.{platform}.yaml",
                            "Invalid.{platform}.yaml",
                            "Scalar.{platform}.yaml",
                        ],
                    },
                    "skill-b": {
                        "name": "skill-b",
                        "expected_output": ["Valid.{platform}.yaml"],
                    },
                },
                "windows",
                debug=False,
            )

        self.assertEqual([(str(valid_path.resolve()), {"func_name": "Valid", "func_va": "0x180100000"})], result)

    def test_collect_post_process_yaml_mappings_skips_paths_outside_current_binary_dir(
        self,
    ) -> None:
        with TemporaryDirectory() as temp_dir:
            gamever_dir = Path(temp_dir) / "bin" / "14141"
            binary_dir = gamever_dir / "server"
            sibling_dir = gamever_dir / "engine"
            binary_dir.mkdir(parents=True, exist_ok=True)
            sibling_dir.mkdir(parents=True, exist_ok=True)
            (sibling_dir / "CrossModule.windows.yaml").write_text(
                "func_name: CrossModule\nfunc_va: '0x180200000'\n",
                encoding="utf-8",
            )

            result = ida_analyze_bin._collect_post_process_yaml_mappings(
                str(binary_dir),
                ["skill-a"],
                {
                    "skill-a": {
                        "name": "skill-a",
                        "expected_output": ["../engine/CrossModule.{platform}.yaml"],
                    },
                },
                "windows",
                debug=False,
            )

        self.assertEqual([], result)

    def test_build_post_process_actions_supports_all_yaml_action_types(self) -> None:
        actions = ida_analyze_bin._build_post_process_actions_from_yaml(
            {
                "vtable_class": "CEntFireOutputAutoCompletionFunctor",
                "vtable_va": "0x1817617a8",
                "func_name": "CEntFireOutputAutoCompletionFunctor_FireOutput",
                "func_va": "0x180c165c0",
                "gv_name": "CCSGameRules__sm_mapGcBanInformation",
                "gv_va": "0x181eff6a8",
                "vfunc_offset": "0xb8",
                "vfunc_sig": "48 FF A0 B8 00 00 00 C3",
                "vfunc_sig_disp": 2,
                "struct_name": "CCheckTransmitInfo",
                "member_name": "m_nPlayerSlot",
                "offset": "0x240",
                "offset_sig": "8B 8F ?? ?? ?? ?? E8 ?? ?? ?? ?? 4C 8B F0",
                "offset_sig_disp": 0,
            },
            "fixture.yaml",
            debug=False,
        )

        self.assertEqual(
            [{"addr": "0x180c165c0", "name": "CEntFireOutputAutoCompletionFunctor_FireOutput"}],
            actions["func_renames"],
        )
        self.assertEqual(
            [
                {
                    "addr": "0x1817617a8",
                    "name": "CEntFireOutputAutoCompletionFunctor_vtable",
                    "kind": "vtable",
                },
                {
                    "addr": "0x181eff6a8",
                    "name": "CCSGameRules__sm_mapGcBanInformation",
                    "kind": "global",
                },
            ],
            actions["data_renames"],
        )
        self.assertEqual(
            [
                {
                    "pattern": "48 FF A0 B8 00 00 00 C3",
                    "disp": 2,
                    "comment": "0xB8 = 184LL = CEntFireOutputAutoCompletionFunctor_FireOutput",
                    "source_path": "fixture.yaml",
                    "kind": "vfunc_sig",
                },
                {
                    "pattern": "8B 8F ?? ?? ?? ?? E8 ?? ?? ?? ?? 4C 8B F0",
                    "disp": 0,
                    "comment": "0x240 = 576LL = CCheckTransmitInfo::m_nPlayerSlot",
                    "source_path": "fixture.yaml",
                    "kind": "offset_sig",
                },
            ],
            actions["sig_comments"],
        )

    def test_build_post_process_actions_skips_invalid_fields_without_blocking_valid_actions(
        self,
    ) -> None:
        actions = ida_analyze_bin._build_post_process_actions_from_yaml(
            {
                "func_name": "ValidFunction",
                "func_va": "0x180111000",
                "gv_name": "InvalidGlobal",
                "gv_va": "not-an-address",
                "vfunc_offset": "not-an-offset",
                "vfunc_sig": "48 FF A0 B8 00 00 00 C3",
            },
            "invalid-fields.yaml",
            debug=False,
        )

        self.assertEqual(
            [{"addr": "0x180111000", "name": "ValidFunction"}],
            actions["func_renames"],
        )
        self.assertEqual([], actions["data_renames"])
        self.assertEqual([], actions["sig_comments"])

    def test_build_post_process_actions_skips_invalid_sig_disp_fields(self) -> None:
        actions = ida_analyze_bin._build_post_process_actions_from_yaml(
            {
                "func_name": "ValidFunction",
                "func_va": "0x180111000",
                "vfunc_offset": "0xb8",
                "vfunc_sig": "48 FF A0 B8 00 00 00 C3",
                "vfunc_sig_disp": None,
                "struct_name": "CCheckTransmitInfo",
                "member_name": "m_nPlayerSlot",
                "offset": "0x240",
                "offset_sig": "8B 8F ?? ?? ?? ?? E8 ?? ?? ?? ?? 4C 8B F0",
                "offset_sig_disp": None,
            },
            "invalid-disp.yaml",
            debug=False,
        )

        self.assertEqual(
            [{"addr": "0x180111000", "name": "ValidFunction"}],
            actions["func_renames"],
        )
        self.assertEqual([], actions["data_renames"])
        self.assertEqual([], actions["sig_comments"])


class TestPostProcessMcpExecution(unittest.IsolatedAsyncioTestCase):
    async def test_post_process_expected_outputs_via_session_executes_renames_and_comments(
        self,
    ) -> None:
        session = MagicMock()
        session.call_tool = AsyncMock(
            side_effect=[
                _tool_result(
                    [
                        {
                            "pattern": "48 FF A0 B8 00 00 00 C3",
                            "matches": ["0x180a32c60"],
                            "n": 1,
                        }
                    ]
                ),
                _tool_result({"items": [{"addr": "0x180a32c62", "ok": True}]}),
                _tool_result({"renamed": True}),
                _tool_result({"result": ""}),
            ]
        )

        ok = await ida_analyze_bin.post_process_expected_outputs_via_session(
            session,
            [
                (
                    "fixture.yaml",
                    {
                        "func_name": "CCSPlayer_ItemServices_DropActivePlayerWeapon",
                        "func_va": "0x180c165c0",
                        "vfunc_offset": "0xb8",
                        "vfunc_sig": "48 FF A0 B8 00 00 00 C3",
                        "vfunc_sig_disp": 2,
                        "gv_name": "CCSGameRules__sm_mapGcBanInformation",
                        "gv_va": "0x181eff6a8",
                    },
                )
            ],
            debug=False,
        )

        self.assertTrue(ok)
        self.assertEqual(
            [
                call(
                    name="find_bytes",
                    arguments={"patterns": ["48 FF A0 B8 00 00 00 C3"], "limit": 2},
                ),
                call(
                    name="set_comments",
                    arguments={
                        "items": [
                            {
                                "addr": "0x180a32c62",
                                "comment": "0xB8 = 184LL = CCSPlayer_ItemServices_DropActivePlayerWeapon",
                            }
                        ]
                    },
                ),
                call(
                    name="rename",
                    arguments={
                        "batch": {
                            "func": [
                                {
                                    "addr": "0x180c165c0",
                                    "name": "CCSPlayer_ItemServices_DropActivePlayerWeapon",
                                }
                            ]
                        }
                    },
                ),
                call(
                    name="py_eval",
                    arguments={
                        "code": (
                            "import idc\n"
                            "idc.set_name(6474954408, "
                            "\"CCSGameRules__sm_mapGcBanInformation\", idc.SN_NOWARN)\n"
                        )
                    },
                ),
            ],
            session.call_tool.await_args_list,
        )

    async def test_post_process_expected_outputs_via_session_skips_non_unique_signature_matches(
        self,
    ) -> None:
        session = MagicMock()
        session.call_tool = AsyncMock(
            side_effect=[
                _tool_result(
                    [
                        {
                            "pattern": "8B 8F ?? ?? ?? ??",
                            "matches": [],
                            "n": 0,
                        }
                    ]
                ),
                _tool_result(
                    [
                        {
                            "pattern": "48 FF A0 B8 00 00 00 C3",
                            "matches": ["0x1801", "0x1802"],
                            "n": 2,
                        }
                    ]
                ),
            ]
        )

        ok = await ida_analyze_bin.post_process_expected_outputs_via_session(
            session,
            [
                (
                    "fixture.yaml",
                    {
                        "struct_name": "CCheckTransmitInfo",
                        "member_name": "m_nPlayerSlot",
                        "offset": "0x240",
                        "offset_sig": "8B 8F ?? ?? ?? ??",
                        "func_name": "CCSPlayer_ItemServices_DropActivePlayerWeapon",
                        "vfunc_offset": "0xb8",
                        "vfunc_sig": "48 FF A0 B8 00 00 00 C3",
                    },
                )
            ],
            debug=False,
        )

        self.assertTrue(ok)
        self.assertEqual(2, session.call_tool.await_count)
        self.assertNotIn(
            "set_comments",
            [call_item.kwargs["name"] for call_item in session.call_tool.await_args_list],
        )

    async def test_post_process_expected_outputs_via_session_falls_back_to_py_eval_comments(
        self,
    ) -> None:
        session = MagicMock()
        session.call_tool = AsyncMock(
            side_effect=[
                _tool_result(
                    [
                        {
                            "pattern": "48 FF A0 B8 00 00 00 C3",
                            "matches": [6453142112],
                            "n": 1,
                        }
                    ]
                ),
                RuntimeError("Unknown tool: set_comments"),
                _tool_result({"result": ""}),
            ]
        )

        ok = await ida_analyze_bin.post_process_expected_outputs_via_session(
            session,
            [
                (
                    "fixture.yaml",
                    {
                        "func_name": "CCSPlayer_ItemServices_DropActivePlayerWeapon",
                        "vfunc_offset": "0xb8",
                        "vfunc_sig": "48 FF A0 B8 00 00 00 C3",
                    },
                )
            ],
            debug=False,
        )

        self.assertTrue(ok)
        self.assertEqual("py_eval", session.call_tool.await_args_list[-1].kwargs["name"])
        self.assertIn(
            "idc.set_cmt(6453142112, \"0xB8 = 184LL = CCSPlayer_ItemServices_DropActivePlayerWeapon\", 0)",
            session.call_tool.await_args_list[-1].kwargs["arguments"]["code"],
        )

    async def test_post_process_expected_outputs_via_session_ignores_set_comments_item_errors(
        self,
    ) -> None:
        session = MagicMock()
        session.call_tool = AsyncMock(
            side_effect=[
                _tool_result(
                    [
                        {
                            "pattern": "48 FF A0 B8 00 00 00 C3",
                            "matches": ["0x180a32c60"],
                            "n": 1,
                        }
                    ]
                ),
                _tool_result(
                    {
                        "items": [
                            {
                                "addr": "0x180a32c60",
                                "error": "Decompiler comment failed",
                            }
                        ]
                    }
                ),
                _tool_result({"renamed": True}),
            ]
        )

        ok = await ida_analyze_bin.post_process_expected_outputs_via_session(
            session,
            [
                (
                    "fixture.yaml",
                    {
                        "func_name": "CCSPlayer_ItemServices_DropActivePlayerWeapon",
                        "func_va": "0x180c165c0",
                        "vfunc_offset": "0xb8",
                        "vfunc_sig": "48 FF A0 B8 00 00 00 C3",
                    },
                )
            ],
            debug=True,
        )

        self.assertTrue(ok)
        self.assertEqual("rename", session.call_tool.await_args_list[-1].kwargs["name"])
        self.assertNotIn(
            "py_eval",
            [call_item.kwargs["name"] for call_item in session.call_tool.await_args_list],
        )


class TestStartIdalibMcp(unittest.TestCase):
    @patch.object(ida_analyze_bin, "wait_for_port", return_value=True)
    @patch("ida_analyze_bin.subprocess.Popen")
    def test_start_idalib_mcp_non_debug_discards_server_output(
        self,
        mock_popen,
        _mock_wait_for_port,
    ) -> None:
        fake_process = MagicMock()
        mock_popen.return_value = fake_process

        process = ida_analyze_bin.start_idalib_mcp(
            "bin/14160/client/client.dll",
            host="127.0.0.1",
            port=13337,
            debug=False,
        )

        self.assertIs(fake_process, process)
        mock_popen.assert_called_once()
        _args, kwargs = mock_popen.call_args
        self.assertEqual(ida_analyze_bin.subprocess.DEVNULL, kwargs["stdout"])
        self.assertEqual(ida_analyze_bin.subprocess.DEVNULL, kwargs["stderr"])


class TestProcessBinary(unittest.TestCase):
    def test_process_binary_treats_absent_ok_as_skip_and_continues(self) -> None:
        with TemporaryDirectory() as temp_dir:
            binary_dir = Path(temp_dir) / "engine"
            binary_dir.mkdir(parents=True, exist_ok=True)
            binary_path = str(binary_dir / "libengine2.so")
            (binary_dir / "CEngineServiceMgr__MainLoop.windows.yaml").write_text(
                "func_name: CEngineServiceMgr__MainLoop\n",
                encoding="utf-8",
            )

            def _fake_preprocess(*, skill_name, expected_outputs, **_kwargs):
                if skill_name == "find-CEngineServiceMgr_DeactivateLoop":
                    return "absent_ok"
                Path(expected_outputs[0]).write_text(
                    "func_name: CLoopTypeBase_DeallocateLoopMode\n",
                    encoding="utf-8",
                )
                return "success"

            with (
                patch.object(
                    ida_analyze_bin,
                    "start_idalib_mcp",
                    return_value=object(),
                ),
                patch.object(
                    ida_analyze_bin,
                    "ensure_mcp_available",
                    side_effect=lambda process, *_args, **_kwargs: (process, True),
                ),
                patch.object(
                    ida_analyze_bin,
                    "_run_validate_expected_input_artifacts_via_mcp",
                    return_value=[],
                ),
                patch.object(
                    ida_analyze_bin,
                    "_run_preprocess_single_skill_via_mcp",
                    side_effect=_fake_preprocess,
                ),
                patch.object(
                    ida_analyze_bin,
                    "run_skill",
                    return_value=False,
                ) as mock_run_skill,
                patch.object(
                    ida_analyze_bin,
                    "quit_ida_gracefully",
                    return_value=None,
                ),
            ):
                success, fail, skip = ida_analyze_bin.process_binary(
                    binary_path=binary_path,
                    skills=[
                        {
                            "name": "find-CEngineServiceMgr_DeactivateLoop",
                            "expected_output": ["CEngineServiceMgr_DeactivateLoop.{platform}.yaml"],
                            "expected_input": ["CEngineServiceMgr__MainLoop.{platform}.yaml"],
                        },
                        {
                            "name": "find-CLoopTypeBase_DeallocateLoopMode",
                            "expected_output": ["CLoopTypeBase_DeallocateLoopMode.{platform}.yaml"],
                            "expected_input": ["CEngineServiceMgr__MainLoop.{platform}.yaml"],
                            "prerequisite": ["find-CEngineServiceMgr_DeactivateLoop"],
                        },
                    ],
                    old_binary_dir=None,
                    platform="windows",
                    agent="codex",
                    max_retries=1,
                    debug=True,
                    host="127.0.0.1",
                    port=39091,
                    ida_args=None,
                    llm_model="gpt-5.4",
                    llm_apikey=None,
                    llm_baseurl=None,
                    llm_temperature=None,
                    llm_effort="high",
                    llm_fake_as="codex",
                )

        self.assertEqual((1, 0, 1), (success, fail, skip))
        mock_run_skill.assert_not_called()

    def test_process_binary_skips_when_all_skip_if_exists_artifacts_exist_before_ida_start(
        self,
    ) -> None:
        with TemporaryDirectory() as temp_dir:
            binary_dir = Path(temp_dir) / "bin" / "14141" / "engine"
            binary_dir.mkdir(parents=True, exist_ok=True)
            binary_path = str(binary_dir / "libengine2.so")
            (binary_dir / "CLoopTypeBase_DeallocateLoopMode.windows.yaml").write_text(
                "func_name: CLoopTypeBase_DeallocateLoopMode\n",
                encoding="utf-8",
            )

            with patch.object(
                ida_analyze_bin,
                "start_idalib_mcp",
                return_value=None,
            ) as mock_start_ida:
                success, fail, skip = ida_analyze_bin.process_binary(
                    binary_path=binary_path,
                    skills=[
                        {
                            "name": "find-CEngineServiceMgr_DeactivateLoop",
                            "expected_output": ["CEngineServiceMgr_DeactivateLoop.{platform}.yaml"],
                            "expected_input": ["CEngineServiceMgr__MainLoop.{platform}.yaml"],
                            "skip_if_exists": ["CLoopTypeBase_DeallocateLoopMode.{platform}.yaml"],
                        }
                    ],
                    agent="codex",
                    host="127.0.0.1",
                    port=13337,
                    ida_args="",
                    platform="windows",
                    debug=False,
                    max_retries=1,
                )

        self.assertEqual((0, 0, 1), (success, fail, skip))
        mock_start_ida.assert_not_called()

    def test_process_binary_skips_optional_only_skill_when_optional_output_exists_before_ida_start(
        self,
    ) -> None:
        with TemporaryDirectory() as temp_dir:
            binary_dir = Path(temp_dir) / "bin" / "14141" / "engine"
            binary_dir.mkdir(parents=True, exist_ok=True)
            binary_path = str(binary_dir / "libengine2.so")
            (binary_dir / "CEngineServiceMgr_DeactivateLoop.windows.yaml").write_text(
                "func_name: CEngineServiceMgr_DeactivateLoop\n",
                encoding="utf-8",
            )

            with patch.object(
                ida_analyze_bin,
                "start_idalib_mcp",
                return_value=None,
            ) as mock_start_ida:
                success, fail, skip = ida_analyze_bin.process_binary(
                    binary_path=binary_path,
                    skills=[
                        {
                            "name": "find-CEngineServiceMgr_DeactivateLoop",
                            "optional_output": [
                                "CEngineServiceMgr_DeactivateLoop.{platform}.yaml"
                            ],
                            "expected_input": [
                                "CEngineServiceMgr__MainLoop.{platform}.yaml"
                            ],
                        }
                    ],
                    agent="codex",
                    host="127.0.0.1",
                    port=13337,
                    ida_args="",
                    platform="windows",
                    debug=False,
                    max_retries=1,
                )

        self.assertEqual((0, 0, 1), (success, fail, skip))
        mock_start_ida.assert_not_called()

    def test_process_binary_rejects_illegal_optional_output_before_ida_start(
        self,
    ) -> None:
        binary_path = str(Path("/tmp/bin/14141/engine/libengine2.so"))

        with patch.object(
            ida_analyze_bin,
            "start_idalib_mcp",
            return_value=None,
        ) as mock_start_ida:
            success, fail, skip = ida_analyze_bin.process_binary(
                binary_path=binary_path,
                skills=[
                    {
                        "name": "find-CEngineServiceMgr_DeactivateLoop",
                        "optional_output": ["../../outside/secret.{platform}.yaml"],
                        "expected_input": [],
                    }
                ],
                agent="codex",
                host="127.0.0.1",
                port=13337,
                ida_args="",
                platform="windows",
                debug=False,
                max_retries=1,
            )

        self.assertEqual((0, 1, 0), (success, fail, skip))
        mock_start_ida.assert_not_called()

    def test_process_binary_does_not_skip_when_skip_if_exists_artifacts_are_partial(
        self,
    ) -> None:
        skills = [
            {
                "name": "find-CEngineServiceMgr_DeactivateLoop",
                "expected_output": ["CEngineServiceMgr_DeactivateLoop.{platform}.yaml"],
                "expected_input": [],
                "skip_if_exists": [
                    "CLoopTypeBase_DeallocateLoopMode.{platform}.yaml",
                    "ILoopType_OtherMode.{platform}.yaml",
                ],
            }
        ]

        with TemporaryDirectory() as temp_dir:
            binary_dir = Path(temp_dir) / "bin" / "14141" / "engine"
            binary_dir.mkdir(parents=True, exist_ok=True)
            binary_path = str(binary_dir / "libengine2.so")
            (binary_dir / "CLoopTypeBase_DeallocateLoopMode.windows.yaml").write_text(
                "func_name: CLoopTypeBase_DeallocateLoopMode\n",
                encoding="utf-8",
            )

            fake_process = object()

            with (
                patch.object(
                    ida_analyze_bin,
                    "start_idalib_mcp",
                    return_value=fake_process,
                ) as mock_start_ida,
                patch.object(
                    ida_analyze_bin,
                    "ensure_mcp_available",
                    return_value=(fake_process, True),
                ),
                patch.object(
                    ida_analyze_bin,
                    "_run_validate_expected_input_artifacts_via_mcp",
                    return_value=[],
                ),
                patch.object(
                    ida_analyze_bin,
                    "_run_preprocess_single_skill_via_mcp",
                    return_value="failed",
                ) as mock_preprocess,
                patch.object(ida_analyze_bin, "run_skill", return_value=False) as mock_run_skill,
                patch.object(ida_analyze_bin, "quit_ida_gracefully", return_value=None),
            ):
                success, fail, skip = ida_analyze_bin.process_binary(
                    binary_path=binary_path,
                    skills=skills,
                    agent="codex",
                    host="127.0.0.1",
                    port=13337,
                    ida_args="",
                    platform="windows",
                    debug=False,
                    max_retries=1,
                )

        self.assertEqual((0, 1, 0), (success, fail, skip))
        mock_start_ida.assert_called_once()
        mock_preprocess.assert_called_once()
        mock_run_skill.assert_called_once()

    def test_process_binary_rechecks_skip_if_exists_before_running_skill(self) -> None:
        with TemporaryDirectory() as temp_dir:
            binary_dir = Path(temp_dir) / "bin" / "14141" / "engine"
            binary_dir.mkdir(parents=True, exist_ok=True)
            binary_path = str(binary_dir / "libengine2.so")
            (binary_dir / "CEngineServiceMgr__MainLoop.windows.yaml").write_text(
                "func_name: CEngineServiceMgr__MainLoop\n",
                encoding="utf-8",
            )

            def _fake_preprocess(*, skill_name, expected_outputs, **_kwargs):
                if skill_name == "produce-ilooptype":
                    Path(expected_outputs[0]).write_text(
                        "func_name: CLoopTypeBase_DeallocateLoopMode\n",
                        encoding="utf-8",
                    )
                    return "success"
                return "failed"

            with (
                patch.object(ida_analyze_bin, "start_idalib_mcp", return_value=object()),
                patch.object(
                    ida_analyze_bin,
                    "ensure_mcp_available",
                    side_effect=lambda process, *_args, **_kwargs: (process, True),
                ),
                patch.object(
                    ida_analyze_bin,
                    "_run_validate_expected_input_artifacts_via_mcp",
                    return_value=[],
                ),
                patch.object(
                    ida_analyze_bin,
                    "_run_preprocess_single_skill_via_mcp",
                    side_effect=_fake_preprocess,
                ) as mock_preprocess,
                patch.object(ida_analyze_bin, "run_skill", return_value=False) as mock_run_skill,
                patch.object(ida_analyze_bin, "quit_ida_gracefully", return_value=None),
            ):
                success, fail, skip = ida_analyze_bin.process_binary(
                    binary_path=binary_path,
                    skills=[
                        {
                            "name": "produce-ilooptype",
                            "expected_output": ["CLoopTypeBase_DeallocateLoopMode.{platform}.yaml"],
                            "expected_input": [],
                        },
                        {
                            "name": "find-CEngineServiceMgr_DeactivateLoop",
                            "expected_output": ["CEngineServiceMgr_DeactivateLoop.{platform}.yaml"],
                            "expected_input": ["CEngineServiceMgr__MainLoop.{platform}.yaml"],
                            "skip_if_exists": ["CLoopTypeBase_DeallocateLoopMode.{platform}.yaml"],
                            "prerequisite": ["produce-ilooptype"],
                        },
                    ],
                    old_binary_dir=None,
                    platform="windows",
                    agent="codex",
                    max_retries=1,
                    debug=True,
                    host="127.0.0.1",
                    port=39091,
                    ida_args=None,
                    llm_model="gpt-5.4",
                    llm_apikey=None,
                    llm_baseurl=None,
                    llm_temperature=None,
                    llm_effort="high",
                    llm_fake_as="codex",
                )

        self.assertEqual((1, 0, 1), (success, fail, skip))
        self.assertEqual(1, mock_preprocess.call_count)
        mock_run_skill.assert_not_called()

    def test_process_binary_skips_optional_only_skill_when_preprocess_fails_without_output(
        self,
    ) -> None:
        with TemporaryDirectory() as temp_dir:
            binary_dir = Path(temp_dir) / "bin" / "14141" / "engine"
            binary_dir.mkdir(parents=True, exist_ok=True)
            binary_path = str(binary_dir / "libengine2.so")
            (binary_dir / "CEngineServiceMgr__MainLoop.windows.yaml").write_text(
                "func_name: CEngineServiceMgr__MainLoop\n",
                encoding="utf-8",
            )
            fake_process = object()

            with (
                patch.object(ida_analyze_bin, "start_idalib_mcp", return_value=fake_process),
                patch.object(
                    ida_analyze_bin,
                    "ensure_mcp_available",
                    return_value=(fake_process, True),
                ),
                patch.object(
                    ida_analyze_bin,
                    "_run_validate_expected_input_artifacts_via_mcp",
                    return_value=[],
                ),
                patch.object(
                    ida_analyze_bin,
                    "_run_preprocess_single_skill_via_mcp",
                    return_value="failed",
                ) as mock_preprocess,
                patch.object(ida_analyze_bin, "run_skill", return_value=False) as mock_run_skill,
                patch.object(ida_analyze_bin, "quit_ida_gracefully", return_value=None),
            ):
                success, fail, skip = ida_analyze_bin.process_binary(
                    binary_path=binary_path,
                    skills=[
                        {
                            "name": "find-CEngineServiceMgr_DeactivateLoop",
                            "optional_output": [
                                "CEngineServiceMgr_DeactivateLoop.{platform}.yaml"
                            ],
                            "expected_input": [
                                "CEngineServiceMgr__MainLoop.{platform}.yaml"
                            ],
                        }
                    ],
                    old_binary_dir=None,
                    platform="windows",
                    agent="codex",
                    max_retries=1,
                    debug=True,
                    host="127.0.0.1",
                    port=39091,
                    ida_args=None,
                    llm_model="gpt-5.4",
                    llm_apikey=None,
                    llm_baseurl=None,
                    llm_temperature=None,
                    llm_effort="high",
                    llm_fake_as="codex",
                )

        self.assertEqual((0, 0, 1), (success, fail, skip))
        mock_preprocess.assert_called_once()
        self.assertEqual(
            [
                str(binary_dir / "CEngineServiceMgr_DeactivateLoop.windows.yaml"),
            ],
            mock_preprocess.call_args.kwargs["expected_outputs"],
        )
        mock_run_skill.assert_not_called()

    def test_process_binary_counts_optional_only_skill_success_when_preprocess_writes_output(
        self,
    ) -> None:
        with TemporaryDirectory() as temp_dir:
            binary_dir = Path(temp_dir) / "bin" / "14141" / "engine"
            binary_dir.mkdir(parents=True, exist_ok=True)
            binary_path = str(binary_dir / "libengine2.so")
            (binary_dir / "CEngineServiceMgr__MainLoop.windows.yaml").write_text(
                "func_name: CEngineServiceMgr__MainLoop\n",
                encoding="utf-8",
            )

            def _fake_preprocess(*, expected_outputs, **_kwargs):
                Path(expected_outputs[0]).write_text(
                    "func_name: CEngineServiceMgr_DeactivateLoop\n",
                    encoding="utf-8",
                )
                return "success"

            with (
                patch.object(ida_analyze_bin, "start_idalib_mcp", return_value=object()),
                patch.object(
                    ida_analyze_bin,
                    "ensure_mcp_available",
                    side_effect=lambda process, *_args, **_kwargs: (process, True),
                ),
                patch.object(
                    ida_analyze_bin,
                    "_run_validate_expected_input_artifacts_via_mcp",
                    return_value=[],
                ),
                patch.object(
                    ida_analyze_bin,
                    "_run_preprocess_single_skill_via_mcp",
                    side_effect=_fake_preprocess,
                ),
                patch.object(ida_analyze_bin, "run_skill", return_value=False) as mock_run_skill,
                patch.object(ida_analyze_bin, "quit_ida_gracefully", return_value=None),
            ):
                success, fail, skip = ida_analyze_bin.process_binary(
                    binary_path=binary_path,
                    skills=[
                        {
                            "name": "find-CEngineServiceMgr_DeactivateLoop",
                            "optional_output": [
                                "CEngineServiceMgr_DeactivateLoop.{platform}.yaml"
                            ],
                            "expected_input": [
                                "CEngineServiceMgr__MainLoop.{platform}.yaml"
                            ],
                        }
                    ],
                    old_binary_dir=None,
                    platform="windows",
                    agent="codex",
                    max_retries=1,
                    debug=True,
                    host="127.0.0.1",
                    port=39091,
                    ida_args=None,
                    llm_model="gpt-5.4",
                    llm_apikey=None,
                    llm_baseurl=None,
                    llm_temperature=None,
                    llm_effort="high",
                    llm_fake_as="codex",
                )

        self.assertEqual((1, 0, 0), (success, fail, skip))
        mock_run_skill.assert_not_called()

    def test_process_binary_passes_required_plus_optional_to_preprocess_but_only_requires_expected_output(
        self,
    ) -> None:
        with TemporaryDirectory() as temp_dir:
            binary_dir = Path(temp_dir) / "bin" / "14141" / "engine"
            binary_dir.mkdir(parents=True, exist_ok=True)
            binary_path = str(binary_dir / "libengine2.so")

            def _fake_preprocess(*, expected_outputs, **_kwargs):
                self.assertEqual(
                    [
                        str(binary_dir / "Required.windows.yaml"),
                        str(binary_dir / "Optional.windows.yaml"),
                    ],
                    expected_outputs,
                )
                Path(expected_outputs[0]).write_text(
                    "func_name: Required\n",
                    encoding="utf-8",
                )
                return "success"

            with (
                patch.object(ida_analyze_bin, "start_idalib_mcp", return_value=object()),
                patch.object(
                    ida_analyze_bin,
                    "ensure_mcp_available",
                    side_effect=lambda process, *_args, **_kwargs: (process, True),
                ),
                patch.object(
                    ida_analyze_bin,
                    "_run_validate_expected_input_artifacts_via_mcp",
                    return_value=[],
                ),
                patch.object(
                    ida_analyze_bin,
                    "_run_preprocess_single_skill_via_mcp",
                    side_effect=_fake_preprocess,
                ),
                patch.object(ida_analyze_bin, "run_skill", return_value=False) as mock_run_skill,
                patch.object(ida_analyze_bin, "quit_ida_gracefully", return_value=None),
            ):
                success, fail, skip = ida_analyze_bin.process_binary(
                    binary_path=binary_path,
                    skills=[
                        {
                            "name": "find-required-and-optional",
                            "expected_output": ["Required.{platform}.yaml"],
                            "optional_output": ["Optional.{platform}.yaml"],
                            "expected_input": [],
                        }
                    ],
                    old_binary_dir=None,
                    platform="windows",
                    agent="codex",
                    max_retries=1,
                    debug=True,
                    host="127.0.0.1",
                    port=39091,
                    ida_args=None,
                    llm_model="gpt-5.4",
                    llm_apikey=None,
                    llm_baseurl=None,
                    llm_temperature=None,
                    llm_effort="high",
                    llm_fake_as="codex",
                )

        self.assertEqual((1, 0, 0), (success, fail, skip))
        mock_run_skill.assert_not_called()

    def test_process_binary_agent_skill_validates_only_required_outputs(
        self,
    ) -> None:
        with TemporaryDirectory() as temp_dir:
            binary_dir = Path(temp_dir) / "bin" / "14141" / "engine"
            binary_dir.mkdir(parents=True, exist_ok=True)
            binary_path = str(binary_dir / "libengine2.so")
            fake_process = object()

            with (
                patch.object(ida_analyze_bin, "start_idalib_mcp", return_value=fake_process),
                patch.object(
                    ida_analyze_bin,
                    "ensure_mcp_available",
                    return_value=(fake_process, True),
                ),
                patch.object(
                    ida_analyze_bin,
                    "_run_validate_expected_input_artifacts_via_mcp",
                    return_value=[],
                ),
                patch.object(
                    ida_analyze_bin,
                    "_run_preprocess_single_skill_via_mcp",
                    return_value="failed",
                ),
                patch.object(ida_analyze_bin, "run_skill", return_value=True) as mock_run_skill,
                patch.object(ida_analyze_bin, "quit_ida_gracefully", return_value=None),
            ):
                success, fail, skip = ida_analyze_bin.process_binary(
                    binary_path=binary_path,
                    skills=[
                        {
                            "name": "find-required-and-optional",
                            "expected_output": ["Required.{platform}.yaml"],
                            "optional_output": ["Optional.{platform}.yaml"],
                            "expected_input": [],
                        }
                    ],
                    old_binary_dir=None,
                    platform="windows",
                    agent="codex",
                    max_retries=1,
                    debug=True,
                    host="127.0.0.1",
                    port=39091,
                    ida_args=None,
                    llm_model="gpt-5.4",
                    llm_apikey=None,
                    llm_baseurl=None,
                    llm_temperature=None,
                    llm_effort="high",
                    llm_fake_as="codex",
                )

        self.assertEqual((1, 0, 0), (success, fail, skip))
        self.assertEqual(
            [str(binary_dir / "Required.windows.yaml")],
            mock_run_skill.call_args.kwargs["expected_yaml_paths"],
        )

    def test_process_binary_rejects_illegal_expected_input_without_crash(self) -> None:
        binary_path = str(Path('/tmp/bin/14141/networksystem/networksystem.dll'))
        skills = [
            {
                'name': 'skill_illegal_expected_input',
                'expected_output': ['CNetChan_vtable.{platform}.yaml'],
                'expected_input': ['../../outside/secret.{platform}.yaml'],
            }
        ]
        fake_process = object()

        with (
            patch.object(ida_analyze_bin, 'start_idalib_mcp', return_value=fake_process),
            patch.object(ida_analyze_bin, 'ensure_mcp_available', return_value=(fake_process, True)),
            patch.object(ida_analyze_bin, 'quit_ida_gracefully') as mock_quit_ida,
            patch.object(ida_analyze_bin, 'run_skill') as mock_run_skill,
        ):
            success, fail, skip = ida_analyze_bin.process_binary(
                binary_path=binary_path,
                skills=skills,
                agent='codex',
                host='127.0.0.1',
                port=13337,
                ida_args='',
                platform='windows',
                debug=False,
                max_retries=1,
            )

        self.assertEqual((0, 1, 0), (success, fail, skip))
        mock_run_skill.assert_not_called()
        mock_quit_ida.assert_called_once_with(fake_process, '127.0.0.1', 13337, debug=False)

    def test_process_binary_preserves_prefilter_failures_when_mcp_startup_fails(self) -> None:
        binary_path = str(Path('/tmp/bin/14141/networksystem/networksystem.dll'))
        skills = [
            {
                'name': 'a_illegal_output',
                'expected_output': ['../../outside/secret.{platform}.yaml'],
                'expected_input': [],
            },
            {
                'name': 'b_valid_output',
                'expected_output': ['CNetChan_vtable.{platform}.yaml'],
                'expected_input': [],
            },
        ]

        with patch.object(ida_analyze_bin, 'start_idalib_mcp', return_value=None):
            success, fail, skip = ida_analyze_bin.process_binary(
                binary_path=binary_path,
                skills=skills,
                agent='codex',
                host='127.0.0.1',
                port=13337,
                ida_args='',
                platform='windows',
                debug=False,
                max_retries=1,
            )

        self.assertEqual((0, 2, 0), (success, fail, skip))

    def test_process_binary_rejects_invalid_expected_input_artifact_before_preprocess(self) -> None:
        fake_process = object()

        with TemporaryDirectory() as temp_dir:
            binary_dir = Path(temp_dir) / "bin" / "14141b" / "engine"
            binary_dir.mkdir(parents=True, exist_ok=True)
            binary_path = str(binary_dir / "libengine2.so")
            expected_input_path = binary_dir / "CDemoRecorder_WriteSpawnGroups.linux.yaml"
            expected_input_path.write_text("func_name: CDemoRecorder_WriteSpawnGroups\n", encoding="utf-8")

            with (
                patch.object(ida_analyze_bin, "start_idalib_mcp", return_value=fake_process),
                patch.object(ida_analyze_bin, "ensure_mcp_available", return_value=(fake_process, True)),
                patch.object(ida_analyze_bin, "quit_ida_gracefully") as mock_quit_ida,
                patch.object(
                    ida_analyze_bin,
                    "_run_validate_expected_input_artifacts_via_mcp",
                    return_value=[
                        (
                            f"{expected_input_path}: func_va=0x616050 resolves to segment "
                            "'.data' instead of '.text'; missing required field func_sig"
                        )
                    ],
                ),
                patch.object(ida_analyze_bin, "_run_preprocess_single_skill_via_mcp") as mock_preprocess,
                patch.object(ida_analyze_bin, "run_skill") as mock_run_skill,
                patch("sys.stdout", new_callable=io.StringIO) as stdout,
            ):
                success, fail, skip = ida_analyze_bin.process_binary(
                    binary_path=binary_path,
                    skills=[
                        {
                            "name": "find-INetworkMessages_FindNetworkMessageById",
                            "expected_output": ["INetworkMessages_FindNetworkMessageById.{platform}.yaml"],
                            "expected_input": ["CDemoRecorder_WriteSpawnGroups.{platform}.yaml"],
                        }
                    ],
                    agent="codex",
                    host="127.0.0.1",
                    port=13337,
                    ida_args="",
                    platform="linux",
                    debug=False,
                    max_retries=1,
                )

        self.assertEqual((0, 1, 0), (success, fail, skip))
        mock_preprocess.assert_not_called()
        mock_run_skill.assert_not_called()
        self.assertIn("invalid expected_input artifact", stdout.getvalue())
        self.assertIn("missing required field func_sig", stdout.getvalue())
        mock_quit_ida.assert_called_once_with(fake_process, "127.0.0.1", 13337, debug=False)

    def test_process_binary_does_not_start_ida_for_post_process_when_rename_is_false(
        self,
    ) -> None:
        with TemporaryDirectory() as temp_dir:
            binary_dir = Path(temp_dir) / "bin" / "14141" / "server"
            binary_dir.mkdir(parents=True, exist_ok=True)
            binary_path = str(binary_dir / "server.dll")
            (binary_dir / "CEntFireOutputAutoCompletionFunctor_FireOutput.windows.yaml").write_text(
                "func_name: CEntFireOutputAutoCompletionFunctor_FireOutput\n"
                "func_va: '0x180c165c0'\n",
                encoding="utf-8",
            )

            with (
                patch.object(ida_analyze_bin, "start_idalib_mcp") as mock_start_ida,
                patch.object(
                    ida_analyze_bin,
                    "_run_post_process_expected_outputs_via_mcp",
                    create=True,
                ) as mock_post_process,
            ):
                success, fail, skip = ida_analyze_bin.process_binary(
                    binary_path=binary_path,
                    skills=[
                        {
                            "name": "find-CEntFireOutputAutoCompletionFunctor_FireOutput",
                            "expected_output": [
                                "CEntFireOutputAutoCompletionFunctor_FireOutput.{platform}.yaml"
                            ],
                            "expected_input": [],
                        }
                    ],
                    agent="codex",
                    host="127.0.0.1",
                    port=13337,
                    ida_args="",
                    platform="windows",
                    debug=False,
                    max_retries=1,
                )

        self.assertEqual((0, 0, 1), (success, fail, skip))
        mock_start_ida.assert_not_called()
        mock_post_process.assert_not_called()

    def test_process_binary_runs_post_process_when_rename_true_and_outputs_exist(
        self,
    ) -> None:
        fake_process = object()

        with TemporaryDirectory() as temp_dir:
            binary_dir = Path(temp_dir) / "bin" / "14141" / "server"
            binary_dir.mkdir(parents=True, exist_ok=True)
            binary_path = str(binary_dir / "server.dll")
            (binary_dir / "CEntFireOutputAutoCompletionFunctor_FireOutput.windows.yaml").write_text(
                "func_name: CEntFireOutputAutoCompletionFunctor_FireOutput\n"
                "func_va: '0x180c165c0'\n",
                encoding="utf-8",
            )

            with (
                patch.object(
                    ida_analyze_bin,
                    "start_idalib_mcp",
                    return_value=fake_process,
                ) as mock_start_ida,
                patch.object(
                    ida_analyze_bin,
                    "ensure_mcp_available",
                    return_value=(fake_process, True),
                ),
                patch.object(
                    ida_analyze_bin,
                    "_run_post_process_expected_outputs_via_mcp",
                    return_value=True,
                    create=True,
                ) as mock_post_process,
                patch.object(
                    ida_analyze_bin,
                    "quit_ida_gracefully",
                    return_value=None,
                ) as mock_quit_ida,
            ):
                success, fail, skip = ida_analyze_bin.process_binary(
                    binary_path=binary_path,
                    skills=[
                        {
                            "name": "find-CEntFireOutputAutoCompletionFunctor_FireOutput",
                            "expected_output": [
                                "CEntFireOutputAutoCompletionFunctor_FireOutput.{platform}.yaml"
                            ],
                            "expected_input": [],
                        }
                    ],
                    agent="codex",
                    host="127.0.0.1",
                    port=13337,
                    ida_args="",
                    platform="windows",
                    debug=False,
                    max_retries=1,
                    rename=True,
                )

        self.assertEqual((0, 0, 1), (success, fail, skip))
        mock_start_ida.assert_called_once_with(binary_path, "127.0.0.1", 13337, "", False)
        mock_post_process.assert_called_once()
        mock_quit_ida.assert_called_once_with(fake_process, "127.0.0.1", 13337, debug=False)

    def test_process_binary_counts_post_process_failure_once(
        self,
    ) -> None:
        fake_process = object()

        with TemporaryDirectory() as temp_dir:
            binary_dir = Path(temp_dir) / "bin" / "14141" / "server"
            binary_dir.mkdir(parents=True, exist_ok=True)
            binary_path = str(binary_dir / "server.dll")
            (binary_dir / "CEntFireOutputAutoCompletionFunctor_FireOutput.windows.yaml").write_text(
                "func_name: CEntFireOutputAutoCompletionFunctor_FireOutput\n"
                "func_va: '0x180c165c0'\n",
                encoding="utf-8",
            )

            with (
                patch.object(
                    ida_analyze_bin,
                    "start_idalib_mcp",
                    return_value=fake_process,
                ),
                patch.object(
                    ida_analyze_bin,
                    "ensure_mcp_available",
                    return_value=(fake_process, True),
                ),
                patch.object(
                    ida_analyze_bin,
                    "_run_post_process_expected_outputs_via_mcp",
                    side_effect=RuntimeError("boom"),
                ),
                patch.object(
                    ida_analyze_bin,
                    "quit_ida_gracefully",
                    return_value=None,
                ),
            ):
                success, fail, skip = ida_analyze_bin.process_binary(
                    binary_path=binary_path,
                    skills=[
                        {
                            "name": "find-CEntFireOutputAutoCompletionFunctor_FireOutput",
                            "expected_output": [
                                "CEntFireOutputAutoCompletionFunctor_FireOutput.{platform}.yaml"
                            ],
                            "expected_input": [],
                        }
                    ],
                    agent="codex",
                    host="127.0.0.1",
                    port=13337,
                    ida_args="",
                    platform="windows",
                    debug=False,
                    max_retries=1,
                    rename=True,
                )

        self.assertEqual((0, 1, 1), (success, fail, skip))

    def test_process_binary_counts_post_process_preflight_collection_failure_once(
        self,
    ) -> None:
        with TemporaryDirectory() as temp_dir:
            binary_dir = Path(temp_dir) / "bin" / "14141" / "server"
            binary_dir.mkdir(parents=True, exist_ok=True)
            binary_path = str(binary_dir / "server.dll")
            (binary_dir / "CEntFireOutputAutoCompletionFunctor_FireOutput.windows.yaml").write_text(
                "func_name: CEntFireOutputAutoCompletionFunctor_FireOutput\n"
                "func_va: '0x180c165c0'\n",
                encoding="utf-8",
            )

            with (
                patch.object(
                    ida_analyze_bin,
                    "_collect_post_process_yaml_mappings",
                    side_effect=RuntimeError("collect boom"),
                ),
                patch.object(
                    ida_analyze_bin,
                    "start_idalib_mcp",
                ) as mock_start_ida,
            ):
                success, fail, skip = ida_analyze_bin.process_binary(
                    binary_path=binary_path,
                    skills=[
                        {
                            "name": "find-CEntFireOutputAutoCompletionFunctor_FireOutput",
                            "expected_output": [
                                "CEntFireOutputAutoCompletionFunctor_FireOutput.{platform}.yaml"
                            ],
                            "expected_input": [],
                        }
                    ],
                    agent="codex",
                    host="127.0.0.1",
                    port=13337,
                    ida_args="",
                    platform="windows",
                    debug=False,
                    max_retries=1,
                    rename=True,
                )

        self.assertEqual((0, 1, 1), (success, fail, skip))
        mock_start_ida.assert_not_called()

    def test_process_binary_counts_startup_failure_for_rename_only_work(
        self,
    ) -> None:
        with TemporaryDirectory() as temp_dir:
            binary_dir = Path(temp_dir) / "bin" / "14141" / "server"
            binary_dir.mkdir(parents=True, exist_ok=True)
            binary_path = str(binary_dir / "server.dll")
            (binary_dir / "CEntFireOutputAutoCompletionFunctor_FireOutput.windows.yaml").write_text(
                "func_name: CEntFireOutputAutoCompletionFunctor_FireOutput\n"
                "func_va: '0x180c165c0'\n",
                encoding="utf-8",
            )

            with patch.object(
                ida_analyze_bin,
                "start_idalib_mcp",
                return_value=None,
            ):
                success, fail, skip = ida_analyze_bin.process_binary(
                    binary_path=binary_path,
                    skills=[
                        {
                            "name": "find-CEntFireOutputAutoCompletionFunctor_FireOutput",
                            "expected_output": [
                                "CEntFireOutputAutoCompletionFunctor_FireOutput.{platform}.yaml"
                            ],
                            "expected_input": [],
                        }
                    ],
                    agent="codex",
                    host="127.0.0.1",
                    port=13337,
                    ida_args="",
                    platform="windows",
                    debug=False,
                    max_retries=1,
                    rename=True,
                )

        self.assertEqual((0, 1, 1), (success, fail, skip))


class TestExpectedInputArtifactValidation(unittest.IsolatedAsyncioTestCase):
    async def test_validate_expected_input_artifacts_reports_invalid_func_va_and_missing_func_sig(self) -> None:
        with TemporaryDirectory() as temp_dir:
            artifact_path = Path(temp_dir) / "CDemoRecorder_WriteSpawnGroups.linux.yaml"
            artifact_path.write_text(
                "\n".join(
                    [
                        "func_name: CDemoRecorder_WriteSpawnGroups",
                        "func_va: '0x616050'",
                        "func_rva: '0x616050'",
                        "func_size: '0x3263'",
                    ]
                ),
                encoding="utf-8",
            )

            with (
                patch.object(
                    ida_analyze_bin,
                    "_lookup_expected_input_artifact_category",
                    return_value="func",
                ),
                patch.object(
                    ida_analyze_bin,
                    "_inspect_func_va_via_session",
                    AsyncMock(
                        return_value={
                            "has_segment": True,
                            "segment_name": ".data",
                            "has_function": False,
                            "function_start": "",
                            "is_function_start": False,
                        }
                    ),
                ),
            ):
                issues = await ida_analyze_bin.validate_expected_input_artifacts_via_session(
                    session=MagicMock(),
                    expected_inputs=[str(artifact_path)],
                    platform="linux",
                    debug=False,
                )

        self.assertEqual(1, len(issues))
        self.assertIn(str(artifact_path), issues[0])
        self.assertIn(
            "func_va=0x616050 resolves to segment '.data' instead of '.text'",
            issues[0],
        )
        self.assertIn("missing required field func_sig", issues[0])

    async def test_validate_expected_input_artifacts_skips_func_va_mapping_for_sibling_module_artifact(
        self,
    ) -> None:
        with TemporaryDirectory() as temp_dir:
            gamever_dir = Path(temp_dir) / "bin" / "14155"
            engine_dir = gamever_dir / "engine"
            server_dir = gamever_dir / "server"
            engine_dir.mkdir(parents=True, exist_ok=True)
            server_dir.mkdir(parents=True, exist_ok=True)
            artifact_path = (
                server_dir
                / "CGameEntitySystem_BuildResourceManifest_ManifestNameOrGroupName.linux.yaml"
            )
            artifact_path.write_text(
                "\n".join(
                    [
                        "func_name: CGameEntitySystem_BuildResourceManifest_ManifestNameOrGroupName",
                        "func_va: '0x1527d60'",
                        "func_rva: '0x1527d60'",
                        "func_size: '0x11b'",
                        "func_sig: AA BB",
                        "vtable_name: CGameEntitySystem",
                        "vfunc_offset: '0x18'",
                        "vfunc_index: 3",
                    ]
                ),
                encoding="utf-8",
            )

            with (
                patch.object(
                    ida_analyze_bin,
                    "_lookup_expected_input_artifact_category",
                    return_value="vfunc",
                ),
                patch.object(
                    ida_analyze_bin,
                    "_inspect_func_va_via_session",
                    AsyncMock(
                        return_value={
                            "has_segment": False,
                            "segment_name": "",
                            "has_function": False,
                            "function_start": "",
                            "is_function_start": False,
                        }
                    ),
                ) as mock_inspect_func_va,
            ):
                issues = await ida_analyze_bin.validate_expected_input_artifacts_via_session(
                    session=MagicMock(),
                    expected_inputs=[str(artifact_path)],
                    platform="linux",
                    binary_dir=str(engine_dir),
                    debug=False,
                )

        self.assertEqual([], issues)
        mock_inspect_func_va.assert_not_awaited()


class _FakePipe:
    def __init__(self, chunks: list[str]) -> None:
        self._chunks = list(chunks)

    def readline(self) -> str:
        return self._chunks.pop(0) if self._chunks else ""

    def close(self) -> None:
        return None


class _FakeStdin:
    def __init__(self) -> None:
        self.writes: list[str] = []
        self.closed = False

    def write(self, data: str) -> int:
        self.writes.append(data)
        return len(data)

    def flush(self) -> None:
        return None

    def close(self) -> None:
        self.closed = True


class _FakePopen:
    def __init__(
        self,
        *,
        stdout_chunks: list[str] | None = None,
        stderr_chunks: list[str] | None = None,
        returncode: int = 0,
    ) -> None:
        self.stdout = _FakePipe(stdout_chunks or [])
        self.stderr = _FakePipe(stderr_chunks or [])
        self.stdin = _FakeStdin()
        self.returncode = returncode
        self.killed = False

    def wait(self, timeout: int | None = None) -> int:
        return self.returncode

    def kill(self) -> None:
        self.killed = True


class TestRunSkillOutputDetection(unittest.TestCase):
    def test_output_contains_error_marker_only_matches_standalone_tokens(self) -> None:
        self.assertTrue(ida_analyze_bin._output_contains_error_marker("Error"))
        self.assertTrue(ida_analyze_bin._output_contains_error_marker("prefix [ERROR] suffix"))
        self.assertTrue(ida_analyze_bin._output_contains_error_marker("before **ERROR** after"))
        self.assertTrue(ida_analyze_bin._output_contains_error_marker("line one\nerror\nline three"))

        self.assertFalse(ida_analyze_bin._output_contains_error_marker("myErrorCode"))
        self.assertFalse(ida_analyze_bin._output_contains_error_marker("error123"))
        self.assertFalse(ida_analyze_bin._output_contains_error_marker("XerrorY"))
        self.assertFalse(ida_analyze_bin._output_contains_error_marker("all good"))


class TestRunSkillCodexPromptTransport(unittest.TestCase):
    @patch.object(Path, 'read_text', return_value='sig finder prompt')
    @patch('ida_analyze_bin.os.path.exists', return_value=True)
    @patch('ida_analyze_bin._run_process_with_stream_capture')
    def test_run_skill_passes_codex_prompt_via_stdin_on_retry(
        self,
        mock_run_process,
        _mock_exists,
        _mock_read_text,
    ) -> None:
        mock_run_process.side_effect = [
            ida_analyze_bin.subprocess.CompletedProcess(
                args=['codex'],
                returncode=1,
                stdout='',
                stderr='first failure',
            ),
            ida_analyze_bin.subprocess.CompletedProcess(
                args=['codex'],
                returncode=0,
                stdout='',
                stderr='',
            ),
        ]

        result = ida_analyze_bin.run_skill(
            skill_name='find-IGameSystem_vtable',
            agent='codex',
            debug=False,
            max_retries=2,
        )

        self.assertTrue(result)
        self.assertEqual(2, mock_run_process.call_count)

        first_call = mock_run_process.call_args_list[0]
        second_call = mock_run_process.call_args_list[1]

        self.assertEqual(['exec', '-'], first_call.args[0][-2:])
        self.assertEqual(['exec', 'resume', '--last', '-'], second_call.args[0][-4:])
        expected_prompt = 'Run SKILL: .claude/skills/find-IGameSystem_vtable/SKILL.md'
        self.assertEqual(expected_prompt, first_call.kwargs['agent_input'])
        self.assertEqual(expected_prompt, second_call.kwargs['agent_input'])
        self.assertFalse(first_call.kwargs['debug'])
        self.assertFalse(second_call.kwargs['debug'])
        self.assertEqual(ida_analyze_bin.SKILL_TIMEOUT, first_call.kwargs['timeout'])
        self.assertEqual(ida_analyze_bin.SKILL_TIMEOUT, second_call.kwargs['timeout'])

    @patch('ida_analyze_bin.os.path.exists', return_value=True)
    @patch('ida_analyze_bin.subprocess.Popen')
    def test_run_skill_debug_true_forwards_stdout_and_stderr(
        self,
        mock_popen,
        _mock_exists,
    ) -> None:
        mock_popen.return_value = _FakePopen(
            stdout_chunks=['agent stdout line\n'],
            stderr_chunks=['agent stderr line\n'],
            returncode=0,
        )

        with patch('sys.stdout', new_callable=io.StringIO) as fake_stdout, patch(
            'sys.stderr', new_callable=io.StringIO
        ) as fake_stderr:
            result = ida_analyze_bin.run_skill(
                skill_name='find-IGameSystem_vtable',
                agent='claude',
                debug=True,
                max_retries=1,
            )

        self.assertTrue(result)
        self.assertIn('agent stdout line\n', fake_stdout.getvalue())
        self.assertIn('agent stderr line\n', fake_stderr.getvalue())

    @patch.object(Path, 'read_text', return_value='sig finder prompt')
    @patch('ida_analyze_bin.os.path.exists', return_value=True)
    @patch('ida_analyze_bin.subprocess.Popen')
    def test_run_skill_retries_when_output_contains_error_marker(
        self,
        mock_popen,
        _mock_exists,
        _mock_read_text,
    ) -> None:
        first_process = _FakePopen(
            stdout_chunks=['starting\n', '[ERROR] lookup failed\n'],
            stderr_chunks=[],
            returncode=0,
        )
        second_process = _FakePopen(
            stdout_chunks=['done\n'],
            stderr_chunks=[],
            returncode=0,
        )
        mock_popen.side_effect = [first_process, second_process]

        with patch('sys.stdout', new_callable=io.StringIO) as fake_stdout, patch(
            'sys.stderr', new_callable=io.StringIO
        ) as fake_stderr:
            result = ida_analyze_bin.run_skill(
                skill_name='find-IGameSystem_vtable',
                agent='codex',
                debug=False,
                max_retries=2,
            )

        self.assertTrue(result)
        self.assertEqual(2, mock_popen.call_count)
        self.assertNotIn('[ERROR] lookup failed\n', fake_stdout.getvalue())
        self.assertEqual('', fake_stderr.getvalue())
        expected_prompt = 'Run SKILL: .claude/skills/find-IGameSystem_vtable/SKILL.md'
        self.assertEqual(expected_prompt, ''.join(first_process.stdin.writes))
        self.assertEqual(expected_prompt, ''.join(second_process.stdin.writes))


@patch.dict(
    "os.environ",
    {
        "CS2VIBE_LLM_FAKE_AS": "",
        "CS2VIBE_LLM_EFFORT": "",
    },
    clear=False,
)
class TestParseArgsLlmOptions(unittest.TestCase):
    @patch.object(ida_analyze_bin, "resolve_oldgamever", return_value="14140")
    def test_parse_args_accepts_llm_options(
        self,
        _mock_resolve_oldgamever,
    ) -> None:
        with patch(
            "sys.argv",
            [
                "ida_analyze_bin.py",
                "-gamever",
                "14141",
                "-llm_model",
                "gpt-4.1-mini",
                "-llm_apikey",
                "test-api-key",
                "-llm_baseurl",
                "https://example.invalid/v1",
                "-llm_temperature",
                "0.25",
            ],
        ):
            args = ida_analyze_bin.parse_args()

        self.assertEqual("gpt-4.1-mini", args.llm_model)
        self.assertEqual("test-api-key", args.llm_apikey)
        self.assertEqual("https://example.invalid/v1", args.llm_baseurl)
        self.assertEqual(0.25, args.llm_temperature)

    @patch.object(ida_analyze_bin, "resolve_oldgamever", return_value="14140")
    def test_parse_args_uses_env_llm_temperature_by_default(
        self,
        _mock_resolve_oldgamever,
    ) -> None:
        with patch.dict(
            "os.environ",
            {
                "CS2VIBE_LLM_TEMPERATURE": "0.6",
                "CS2VIBE_LLM_FAKE_AS": "",
                "CS2VIBE_LLM_EFFORT": "",
            },
            clear=False,
        ), patch(
            "sys.argv",
            [
                "ida_analyze_bin.py",
                "-gamever",
                "14141",
            ],
        ):
            args = ida_analyze_bin.parse_args()

        self.assertEqual(0.6, args.llm_temperature)

    @patch.object(ida_analyze_bin, "resolve_oldgamever", return_value="14140")
    def test_parse_args_prefers_cli_llm_temperature_over_env(
        self,
        _mock_resolve_oldgamever,
    ) -> None:
        with patch.dict(
            "os.environ",
            {
                "CS2VIBE_LLM_TEMPERATURE": "0.6",
                "CS2VIBE_LLM_FAKE_AS": "",
                "CS2VIBE_LLM_EFFORT": "",
            },
            clear=False,
        ), patch(
            "sys.argv",
            [
                "ida_analyze_bin.py",
                "-gamever",
                "14141",
                "-llm_temperature",
                "0.3",
            ],
        ):
            args = ida_analyze_bin.parse_args()

        self.assertEqual(0.3, args.llm_temperature)

    @patch.object(ida_analyze_bin, "resolve_oldgamever", return_value="14140")
    def test_parse_args_accepts_llm_fake_as_and_effort(
        self,
        _mock_resolve_oldgamever,
    ) -> None:
        with patch(
            "sys.argv",
            [
                "ida_analyze_bin.py",
                "-gamever",
                "14141",
                "-llm_fake_as",
                "codex",
                "-llm_effort",
                "high",
                "-llm_temperature",
                "0.25",
            ],
        ):
            args = ida_analyze_bin.parse_args()

        self.assertEqual("codex", args.llm_fake_as)
        self.assertEqual("high", args.llm_effort)
        self.assertEqual(0.25, args.llm_temperature)

    @patch.object(ida_analyze_bin, "resolve_oldgamever", return_value="14140")
    def test_parse_args_uses_env_llm_fake_as_and_default_effort(
        self,
        _mock_resolve_oldgamever,
    ) -> None:
        with patch.dict(
            "os.environ",
            {
                "CS2VIBE_LLM_FAKE_AS": "codex",
                "CS2VIBE_LLM_EFFORT": "",
            },
            clear=False,
        ), patch(
            "sys.argv",
            [
                "ida_analyze_bin.py",
                "-gamever",
                "14141",
            ],
        ):
            args = ida_analyze_bin.parse_args()

        self.assertEqual("codex", args.llm_fake_as)
        self.assertEqual("medium", args.llm_effort)

    @patch.object(ida_analyze_bin, "resolve_oldgamever", return_value="14140")
    def test_parse_args_normalizes_blank_llm_fake_as_to_none(
        self,
        _mock_resolve_oldgamever,
    ) -> None:
        with patch(
            "sys.argv",
            [
                "ida_analyze_bin.py",
                "-gamever",
                "14141",
                "-llm_fake_as",
                "   ",
            ],
        ):
            args = ida_analyze_bin.parse_args()

        self.assertIsNone(args.llm_fake_as)

    @patch.object(ida_analyze_bin, "resolve_oldgamever", return_value="14140")
    def test_parse_args_normalizes_blank_llm_effort_to_medium(
        self,
        _mock_resolve_oldgamever,
    ) -> None:
        with patch(
            "sys.argv",
            [
                "ida_analyze_bin.py",
                "-gamever",
                "14141",
                "-llm_effort",
                "   ",
            ],
        ):
            args = ida_analyze_bin.parse_args()

        self.assertEqual("medium", args.llm_effort)

    @patch.object(ida_analyze_bin, "resolve_oldgamever", return_value="14140")
    def test_parse_args_prefers_cli_llm_effort_over_env(
        self,
        _mock_resolve_oldgamever,
    ) -> None:
        with patch.dict(
            "os.environ",
            {
                "CS2VIBE_LLM_FAKE_AS": "",
                "CS2VIBE_LLM_EFFORT": "low",
            },
            clear=False,
        ), patch(
            "sys.argv",
            [
                "ida_analyze_bin.py",
                "-gamever",
                "14141",
                "-llm_effort",
                "xhigh",
            ],
        ):
            args = ida_analyze_bin.parse_args()

        self.assertEqual("xhigh", args.llm_effort)

    @patch.object(ida_analyze_bin, "resolve_oldgamever", return_value="14140")
    def test_parse_args_rejects_invalid_llm_fake_as(
        self,
        _mock_resolve_oldgamever,
    ) -> None:
        with patch(
            "sys.argv",
            [
                "ida_analyze_bin.py",
                "-gamever",
                "14141",
                "-llm_fake_as",
                "openai",
            ],
        ), patch("sys.stderr", new_callable=io.StringIO) as fake_stderr:
            with self.assertRaises(SystemExit) as exc:
                ida_analyze_bin.parse_args()

        self.assertEqual(2, exc.exception.code)
        self.assertIn("Invalid LLM fake_as", fake_stderr.getvalue())

    @patch.object(ida_analyze_bin, "resolve_oldgamever", return_value="14140")
    def test_parse_args_rejects_invalid_llm_effort(
        self,
        _mock_resolve_oldgamever,
    ) -> None:
        with patch(
            "sys.argv",
            [
                "ida_analyze_bin.py",
                "-gamever",
                "14141",
                "-llm_effort",
                "turbo",
            ],
        ), patch("sys.stderr", new_callable=io.StringIO) as fake_stderr:
            with self.assertRaises(SystemExit) as exc:
                ida_analyze_bin.parse_args()

        self.assertEqual(2, exc.exception.code)
        self.assertIn("Invalid LLM effort", fake_stderr.getvalue())

    @patch.object(ida_analyze_bin, "resolve_oldgamever", return_value="14140")
    def test_parse_args_rejects_legacy_vcall_finder_model(
        self,
        _mock_resolve_oldgamever,
    ) -> None:
        with patch(
            "sys.argv",
            [
                "ida_analyze_bin.py",
                "-gamever",
                "14141",
                "-vcall_finder_model",
                "gpt-4o",
            ],
        ), patch("sys.stderr", new_callable=io.StringIO) as fake_stderr:
            with self.assertRaises(SystemExit) as exc:
                ida_analyze_bin.parse_args()

        self.assertEqual(2, exc.exception.code)
        self.assertIn("-vcall_finder_model", fake_stderr.getvalue())

    @patch.object(ida_analyze_bin, "resolve_oldgamever", return_value="14140")
    def test_parse_args_defaults_rename_to_false(
        self,
        _mock_resolve_oldgamever,
    ) -> None:
        with patch(
            "sys.argv",
            [
                "ida_analyze_bin.py",
                "-gamever",
                "14141",
            ],
        ):
            args = ida_analyze_bin.parse_args()

        self.assertFalse(args.rename)

    @patch.object(ida_analyze_bin, "resolve_oldgamever", return_value="14140")
    def test_parse_args_accepts_rename(
        self,
        _mock_resolve_oldgamever,
    ) -> None:
        with patch(
            "sys.argv",
            [
                "ida_analyze_bin.py",
                "-gamever",
                "14141",
                "-rename",
            ],
        ):
            args = ida_analyze_bin.parse_args()

        self.assertTrue(args.rename)


class TestProcessBinaryLlmWiring(unittest.TestCase):
    @patch("ida_analyze_bin.os.path.exists", return_value=False)
    @patch.object(ida_analyze_bin, "run_skill", return_value=False)
    @patch.object(
        ida_analyze_bin,
        "preprocess_single_skill_via_mcp",
        new_callable=AsyncMock,
        return_value=False,
    )
    @patch.object(ida_analyze_bin, "ensure_mcp_available")
    @patch.object(ida_analyze_bin, "start_idalib_mcp")
    @patch.object(ida_analyze_bin, "quit_ida_gracefully")
    def test_process_binary_passes_unified_llm_options_to_preprocess(
        self,
        _mock_quit_ida,
        mock_start_idalib_mcp,
        mock_ensure_mcp_available,
        mock_preprocess,
        _mock_run_skill,
        _mock_exists,
    ) -> None:
        fake_process = object()
        mock_start_idalib_mcp.return_value = fake_process
        mock_ensure_mcp_available.return_value = (fake_process, True)

        ida_analyze_bin.process_binary(
            binary_path="/tmp/bin/14141/networksystem/networksystem.dll",
            skills=[
                {
                    "name": "find-IGameSystem_vtable",
                    "expected_output": ["IGameSystem_vtable.{platform}.yaml"],
                    "expected_input": [],
                }
            ],
            agent="codex",
            host="127.0.0.1",
            port=13337,
            ida_args="",
            platform="windows",
            debug=False,
            max_retries=1,
            llm_model="gpt-4.1-mini",
            llm_apikey="test-api-key",
            llm_baseurl="https://example.invalid/v1",
            llm_temperature=0.4,
            llm_effort="high",
            llm_fake_as="codex",
        )

        self.assertEqual("gpt-4.1-mini", mock_preprocess.await_args.kwargs["llm_model"])
        self.assertEqual("test-api-key", mock_preprocess.await_args.kwargs["llm_apikey"])
        self.assertEqual(
            "https://example.invalid/v1",
            mock_preprocess.await_args.kwargs["llm_baseurl"],
        )
        self.assertEqual(0.4, mock_preprocess.await_args.kwargs["llm_temperature"])
        self.assertEqual("high", mock_preprocess.await_args.kwargs["llm_effort"])
        self.assertEqual("codex", mock_preprocess.await_args.kwargs["llm_fake_as"])

    @patch("ida_analyze_bin.os.path.exists", return_value=False)
    @patch.object(ida_analyze_bin, "run_skill", return_value=False)
    @patch.object(
        ida_analyze_bin,
        "preprocess_single_skill_via_mcp",
        new_callable=AsyncMock,
        return_value=False,
    )
    @patch.object(ida_analyze_bin, "ensure_mcp_available")
    @patch.object(ida_analyze_bin, "start_idalib_mcp")
    @patch.object(ida_analyze_bin, "quit_ida_gracefully")
    def test_process_binary_passes_skill_max_retries_to_preprocess(
        self,
        _mock_quit_ida,
        mock_start_idalib_mcp,
        mock_ensure_mcp_available,
        mock_preprocess,
        _mock_run_skill,
        _mock_exists,
    ) -> None:
        fake_process = object()
        mock_start_idalib_mcp.return_value = fake_process
        mock_ensure_mcp_available.return_value = (fake_process, True)

        ida_analyze_bin.process_binary(
            binary_path="/tmp/bin/14141/server/server.dll",
            skills=[
                {
                    "name": "find-IGameSystem_DestroyAllGameSystems",
                    "expected_output": [
                        "IGameSystem_DestroyAllGameSystems.{platform}.yaml"
                    ],
                    "expected_input": [],
                    "max_retries": 4,
                }
            ],
            agent="codex",
            host="127.0.0.1",
            port=13337,
            ida_args="",
            platform="windows",
            debug=False,
            max_retries=2,
            llm_model="gpt-5.4",
            llm_fake_as="codex",
        )

        self.assertEqual(4, mock_preprocess.await_args.kwargs["llm_max_retries"])


class TestMainLlmWiring(unittest.TestCase):
    @patch.object(ida_analyze_bin, "process_binary", return_value=(0, 0, 0))
    @patch.object(ida_analyze_bin, "parse_config")
    @patch("ida_analyze_bin.os.path.exists", return_value=True)
    @patch.object(ida_analyze_bin, "parse_args")
    def test_main_passes_unified_llm_options_to_vcall_aggregation(
        self,
        mock_parse_args,
        _mock_exists,
        mock_parse_config,
        _mock_process_binary,
    ) -> None:
        captured = {}

        def fake_aggregate_vcall_results_for_object(
            *,
            base_dir,
            gamever,
            object_name,
            model,
            api_key=None,
            base_url=None,
            temperature=None,
            effort=None,
            fake_as=None,
            client=None,
            debug=False,
        ):
            captured["kwargs"] = {
                "base_dir": base_dir,
                "gamever": gamever,
                "object_name": object_name,
                "model": model,
                "api_key": api_key,
                "base_url": base_url,
                "temperature": temperature,
                "effort": effort,
                "fake_as": fake_as,
                "client": client,
                "debug": debug,
            }
            return {"status": "success", "processed": 1, "failed": 0}

        mock_parse_args.return_value = SimpleNamespace(
            configyaml="config.yaml",
            bindir="bin",
            gamever="14141",
            oldgamever=None,
            platforms=["windows"],
            module_filter=None,
            modules="*",
            agent="codex",
            ida_args="",
            debug=False,
            maxretry=3,
            vcall_finder_filter={"all": True},
            llm_model="gpt-4.1-mini",
            llm_apikey="test-api-key",
            llm_baseurl="https://example.invalid/v1",
            llm_temperature=0.5,
            llm_effort="high",
            llm_fake_as="codex",
            rename=False,
        )
        mock_parse_config.return_value = [
            {
                "name": "networksystem",
                "skills": [],
                "vcall_finder_objects": ["g_pNetworkMessages"],
                "path_windows": "game/bin/win64/networksystem.dll",
            }
        ]

        with patch.object(
            ida_analyze_bin,
            "aggregate_vcall_results_for_object",
            new=fake_aggregate_vcall_results_for_object,
        ):
            ida_analyze_bin.main()

        self.assertEqual(
            {
                "base_dir": "vcall_finder",
                "gamever": "14141",
                "object_name": "g_pNetworkMessages",
                "model": "gpt-4.1-mini",
                "api_key": "test-api-key",
                "base_url": "https://example.invalid/v1",
                "temperature": 0.5,
                "effort": "high",
                "fake_as": "codex",
                "client": None,
                "debug": False,
            },
            captured["kwargs"],
        )


class TestMainPostProcessWiring(unittest.TestCase):
    @patch.object(ida_analyze_bin, "parse_config")
    @patch("ida_analyze_bin.os.path.exists", return_value=True)
    @patch.object(ida_analyze_bin, "parse_args")
    def test_main_passes_rename_to_process_binary(
        self,
        mock_parse_args,
        _mock_exists,
        mock_parse_config,
    ) -> None:
        captured = {}

        def fake_process_binary(*args, **kwargs):
            captured["kwargs"] = kwargs
            return (0, 0, 0)

        mock_parse_args.return_value = SimpleNamespace(
            configyaml="config.yaml",
            bindir="bin",
            gamever="14141",
            oldgamever=None,
            platforms=["windows"],
            module_filter=None,
            modules="*",
            agent="codex",
            ida_args="",
            debug=False,
            maxretry=3,
            vcall_finder_filter=None,
            llm_model="gpt-4.1-mini",
            llm_apikey=None,
            llm_baseurl=None,
            llm_temperature=None,
            llm_effort="high",
            llm_fake_as="codex",
            rename=True,
        )
        mock_parse_config.return_value = [
            {
                "name": "server",
                "skills": [
                    {
                        "name": "find-CEntFireOutputAutoCompletionFunctor_FireOutput",
                        "expected_output": [
                            "CEntFireOutputAutoCompletionFunctor_FireOutput.{platform}.yaml"
                        ],
                        "expected_input": [],
                    }
                ],
                "vcall_finder_objects": [],
                "path_windows": "game/bin/win64/server.dll",
            }
        ]

        with patch.object(ida_analyze_bin, "process_binary", new=fake_process_binary):
            ida_analyze_bin.main()

        self.assertTrue(captured["kwargs"]["rename"])


if __name__ == '__main__':
    unittest.main()
