from __future__ import annotations

import json
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
from unittest.mock import patch

from agentic_swmm.agent.tool_registry import AgentToolRegistry
from agentic_swmm.agent.types import ToolCall
from agentic_swmm.cli import _route_default_to_agent, build_parser
from agentic_swmm.commands.agent import _find_repo_inp
from agentic_swmm.agent.intent_map import load_intent_map
from agentic_swmm.agent.planner import OpenAIPlanner, _looks_like_swmm_request, _select_relevant_mcp_servers, _select_relevant_skills, _workflow_route_args
from agentic_swmm.agent.prompts import openai_planner_prompt
from agentic_swmm.utils.paths import script_path


REPO_ROOT = Path(__file__).resolve().parents[1]


class FakePreparedInpExecutor:
    def __init__(self, route: dict[str, object]):
        self.route = route
        self.results: list[dict[str, object]] = []
        self.dry_run = False

    def execute(self, call: ToolCall, *, index: int | None = None) -> dict[str, object]:
        if call.name == "list_skills":
            result: dict[str, object] = {"tool": call.name, "args": call.args, "ok": True, "summary": "12 skills available"}
        elif call.name == "read_skill":
            result = {"tool": call.name, "args": call.args, "ok": True, "summary": f"read skill {call.args.get('skill_name')}"}
        elif call.name == "list_mcp_servers":
            result = {"tool": call.name, "args": call.args, "ok": True, "summary": "8 configured MCP server(s)"}
        elif call.name == "list_mcp_tools":
            result = {"tool": call.name, "args": call.args, "ok": True, "summary": f"1 MCP tool(s) on {call.args.get('server')}", "mapped_tools": []}
        elif call.name == "select_workflow_mode":
            result: dict[str, object] = {"tool": call.name, "args": call.args, "ok": True, "results": self.route, "summary": "mode=prepared_inp_cli missing=0"}
        elif call.name == "run_swmm_inp":
            result = {"tool": call.name, "args": call.args, "ok": True, "summary": "standard layout: 00_inputs/, 04_builder/, 05_runner/, 06_qa/, manifest.json, command_trace.json"}
        elif call.name == "audit_run":
            result = {"tool": call.name, "args": call.args, "ok": True, "summary": "audit_note=/tmp/run/experiment_note.md"}
        elif call.name == "inspect_plot_options":
            result = {
                "tool": call.name,
                "args": call.args,
                "ok": True,
                "summary": "rain=1 nodes=2 attrs=4",
                "results": {
                    "rainfall_options": [{"name": "MACAO_94_23", "rain_kind": "cumulative_depth_mm"}],
                    "node_options": ["OU2", "OUT_0", "J2"],
                    "node_attribute_options": [
                        {"name": "Total_inflow"},
                        {"name": "Depth_above_invert"},
                        {"name": "Volume_stored_ponded"},
                        {"name": "Flow_lost_flooding"},
                    ],
                    "defaults": {"rain_ts": "MACAO_94_23", "node": "OU2", "node_attr": "Total_inflow"},
                    "selections_needed": ["node", "node_attr"],
                },
            }
        elif call.name == "plot_run":
            result = {"tool": call.name, "args": call.args, "ok": True, "summary": "plot: /tmp/run/07_plots/fig_rain_runoff.png"}
        else:
            result = {"tool": call.name, "args": call.args, "ok": False, "summary": f"unexpected tool: {call.name}"}
        self.results.append(result)
        return result


class AgenticSwmmCliTests(unittest.TestCase):
    def test_script_path_prefers_source_checkout_resource(self) -> None:
        expected = REPO_ROOT / "skills" / "swmm-runner" / "scripts" / "swmm_runner.py"

        self.assertEqual(script_path("skills", "swmm-runner", "scripts", "swmm_runner.py"), expected)

    def test_help_lists_primary_commands(self) -> None:
        proc = subprocess.run(
            [sys.executable, "-m", "agentic_swmm.cli", "--help"],
            cwd=REPO_ROOT,
            capture_output=True,
            text=True,
            check=True,
        )

        self.assertIn("doctor", proc.stdout)
        self.assertIn("agent", proc.stdout)
        self.assertNotIn("chat", proc.stdout)
        self.assertIn("model", proc.stdout)
        self.assertIn("config", proc.stdout)
        self.assertIn("capabilities", proc.stdout)
        self.assertIn("setup", proc.stdout)
        self.assertIn("mcp", proc.stdout)
        self.assertIn("skill", proc.stdout)
        self.assertIn("run", proc.stdout)
        self.assertIn("audit", proc.stdout)
        self.assertIn("plot", proc.stdout)
        self.assertIn("memory", proc.stdout)

    def test_model_config_uses_isolated_config_dir(self) -> None:
        with tempfile.TemporaryDirectory() as tmp:
            env = os.environ.copy()
            env["AISWMM_CONFIG_DIR"] = tmp
            proc = subprocess.run(
                [
                    sys.executable,
                    "-m",
                    "agentic_swmm.cli",
                    "model",
                    "--provider",
                    "openai",
                    "--model",
                    "gpt-test",
                ],
                cwd=REPO_ROOT,
                env=env,
                capture_output=True,
                text=True,
                check=True,
            )

            self.assertIn("provider: openai", proc.stdout)
            self.assertIn("model: gpt-test", proc.stdout)
            self.assertTrue((Path(tmp) / "config.toml").exists())

    def test_setup_accepts_newer_openai_model_names(self) -> None:
        with tempfile.TemporaryDirectory() as tmp:
            env = os.environ.copy()
            env["AISWMM_CONFIG_DIR"] = tmp
            proc = subprocess.run(
                [
                    sys.executable,
                    "-m",
                    "agentic_swmm.cli",
                    "setup",
                    "--provider",
                    "openai",
                    "--model",
                    "gpt-5.4",
                    "--json",
                ],
                cwd=REPO_ROOT,
                env=env,
                capture_output=True,
                text=True,
                check=True,
            )
            payload = json.loads(proc.stdout)

            self.assertEqual(payload["provider"]["model"], "gpt-5.4")

    def test_legacy_chat_command_routes_to_openai_agent(self) -> None:
        with tempfile.TemporaryDirectory() as tmp:
            env = os.environ.copy()
            env["AISWMM_CONFIG_DIR"] = tmp
            env["AISWMM_OPENAI_MOCK_RESPONSE"] = "mocked agent answer"
            proc = subprocess.run(
                [
                    sys.executable,
                    "-m",
                    "agentic_swmm.cli",
                    "chat",
                    "--provider",
                    "openai",
                    "--model",
                    "gpt-test",
                    "summarize",
                    "this",
                    "run",
                ],
                cwd=REPO_ROOT,
                env=env,
                capture_output=True,
                text=True,
                check=True,
            )

            self.assertIn("aiswmm executor", proc.stdout)
            self.assertIn("aiswmm> Planner: openai", proc.stdout)
            self.assertIn("mocked agent answer", proc.stdout)

    def test_cli_without_command_defaults_to_openai_agent(self) -> None:
        with tempfile.TemporaryDirectory() as tmp:
            env = os.environ.copy()
            env["AISWMM_CONFIG_DIR"] = tmp
            env["AISWMM_OPENAI_MOCK_RESPONSE"] = "mocked default agent"
            proc = subprocess.run(
                [sys.executable, "-m", "agentic_swmm.cli", "--model", "gpt-test"],
                cwd=REPO_ROOT,
                env=env,
                input="inspect project\n/exit\n",
                capture_output=True,
                text=True,
                check=True,
            )

            # Runtime UX PRD trimmed the startup banner to a single line —
            # match the new "aiswmm interactive (...)" header.
            self.assertIn("aiswmm interactive", proc.stdout)
            self.assertIn("aiswmm executor", proc.stdout)
            self.assertIn("aiswmm> Planner: openai", proc.stdout)
            self.assertIn("aiswmm> Goal: inspect project", proc.stdout)
            self.assertIn("mocked default agent", proc.stdout)

    def test_interactive_new_session_command_switches_context_without_nested_dirs(self) -> None:
        with tempfile.TemporaryDirectory() as tmp:
            env = os.environ.copy()
            env["AISWMM_CONFIG_DIR"] = tmp
            env["AISWMM_OPENAI_MOCK_RESPONSE"] = "mocked default agent"
            session_base = Path(tmp) / "interactive"
            proc = subprocess.run(
                [
                    sys.executable,
                    "-m",
                    "agentic_swmm.cli",
                    "agent",
                    "--planner",
                    "openai",
                    "--interactive",
                    "--model",
                    "gpt-test",
                    "--session-dir",
                    str(session_base),
                ],
                cwd=REPO_ROOT,
                env=env,
                input="/new-session\ninspect project\n/exit\n",
                capture_output=True,
                text=True,
                check=True,
            )

            self.assertIn("New session:", proc.stdout)
            self.assertIn("Date folder:", proc.stdout)
            self.assertIn("aiswmm> Goal: inspect project", proc.stdout)
            date_dirs = [path for path in session_base.iterdir() if path.is_dir()]
            self.assertEqual(len(date_dirs), 1)
            output_dirs = [path for path in date_dirs[0].iterdir() if path.is_dir()]
            self.assertEqual(len(output_dirs), 1)
            self.assertIn("_chat", output_dirs[0].name)
            self.assertTrue((date_dirs[0] / "_sessions.jsonl").exists())

    def test_natural_language_goal_defaults_to_openai_agent(self) -> None:
        with tempfile.TemporaryDirectory() as tmp:
            env = os.environ.copy()
            env["AISWMM_CONFIG_DIR"] = tmp
            env["AISWMM_OPENAI_MOCK_RESPONSE"] = "mocked natural language agent"
            proc = subprocess.run(
                [
                    sys.executable,
                    "-m",
                    "agentic_swmm.cli",
                    "inspect",
                    "the",
                    "project",
                ],
                cwd=REPO_ROOT,
                env=env,
                capture_output=True,
                text=True,
                check=True,
            )

            self.assertIn("aiswmm executor", proc.stdout)
            self.assertIn("aiswmm> Goal: inspect the project", proc.stdout)
            self.assertIn("mocked natural language agent", proc.stdout)

    def test_default_router_preserves_explicit_low_level_run(self) -> None:
        # PRD-08 A.3's provider preflight downgrades the interactive
        # ``--planner openai`` dispatch to ``--planner rule`` when no
        # LLM provider is configured. Pin ``OPENAI_API_KEY`` so this
        # test exercises the with-provider routing; the no-provider
        # fallback is covered by tests/test_cli_provider_preflight.py.
        with mock.patch.dict(os.environ, {"OPENAI_API_KEY": "test-key"}):
            self.assertEqual(_route_default_to_agent([]), ["agent", "--planner", "openai", "--interactive"])
            self.assertEqual(_route_default_to_agent(["chat"]), ["agent", "--planner", "openai", "--interactive"])
            self.assertEqual(
                _route_default_to_agent(["--model", "gpt-test"]),
                ["agent", "--planner", "openai", "--interactive", "--model", "gpt-test"],
            )
            self.assertEqual(_route_default_to_agent(["run", "--inp", "model.inp"]), ["run", "--inp", "model.inp"])
            self.assertEqual(_route_default_to_agent(["capabilities"]), ["capabilities"])
            self.assertEqual(
                _route_default_to_agent(["--verbose", "--model", "gpt-test"]),
                ["agent", "--planner", "openai", "--interactive", "--verbose", "--model", "gpt-test"],
            )
            self.assertEqual(
                _route_default_to_agent(["run", "tecnopolo_r1_199401.inp"]),
                ["agent", "--planner", "openai", "run", "tecnopolo_r1_199401.inp"],
            )

    def test_default_router_preserves_run_help_flags(self) -> None:
        # Regression: ``aiswmm run --help`` must reach the run subparser,
        # not be hijacked to ``aiswmm agent run --help``. The dispatcher
        # used to detect missing ``--inp`` and route to the agent so the
        # natural-language planner could prompt for missing inputs, but
        # ``--help`` is the user asking for the run command's usage —
        # never a goal description.
        self.assertEqual(
            _route_default_to_agent(["run", "--help"]),
            ["run", "--help"],
        )
        self.assertEqual(
            _route_default_to_agent(["run", "-h"]),
            ["run", "-h"],
        )

    def test_agent_resolves_bare_inp_names_from_examples(self) -> None:
        self.assertEqual(
            _find_repo_inp("tecnopolo_r1_199401.inp"),
            REPO_ROOT / "examples" / "tecnopolo" / "tecnopolo_r1_199401.inp",
        )

    def test_agent_default_step_budget_covers_run_audit_plot(self) -> None:
        parser = build_parser()
        args = parser.parse_args(["agent", "run", "examples/tecnopolo"])

        self.assertEqual(args.max_steps, 16)

    def test_examples_directory_goals_are_swmm_requests(self) -> None:
        self.assertTrue(_looks_like_swmm_request("examples/tecnopolo/。你帮我跑一下这个我看看"))
        self.assertEqual(
            _workflow_route_args("examples/tecnopolo/。你帮我跑一下这个我看看")["inp_path"],
            "examples/tecnopolo/tecnopolo_r1_199401.inp",
        )

    def test_openai_planner_prompt_loads_startup_identity_memory(self) -> None:
        prompt = openai_planner_prompt()

        self.assertIn("Startup memory: identification_memory.md", prompt)
        # PR #74 rewrote the agent's startup memory in first-person warm
        # identity ("I am **aiswmm**, ...") instead of the older
        # second-person framing ("You are **aiswmm**").
        self.assertIn("I am **aiswmm**", prompt)

    def test_intent_map_is_external_config(self) -> None:
        payload = load_intent_map()

        self.assertEqual(payload["schema_version"], "1.0")
        self.assertIn("swmm-end-to-end", payload["always_load_skills"])
        uncertainty = next(intent for intent in payload["intents"] if intent["id"] == "uncertainty")
        self.assertIn("required_inputs", uncertainty)
        self.assertIn("preferred_tools", uncertainty)
        self.assertIn("stop_conditions", uncertainty)
        self.assertIn("next_user_prompt", uncertainty)
        self.assertIn("swmm-runner", payload["mcp_enabled_skills"])

    def test_relevant_skill_selection_is_dynamic(self) -> None:
        self.assertEqual(
            _select_relevant_skills("run examples/tecnopolo/model.inp and audit it")[:3],
            ["swmm-end-to-end", "swmm-runner", "swmm-experiment-audit"],
        )
        self.assertIn("swmm-calibration", _select_relevant_skills("run sensitivity calibration with observed flow and NSE"))
        self.assertIn("swmm-uncertainty", _select_relevant_skills("propagate fuzzy alpha-cut uncertainty"))
        self.assertIn("swmm-gis", _select_relevant_skills("build from GeoPackage GIS subcatchment data"))
        self.assertIn("swmm-climate", _select_relevant_skills("format rainfall raingage timeseries"))
        self.assertIn("swmm-network", _select_relevant_skills("check junction conduit outfall network"))
        self.assertIn("swmm-builder", _select_relevant_skills("build INP from network_json and subcatchments_csv"))
        self.assertIn("swmm-modeling-memory", _select_relevant_skills("summarize modeling memory lessons"))

    def test_relevant_mcp_selection_follows_mcp_enabled_skills(self) -> None:
        # Issue #124 Part D: ``mcp_enabled_skills`` now covers all 11 shipped
        # MCP servers, so ``swmm-experiment-audit`` and ``swmm-uncertainty``
        # pass through. ``swmm-end-to-end`` is still skill-only (no MCP).
        self.assertEqual(
            _select_relevant_mcp_servers(["swmm-end-to-end", "swmm-runner", "swmm-plot", "swmm-experiment-audit"]),
            ["swmm-runner", "swmm-plot", "swmm-experiment-audit"],
        )
        self.assertEqual(
            _select_relevant_mcp_servers(["swmm-calibration", "swmm-uncertainty", "swmm-gis"]),
            ["swmm-calibration", "swmm-uncertainty", "swmm-gis"],
        )

    def test_plot_continuation_uses_previous_run_directory(self) -> None:
        route = {
            "mode": "existing_run_plot",
            "missing_inputs": [],
            "provided_values": {"run_dir": "runs/agent/interactive/session/runs/003-tecnopolo"},
        }
        executor = FakePreparedInpExecutor(route)
        planner = OpenAIPlanner(provider=None, registry=AgentToolRegistry(), max_steps=16)
        with tempfile.TemporaryDirectory() as tmp:
            outcome = planner.run(
                goal=(
                    "我想要 MACAO_94_23，node J2，Total_inflow 帮我作图\n\n"
                    "Previous run directory: runs/agent/interactive/session/runs/003-tecnopolo"
                ),
                session_dir=Path(tmp),
                trace_path=Path(tmp) / "agent_trace.jsonl",
                executor=executor,
            )

        self.assertTrue(outcome.ok)
        self.assertEqual(outcome.plan[0].name, "list_skills")
        skill_names = [call.args["skill_name"] for call in outcome.plan if call.name == "read_skill"]
        self.assertIn("swmm-end-to-end", skill_names)
        self.assertIn("swmm-plot", skill_names)
        self.assertIn("swmm-runner", skill_names)
        mcp_servers = [call.args["server"] for call in outcome.plan if call.name == "list_mcp_tools"]
        self.assertIn("swmm-runner", mcp_servers)
        self.assertIn("swmm-plot", mcp_servers)
        self.assertIn("list_mcp_servers", [call.name for call in outcome.plan])
        self.assertEqual(outcome.plan[-3].name, "select_workflow_mode")
        self.assertEqual(outcome.plan[-2].name, "inspect_plot_options")
        self.assertEqual(outcome.plan[-1].name, "plot_run")
        self.assertEqual(outcome.plan[-1].args["run_dir"], "runs/agent/interactive/session/runs/003-tecnopolo")
        self.assertEqual(outcome.plan[-1].args["rain_ts"], "MACAO_94_23")
        self.assertEqual(outcome.plan[-1].args["node_attr"], "Total_inflow")
        self.assertIn("fig_J2_Total_inflow.png", outcome.plan[-1].args["out_png"])

    def test_plot_selection_variable_with_run_dir_routes_to_existing_run_plot(self) -> None:
        # PRD-INTENT-OVERMATCH (#95) removed "total_inflow" et al. from the
        # wants_plot keyword tuple — node-attribute names alone are no longer
        # a plot trigger. Going forward, the LLM is expected to pass
        # ``mode="existing_run_plot"`` explicitly when it identifies a plot
        # intent. This test now exercises that path.
        registry = AgentToolRegistry()
        result = registry.execute(
            ToolCall(
                "select_workflow_mode",
                {
                    "goal": "Total_inflow J2 MACAO_94_23\n\nPrevious run directory: runs/2026-05-11/204519_tecnopolo_run",
                    "run_dir": "runs/2026-05-11/204519_tecnopolo_run",
                    "mode": "existing_run_plot",
                },
            ),
            Path(tempfile.gettempdir()),
        )

        self.assertTrue(result["ok"])
        self.assertEqual(result["results"]["mode"], "existing_run_plot")
        self.assertEqual(result["results"]["missing_inputs"], [])

    def test_plot_followup_uses_global_active_run_when_run_dir_is_omitted(self) -> None:
        with tempfile.TemporaryDirectory() as tmp, patch.dict(os.environ, {"AISWMM_CONFIG_DIR": tmp}):
            active_run_dir = "runs/2026-05-11/204519_tecnopolo_run"
            (Path(tmp) / "state.json").write_text(
                json.dumps(
                    {
                        "active_case_id": "204519_tecnopolo_run",
                        "active_run_dir": active_run_dir,
                        "recent_cases": [{"case_id": "204519_tecnopolo_run", "active_run_dir": active_run_dir}],
                    }
                ),
                encoding="utf-8",
            )
            registry = AgentToolRegistry()
            result = registry.execute(ToolCall("select_workflow_mode", {"goal": "plot J2 depth"}), Path(tmp))

        self.assertTrue(result["ok"])
        self.assertEqual(result["results"]["mode"], "existing_run_plot")
        self.assertEqual(result["results"]["provided_values"]["run_dir"], active_run_dir)
        self.assertEqual(result["results"]["missing_inputs"], [])

    def test_audit_followup_uses_global_active_run_when_run_dir_is_omitted(self) -> None:
        with tempfile.TemporaryDirectory() as tmp, patch.dict(os.environ, {"AISWMM_CONFIG_DIR": tmp}):
            active_run_dir = "runs/2026-05-11/204519_tecnopolo_run"
            (Path(tmp) / "state.json").write_text(
                json.dumps({"active_case_id": "204519_tecnopolo_run", "active_run_dir": active_run_dir}),
                encoding="utf-8",
            )
            registry = AgentToolRegistry()
            result = registry.execute(ToolCall("select_workflow_mode", {"goal": "audit it"}), Path(tmp))

        self.assertTrue(result["ok"])
        self.assertEqual(result["results"]["mode"], "audit_only_or_comparison")
        self.assertEqual(result["results"]["provided_values"]["run_dir"], active_run_dir)
        self.assertEqual(result["results"]["missing_inputs"], [])
        self.assertEqual(result["results"]["recommended_next_tools"], ["audit_run"])

    def test_planner_executes_audit_followup_from_workflow_route(self) -> None:
        route = {
            "mode": "audit_only_or_comparison",
            "missing_inputs": [],
            "provided_values": {"run_dir": "runs/2026-05-11/204519_tecnopolo_run"},
        }
        executor = FakePreparedInpExecutor(route)
        planner = OpenAIPlanner(provider=None, registry=AgentToolRegistry(), max_steps=16)
        with tempfile.TemporaryDirectory() as tmp:
            outcome = planner.run(
                goal="audit it",
                session_dir=Path(tmp),
                trace_path=Path(tmp) / "agent_trace.jsonl",
                executor=executor,
            )

        self.assertTrue(outcome.ok)
        self.assertEqual(outcome.plan[-1].name, "audit_run")
        self.assertEqual(outcome.plan[-1].args["run_dir"], "runs/2026-05-11/204519_tecnopolo_run")
        self.assertIn("audit", outcome.final_text.lower())

    def test_plot_option_request_does_not_redraw_default_peak(self) -> None:
        route = {
            "mode": "existing_run_plot",
            "missing_inputs": [],
            "provided_values": {"run_dir": "runs/agent/interactive/session/runs/003-tecnopolo"},
        }
        executor = FakePreparedInpExecutor(route)
        planner = OpenAIPlanner(provider=None, registry=AgentToolRegistry(), max_steps=16)
        with tempfile.TemporaryDirectory() as tmp:
            outcome = planner.run(
                goal=(
                    "能不能换个图，你有别的作图选项么，我不想要 peak\n\n"
                    "Previous run directory: runs/agent/interactive/session/runs/003-tecnopolo"
                ),
                session_dir=Path(tmp),
                trace_path=Path(tmp) / "agent_trace.jsonl",
                executor=executor,
            )

        self.assertTrue(outcome.ok)
        skill_names = [call.args["skill_name"] for call in outcome.plan if call.name == "read_skill"]
        self.assertIn("swmm-end-to-end", skill_names)
        self.assertIn("swmm-plot", skill_names)
        self.assertEqual(outcome.plan[-2].name, "select_workflow_mode")
        self.assertEqual(outcome.plan[-1].name, "inspect_plot_options")
        self.assertIn("plot variable options", outcome.final_text)
        self.assertIn("Depth_above_invert", outcome.final_text)

    def test_plot_continuation_can_switch_to_depth(self) -> None:
        route = {
            "mode": "existing_run_plot",
            "missing_inputs": [],
            "provided_values": {"run_dir": "runs/agent/interactive/session/runs/003-tecnopolo"},
        }
        executor = FakePreparedInpExecutor(route)
        planner = OpenAIPlanner(provider=None, registry=AgentToolRegistry(), max_steps=16)
        with tempfile.TemporaryDirectory() as tmp:
            outcome = planner.run(
                goal=(
                    "换成 node J2 的水深图\n\n"
                    "Previous run directory: runs/agent/interactive/session/runs/003-tecnopolo"
                ),
                session_dir=Path(tmp),
                trace_path=Path(tmp) / "agent_trace.jsonl",
                executor=executor,
            )

        self.assertTrue(outcome.ok)
        self.assertEqual(outcome.plan[-1].name, "plot_run")
        self.assertEqual(outcome.plan[-1].args["node"], "J2")
        self.assertEqual(outcome.plan[-1].args["node_attr"], "Depth_above_invert")
        self.assertIn("fig_J2_Depth_above_invert.png", outcome.plan[-1].args["out_png"])

    def test_session_state_tracks_workflow_state_for_plot_run(self) -> None:
        from agentic_swmm.agent.state import write_session_state

        route = {
            "mode": "existing_run_plot",
            "missing_inputs": [],
            "provided_values": {"run_dir": "runs/agent/interactive/session/runs/003-tecnopolo"},
        }
        executor = FakePreparedInpExecutor(route)
        planner = OpenAIPlanner(provider=None, registry=AgentToolRegistry(), max_steps=16)
        with tempfile.TemporaryDirectory() as tmp:
            session_dir = Path(tmp)
            outcome = planner.run(
                goal=(
                    "换成 node J2 的水深图\n\n"
                    "Previous run directory: runs/agent/interactive/session/runs/003-tecnopolo"
                ),
                session_dir=session_dir,
                trace_path=session_dir / "agent_trace.jsonl",
                executor=executor,
            )
            state_path, context_path = write_session_state(
                session_dir=session_dir,
                goal="换成 node J2 的水深图",
                planner="openai",
                model="gpt-test",
                allowed_tools=AgentToolRegistry().sorted_names(),
                outcome=outcome,
            )

            state = json.loads(state_path.read_text(encoding="utf-8"))
            context_text = context_path.read_text(encoding="utf-8")

        self.assertEqual(state["workflow_state"]["active_run_dir"], "runs/agent/interactive/session/runs/003-tecnopolo")
        self.assertEqual(state["workflow_state"]["selected_node"], "J2")
        self.assertEqual(state["workflow_state"]["selected_variable"], "Depth_above_invert")
        self.assertIn("plot", state["workflow_state"]["selected_intents"])
        self.assertTrue(state["intent_contracts"])
        self.assertIn("Workflow State", context_text)

    def test_session_state_persists_global_active_run_pointer(self) -> None:
        from agentic_swmm.agent.state import write_session_state

        route = {
            "mode": "existing_run_plot",
            "missing_inputs": [],
            "provided_values": {"run_dir": "runs/agent/interactive/session/runs/003-tecnopolo"},
        }
        executor = FakePreparedInpExecutor(route)
        planner = OpenAIPlanner(provider=None, registry=AgentToolRegistry(), max_steps=16)
        with tempfile.TemporaryDirectory() as tmp, patch.dict(os.environ, {"AISWMM_CONFIG_DIR": tmp}):
            session_dir = Path(tmp) / "session"
            session_dir.mkdir()
            outcome = planner.run(
                goal="plot J2 depth",
                session_dir=session_dir,
                trace_path=session_dir / "agent_trace.jsonl",
                executor=executor,
            )
            write_session_state(
                session_dir=session_dir,
                goal="plot J2 depth",
                planner="openai",
                model="gpt-test",
                allowed_tools=AgentToolRegistry().sorted_names(),
                outcome=outcome,
            )

            global_state = json.loads((Path(tmp) / "state.json").read_text(encoding="utf-8"))

        self.assertEqual(global_state["active_run_dir"], "runs/agent/interactive/session/runs/003-tecnopolo")
        self.assertEqual(global_state["active_case_id"], "003-tecnopolo")
        self.assertEqual(global_state["recent_cases"][0]["case_id"], "003-tecnopolo")
        self.assertEqual(global_state["recent_cases"][0]["active_run_dir"], "runs/agent/interactive/session/runs/003-tecnopolo")

    def test_session_state_persists_case_state_in_active_run_folder(self) -> None:
        from agentic_swmm.agent.state import write_session_state

        with tempfile.TemporaryDirectory() as tmp, patch.dict(os.environ, {"AISWMM_CONFIG_DIR": str(Path(tmp) / "config")}):
            active_run_dir = Path(tmp) / "runs" / "003-tecnopolo"
            route = {
                "mode": "existing_run_plot",
                "missing_inputs": [],
                "provided_values": {"run_dir": str(active_run_dir)},
            }
            executor = FakePreparedInpExecutor(route)
            planner = OpenAIPlanner(provider=None, registry=AgentToolRegistry(), max_steps=16)
            session_dir = Path(tmp) / "session"
            session_dir.mkdir()
            outcome = planner.run(
                goal="plot J2 depth",
                session_dir=session_dir,
                trace_path=session_dir / "agent_trace.jsonl",
                executor=executor,
            )
            write_session_state(
                session_dir=session_dir,
                goal="plot J2 depth",
                planner="openai",
                model="gpt-test",
                allowed_tools=AgentToolRegistry().sorted_names(),
                outcome=outcome,
            )

            case_state = json.loads((active_run_dir / "aiswmm_state.json").read_text(encoding="utf-8"))

        self.assertEqual(case_state["case_id"], "003-tecnopolo")
        self.assertEqual(case_state["active_run_dir"], str(active_run_dir))
        self.assertEqual(case_state["selected_node"], "J2")
        self.assertEqual(case_state["selected_plot_variable"], "Depth_above_invert")
        self.assertIn("plot", case_state["available_next_actions"])

    def test_prepared_inp_workflow_stops_to_guide_plot_choice(self) -> None:
        route = {
            "mode": "prepared_inp_cli",
            "missing_inputs": [],
            "provided_values": {"inp_path": "examples/tecnopolo/tecnopolo_r1_199401.inp"},
        }
        executor = FakePreparedInpExecutor(route)
        planner = OpenAIPlanner(provider=None, registry=AgentToolRegistry(), max_steps=16)
        # Round 1 integration adds a post-flight QA gate that reads the
        # .rpt SWMM would have produced. This test uses a fake executor
        # so no .rpt exists; opt the gate out for this routing-focused
        # test. The integration test in
        # test_workflow_mode_memory_integration.py exercises the gate
        # path proper.
        with mock.patch.dict(os.environ, {"AISWMM_DISABLE_SWMM_GATES": "1"}):
            with tempfile.TemporaryDirectory() as tmp:
                outcome = planner.run(
                    goal="examples/tecnopolo/。你帮我跑一下这个我看看",
                    session_dir=Path(tmp),
                    trace_path=Path(tmp) / "agent_trace.jsonl",
                    executor=executor,
                )

        self.assertTrue(outcome.ok)
        skill_names = [call.args["skill_name"] for call in outcome.plan if call.name == "read_skill"]
        self.assertIn("swmm-end-to-end", skill_names)
        self.assertIn("swmm-runner", skill_names)
        self.assertEqual(outcome.plan[-4].name, "select_workflow_mode")
        self.assertEqual(outcome.plan[-3].name, "run_swmm_inp")
        self.assertEqual(outcome.plan[-2].name, "audit_run")
        self.assertEqual(outcome.plan[-1].name, "inspect_plot_options")
        self.assertIn("Before plotting", outcome.final_text)
        self.assertIn("Depth_above_invert", outcome.final_text)

    def test_prepared_inp_workflow_plots_when_user_selects_depth(self) -> None:
        route = {
            "mode": "prepared_inp_cli",
            "missing_inputs": [],
            "provided_values": {"inp_path": "examples/tecnopolo/tecnopolo_r1_199401.inp"},
        }
        executor = FakePreparedInpExecutor(route)
        planner = OpenAIPlanner(provider=None, registry=AgentToolRegistry(), max_steps=16)
        # Round 1 integration: opt out of the post-flight gate (no
        # real SWMM run = no .rpt for the gate to read).
        with mock.patch.dict(os.environ, {"AISWMM_DISABLE_SWMM_GATES": "1"}):
            with tempfile.TemporaryDirectory() as tmp:
                outcome = planner.run(
                    goal="examples/tecnopolo/ 帮我画 OU2 的水深图",
                    session_dir=Path(tmp),
                    trace_path=Path(tmp) / "agent_trace.jsonl",
                    executor=executor,
                )

        self.assertTrue(outcome.ok)
        self.assertEqual(outcome.plan[-1].name, "plot_run")
        self.assertEqual(outcome.plan[-1].args["node"], "OU2")
        self.assertEqual(outcome.plan[-1].args["node_attr"], "Depth_above_invert")

    def test_agent_dry_run_plans_acceptance_audit(self) -> None:
        with tempfile.TemporaryDirectory() as tmp:
            proc = subprocess.run(
                [
                    sys.executable,
                    "-m",
                    "agentic_swmm.cli",
                    "agent",
                    "--session-dir",
                    str(Path(tmp) / "agent-session"),
                    "--dry-run",
                    "run acceptance and audit",
                ],
                cwd=REPO_ROOT,
                env={**os.environ, "AISWMM_CONFIG_DIR": tmp},
                capture_output=True,
                text=True,
                check=True,
            )

            self.assertIn("aiswmm executor", proc.stdout)
            self.assertIn("demo_acceptance", proc.stdout)
            self.assertIn("audit_run", proc.stdout)
            report = Path(tmp) / "agent-session" / "final_report.md"
            self.assertTrue(report.exists())

    def test_agent_openai_planner_uses_mock_tool_calls(self) -> None:
        with tempfile.TemporaryDirectory() as tmp:
            session_dir = Path(tmp) / "agent-session"
            env = os.environ.copy()
            env["AISWMM_CONFIG_DIR"] = tmp
            env["AISWMM_OPENAI_MOCK_TOOL_CALLS"] = json.dumps(
                [
                    {"name": "doctor", "arguments": {}},
                    {"name": "read_file", "arguments": {"path": "README.md"}},
                ]
            )
            env["AISWMM_OPENAI_MOCK_RESPONSE"] = "mock planner final"
            proc = subprocess.run(
                [
                    sys.executable,
                    "-m",
                    "agentic_swmm.cli",
                    "agent",
                    "--planner",
                    "openai",
                    "--model",
                    "gpt-test",
                    "--session-dir",
                    str(session_dir),
                    "--dry-run",
                    "inspect the project",
                ],
                cwd=REPO_ROOT,
                env=env,
                capture_output=True,
                text=True,
                check=True,
            )

            self.assertIn("aiswmm> Planner: openai", proc.stdout)
            self.assertIn("doctor", proc.stdout)
            report = (session_dir / "final_report.md").read_text(encoding="utf-8")
            self.assertIn("- planner: openai", report)
            # Runtime UX PRD: inline allowed_tools list dropped; report
            # now references agent_trace.jsonl in a footer line.
            self.assertIn("agent_trace.jsonl", report)

    def test_agent_openai_planner_rejects_unsupported_tools(self) -> None:
        with tempfile.TemporaryDirectory() as tmp:
            env = os.environ.copy()
            env["AISWMM_CONFIG_DIR"] = tmp
            env["AISWMM_OPENAI_MOCK_TOOL_CALLS"] = json.dumps([{"name": "shell", "arguments": {"cmd": "pwd"}}])
            proc = subprocess.run(
                [
                    sys.executable,
                    "-m",
                    "agentic_swmm.cli",
                    "agent",
                    "--planner",
                    "openai",
                    "--model",
                    "gpt-test",
                    "--session-dir",
                    str(Path(tmp) / "agent-session"),
                    "try shell",
                ],
                cwd=REPO_ROOT,
                env=env,
                capture_output=True,
                text=True,
            )

            self.assertNotEqual(proc.returncode, 0)
            self.assertIn("unsupported tool", proc.stderr)

    def test_agent_openai_planner_can_read_skill_contracts(self) -> None:
        with tempfile.TemporaryDirectory() as tmp:
            session_dir = Path(tmp) / "agent-session"
            env = os.environ.copy()
            env["AISWMM_CONFIG_DIR"] = tmp
            env["AISWMM_OPENAI_MOCK_TOOL_CALLS"] = json.dumps(
                [
                    {"name": "list_skills", "arguments": {}},
                    {"name": "read_skill", "arguments": {"skill_name": "swmm-runner"}},
                ]
            )
            env["AISWMM_OPENAI_MOCK_RESPONSE"] = "skill contracts inspected"
            proc = subprocess.run(
                [
                    sys.executable,
                    "-m",
                    "agentic_swmm.cli",
                    "agent",
                    "--planner",
                    "openai",
                    "--model",
                    "gpt-test",
                    "--session-dir",
                    str(session_dir),
                    "inspect runner skill",
                ],
                cwd=REPO_ROOT,
                env=env,
                capture_output=True,
                text=True,
                check=True,
            )

            self.assertIn("list_skills", proc.stdout)
            self.assertIn("read_skill", proc.stdout)
            self.assertIn("skill contracts inspected", proc.stdout)
            report = (session_dir / "final_report.md").read_text(encoding="utf-8")
            self.assertIn("read_skill", report)

    def test_agent_openai_planner_uses_workspace_and_runtime_tools(self) -> None:
        with tempfile.TemporaryDirectory() as tmp:
            session_dir = Path(tmp) / "agent-session"
            env = os.environ.copy()
            env["AISWMM_CONFIG_DIR"] = tmp
            env["AISWMM_OPENAI_MOCK_TOOL_CALLS"] = json.dumps(
                [
                    {"name": "capabilities", "arguments": {}},
                    {"name": "list_dir", "arguments": {"path": "agentic_swmm"}},
                    {"name": "search_files", "arguments": {"query": "Agentic SWMM", "glob": "README.md"}},
                    {"name": "list_mcp_servers", "arguments": {}},
                ]
            )
            env["AISWMM_OPENAI_MOCK_RESPONSE"] = "workspace tools inspected"
            proc = subprocess.run(
                [
                    sys.executable,
                    "-m",
                    "agentic_swmm.cli",
                    "agent",
                    "--planner",
                    "openai",
                    "--model",
                    "gpt-test",
                    "--session-dir",
                    str(session_dir),
                    "inspect runtime capabilities",
                ],
                cwd=REPO_ROOT,
                env=env,
                capture_output=True,
                text=True,
                check=True,
            )

            self.assertIn("capabilities", proc.stdout)
            self.assertIn("list_dir", proc.stdout)
            self.assertIn("search_files", proc.stdout)
            self.assertIn("list_mcp_servers", proc.stdout)
            self.assertIn("workspace tools inspected", proc.stdout)
            report = (session_dir / "final_report.md").read_text(encoding="utf-8")
            # Runtime UX PRD: inline allowed_tools list is gone; the
            # tool names below still appear in stdout (the executor
            # progress channel) but no longer in the final report.
            self.assertIn("agent_trace.jsonl", report)

    def test_search_files_normalizes_recursive_extension_glob(self) -> None:
        registry = AgentToolRegistry()
        with tempfile.TemporaryDirectory() as tmp:
            result = registry.execute(ToolCall("search_files", {"query": "[OPTIONS]", "glob": "**.inp"}), Path(tmp))

        self.assertTrue(result["ok"])
        self.assertEqual(result["glob"], "**/*.inp")
        self.assertGreaterEqual(len(result["results"]), 2)

    def test_agent_selects_workflow_mode_before_swmm_execution(self) -> None:
        with tempfile.TemporaryDirectory() as tmp:
            session_dir = Path(tmp) / "agent-session"
            env = os.environ.copy()
            env["AISWMM_CONFIG_DIR"] = tmp
            env["AISWMM_OPENAI_MOCK_TOOL_CALLS"] = json.dumps(
                [
                    {"name": "select_workflow_mode", "arguments": {"goal": "run external inp", "inp_path": "D:\\models\\case.inp"}},
                ]
            )
            env["AISWMM_OPENAI_MOCK_RESPONSE"] = "workflow selected"
            proc = subprocess.run(
                [
                    sys.executable,
                    "-m",
                    "agentic_swmm.cli",
                    "agent",
                    "--planner",
                    "openai",
                    "--model",
                    "gpt-test",
                    "--session-dir",
                    str(session_dir),
                    "run external inp",
                ],
                cwd=REPO_ROOT,
                env=env,
                capture_output=True,
                text=True,
                check=True,
            )

            self.assertIn("select_workflow_mode", proc.stdout)
            report = (session_dir / "final_report.md").read_text(encoding="utf-8")
            self.assertIn("select_workflow_mode", report)
            self.assertIn("workflow selected", report)

    def test_select_workflow_mode_reports_missing_inputs(self) -> None:
        with tempfile.TemporaryDirectory() as tmp:
            session_dir = Path(tmp) / "agent-session"
            env = os.environ.copy()
            env["AISWMM_CONFIG_DIR"] = tmp
            env["AISWMM_OPENAI_MOCK_TOOL_CALLS"] = json.dumps(
                [
                    {"name": "select_workflow_mode", "arguments": {"goal": "run a SWMM model"}},
                ]
            )
            env["AISWMM_OPENAI_MOCK_RESPONSE"] = "need user input"
            proc = subprocess.run(
                [
                    sys.executable,
                    "-m",
                    "agentic_swmm.cli",
                    "agent",
                    "--planner",
                    "openai",
                    "--model",
                    "gpt-test",
                    "--session-dir",
                    str(session_dir),
                    "run a SWMM model",
                ],
                cwd=REPO_ROOT,
                env=env,
                capture_output=True,
                text=True,
                check=True,
            )

            self.assertIn("mode=needs_user_inputs", proc.stdout)
            trace = (session_dir / "agent_trace.jsonl").read_text(encoding="utf-8")
            self.assertIn("SWMM INP path", trace)

    def test_auto_workflow_router_stops_missing_swmm_inputs(self) -> None:
        with tempfile.TemporaryDirectory() as tmp:
            session_dir = Path(tmp) / "agent-session"
            env = os.environ.copy()
            env["AISWMM_CONFIG_DIR"] = tmp
            env["AISWMM_FORCE_AUTO_WORKFLOW_ROUTER"] = "1"
            env["AISWMM_OPENAI_MOCK_RESPONSE"] = "should not be used"
            proc = subprocess.run(
                [
                    sys.executable,
                    "-m",
                    "agentic_swmm.cli",
                    "agent",
                    "--planner",
                    "openai",
                    "--model",
                    "gpt-test",
                    "--session-dir",
                    str(session_dir),
                    "run a SWMM model",
                ],
                cwd=REPO_ROOT,
                env=env,
                capture_output=True,
                text=True,
                check=True,
            )

            self.assertIn("select_workflow_mode", proc.stdout)
            self.assertIn("Please provide a SWMM INP path", proc.stdout)

    def test_capabilities_command_lists_new_agent_tools(self) -> None:
        proc = subprocess.run(
            [sys.executable, "-m", "agentic_swmm.cli", "capabilities", "--json"],
            cwd=REPO_ROOT,
            capture_output=True,
            text=True,
            check=True,
        )
        payload = json.loads(proc.stdout)
        self.assertEqual(payload["filesystem"]["arbitrary_shell"], False)
        self.assertTrue(payload["web"]["enabled"])
        self.assertTrue(payload["mcp"]["enabled"])
        self.assertIn("web_search", payload["tools"])
        self.assertIn("call_mcp_tool", payload["tools"])
        self.assertIn("select_workflow_mode", payload["tools"])
        self.assertIn("inspect_plot_options", payload["tools"])
        self.assertIn("apply_patch", payload["tools"])
        self.assertIn("run_tests", payload["tools"])
        self.assertIn("run_allowed_command", payload["tools"])

    def test_plot_help_exposes_node_attribute_selection(self) -> None:
        proc = subprocess.run(
            [sys.executable, "-m", "agentic_swmm.cli", "plot", "--help"],
            cwd=REPO_ROOT,
            capture_output=True,
            text=True,
            check=True,
        )

        self.assertIn("--node-attr", proc.stdout)
        self.assertIn("Volume_stored_ponded", proc.stdout)

    def test_agent_blocks_disallowed_shell_command(self) -> None:
        with tempfile.TemporaryDirectory() as tmp:
            session_dir = Path(tmp) / "agent-session"
            env = os.environ.copy()
            env["AISWMM_CONFIG_DIR"] = tmp
            env["AISWMM_OPENAI_MOCK_TOOL_CALLS"] = json.dumps(
                [{"name": "run_allowed_command", "arguments": {"command": ["cmd", "/c", "dir"]}}]
            )
            proc = subprocess.run(
                [
                    sys.executable,
                    "-m",
                    "agentic_swmm.cli",
                    "agent",
                    "--planner",
                    "openai",
                    "--model",
                    "gpt-test",
                    "--session-dir",
                    str(session_dir),
                    "try shell",
                ],
                cwd=REPO_ROOT,
                env=env,
                capture_output=True,
                text=True,
            )

            self.assertNotEqual(proc.returncode, 0)
            self.assertIn("command is not allowlisted", proc.stdout)

    def test_agent_can_run_scoped_pytest_tool(self) -> None:
        with tempfile.TemporaryDirectory() as tmp:
            session_dir = Path(tmp) / "agent-session"
            env = os.environ.copy()
            env["AISWMM_CONFIG_DIR"] = tmp
            env["AISWMM_OPENAI_MOCK_TOOL_CALLS"] = json.dumps(
                [{"name": "run_tests", "arguments": {"paths": ["tests/test_swmm_modeling_memory.py"], "timeout_seconds": 60}}]
            )
            env["AISWMM_OPENAI_MOCK_RESPONSE"] = "tests checked"
            proc = subprocess.run(
                [
                    sys.executable,
                    "-m",
                    "agentic_swmm.cli",
                    "agent",
                    "--planner",
                    "openai",
                    "--model",
                    "gpt-test",
                    "--session-dir",
                    str(session_dir),
                    "run focused tests",
                ],
                cwd=REPO_ROOT,
                env=env,
                capture_output=True,
                text=True,
                check=True,
            )

            self.assertIn("run_tests", proc.stdout)
            self.assertIn("OK:", proc.stdout)

    def test_openai_agent_writes_session_state_and_context_summary(self) -> None:
        with tempfile.TemporaryDirectory() as tmp:
            session_dir = Path(tmp) / "agent-session"
            env = os.environ.copy()
            env["AISWMM_CONFIG_DIR"] = tmp
            env["AISWMM_OPENAI_MOCK_RESPONSE"] = "state checked"
            proc = subprocess.run(
                [
                    sys.executable,
                    "-m",
                    "agentic_swmm.cli",
                    "agent",
                    "--planner",
                    "openai",
                    "--model",
                    "gpt-test",
                    "--session-dir",
                    str(session_dir),
                    "inspect project state",
                ],
                cwd=REPO_ROOT,
                env=env,
                capture_output=True,
                text=True,
                check=True,
            )

            self.assertIn("state checked", proc.stdout)
            state = json.loads((session_dir / "session_state.json").read_text(encoding="utf-8"))
            self.assertEqual(state["planner"], "openai")
            self.assertIn("retry_policy", state)
            self.assertIn("intent_contracts", state)
            self.assertIn("workflow_state", state)
            self.assertTrue((session_dir / "context_summary.md").exists())

    def test_mcp_tool_list_returns_mapped_schemas(self) -> None:
        registry = AgentToolRegistry()
        fake_tools = [
            {
                "name": "validate_network",
                "description": "Validate a network.",
                "inputSchema": {"type": "object", "properties": {"network_json": {"type": "string"}}, "required": ["network_json"]},
            }
        ]
        with tempfile.TemporaryDirectory() as tmp, patch.dict(os.environ, {"AISWMM_CONFIG_DIR": tmp}), patch("agentic_swmm.agent.mcp_client.list_tools", return_value=fake_tools) as mocked:
            result = registry.execute(ToolCall("list_mcp_tools", {"server": "swmm-network"}), Path(tmp))

        self.assertTrue(result["ok"])
        self.assertEqual(mocked.call_args.kwargs["timeout"], 5)
        self.assertEqual(result["mapped_tools"][0]["planner_tool"], "call_mcp_tool")
        self.assertEqual(result["mapped_tools"][0]["arguments"]["server"], "swmm-network")
        self.assertIn("network_json", result["mapped_tools"][0]["arguments"]["arguments_schema"]["properties"])

    def test_mcp_tool_list_uses_schema_cache(self) -> None:
        registry = AgentToolRegistry()
        fake_tools = [{"name": "cached_run", "description": "Cached run.", "inputSchema": {"type": "object", "properties": {}}}]
        with tempfile.TemporaryDirectory() as tmp, patch.dict(os.environ, {"AISWMM_CONFIG_DIR": tmp}), patch("agentic_swmm.agent.mcp_client.list_tools", return_value=fake_tools) as mocked:
            first = registry.execute(ToolCall("list_mcp_tools", {"server": "swmm-runner"}), Path(tmp))
            second = registry.execute(ToolCall("list_mcp_tools", {"server": "swmm-runner"}), Path(tmp))

        self.assertTrue(first["ok"])
        self.assertTrue(second["ok"])
        self.assertEqual(first["cache"], "miss")
        self.assertEqual(second["cache"], "hit")
        self.assertEqual(mocked.call_count, 1)

    def test_mcp_tool_list_refresh_bypasses_schema_cache(self) -> None:
        registry = AgentToolRegistry()
        fake_tools = [{"name": "cached_run", "description": "Cached run.", "inputSchema": {"type": "object", "properties": {}}}]
        with tempfile.TemporaryDirectory() as tmp, patch.dict(os.environ, {"AISWMM_CONFIG_DIR": tmp}), patch("agentic_swmm.agent.mcp_client.list_tools", return_value=fake_tools) as mocked:
            registry.execute(ToolCall("list_mcp_tools", {"server": "swmm-runner"}), Path(tmp))
            refreshed = registry.execute(ToolCall("list_mcp_tools", {"server": "swmm-runner", "refresh": True}), Path(tmp))

        self.assertTrue(refreshed["ok"])
        self.assertEqual(refreshed["cache"], "refresh")
        self.assertEqual(mocked.call_count, 2)

    def test_mcp_tool_list_honors_short_timeout(self) -> None:
        registry = AgentToolRegistry()
        with tempfile.TemporaryDirectory() as tmp, patch.dict(os.environ, {"AISWMM_CONFIG_DIR": tmp}), patch("agentic_swmm.agent.mcp_client.list_tools", side_effect=RuntimeError("MCP response timed out.")) as mocked:
            result = registry.execute(ToolCall("list_mcp_tools", {"server": "swmm-runner", "timeout_seconds": 3}), Path(tmp))

        self.assertFalse(result["ok"])
        self.assertEqual(mocked.call_args.kwargs["timeout"], 3)
        self.assertIn("fallback_tools", result)
        self.assertIn("run_swmm_inp", result["fallback_tools"])

    def test_mcp_call_failure_reports_recovery_and_fallback(self) -> None:
        registry = AgentToolRegistry()
        with tempfile.TemporaryDirectory() as tmp, patch("agentic_swmm.agent.mcp_client.call_tool", side_effect=RuntimeError("bad args")):
            result = registry.execute(ToolCall("call_mcp_tool", {"server": "swmm-runner", "tool": "run", "arguments": {}}), Path(tmp))

        self.assertFalse(result["ok"])
        self.assertIn("recovery", result)
        self.assertIn("run_swmm_inp", result["fallback_tools"])

    def test_workflow_router_suggests_nodes_from_inp(self) -> None:
        registry = AgentToolRegistry()
        inp = REPO_ROOT / "examples" / "tecnopolo" / "tecnopolo_r1_199401.inp"
        with tempfile.TemporaryDirectory() as tmp:
            result = registry.execute(ToolCall("select_workflow_mode", {"goal": "run inp", "inp_path": str(inp)}), Path(tmp))

        payload = result["results"]
        self.assertEqual(payload["mode"], "prepared_inp_cli")
        self.assertFalse(payload["missing_inputs"])
        self.assertTrue(payload["node_suggestions"])
        self.assertEqual(payload["node_suggestions"][0], "OU2")
        options = payload["plot_selection_options"]
        self.assertTrue(options["rainfall_options"])
        self.assertIn("Depth_above_invert", {item["name"] for item in options["node_attribute_options"]})
        self.assertIn("Volume_stored_ponded", {item["name"] for item in options["node_attribute_options"]})

    def test_agent_can_inspect_plot_options_before_plotting(self) -> None:
        registry = AgentToolRegistry()
        inp = REPO_ROOT / "examples" / "tecnopolo" / "tecnopolo_r1_199401.inp"
        with tempfile.TemporaryDirectory() as tmp:
            result = registry.execute(ToolCall("inspect_plot_options", {"inp_path": str(inp)}), Path(tmp))

        payload = result["results"]
        self.assertTrue(result["ok"])
        self.assertEqual(payload["defaults"]["node"], "OU2")
        self.assertTrue(payload["rainfall_options"])
        self.assertNotIn("rain_ts", payload["selections_needed"])
        self.assertIn("node", payload["selections_needed"])
        self.assertIn("node_attr", payload["selections_needed"])
        self.assertIn("Total_inflow", {item["name"] for item in payload["node_attribute_options"]})
        self.assertIn("Flow_lost_flooding", {item["name"] for item in payload["node_attribute_options"]})

    def test_agent_openai_planner_reports_missing_external_inp(self) -> None:
        with tempfile.TemporaryDirectory() as tmp:
            outside_inp = Path(tmp) / "outside.inp"
            env = os.environ.copy()
            env["AISWMM_CONFIG_DIR"] = tmp
            env["AISWMM_OPENAI_MOCK_TOOL_CALLS"] = json.dumps(
                [{"name": "run_swmm_inp", "arguments": {"inp_path": str(outside_inp)}}]
            )
            proc = subprocess.run(
                [
                    sys.executable,
                    "-m",
                    "agentic_swmm.cli",
                    "agent",
                    "--planner",
                    "openai",
                    "--model",
                    "gpt-test",
                    "--session-dir",
                    str(Path(tmp) / "agent-session"),
                    "run outside inp",
                ],
                cwd=REPO_ROOT,
                env=env,
                capture_output=True,
                text=True,
            )

            self.assertNotEqual(proc.returncode, 0)
            self.assertIn("external INP file not found", proc.stdout)

    def test_skill_and_mcp_lists_are_available(self) -> None:
        skill_proc = subprocess.run(
            [sys.executable, "-m", "agentic_swmm.cli", "skill", "list"],
            cwd=REPO_ROOT,
            capture_output=True,
            text=True,
            check=True,
        )
        self.assertIn("swmm-end-to-end", skill_proc.stdout)

        mcp_proc = subprocess.run(
            [sys.executable, "-m", "agentic_swmm.cli", "mcp", "list"],
            cwd=REPO_ROOT,
            capture_output=True,
            text=True,
            check=True,
        )
        self.assertIn("swmm-runner", mcp_proc.stdout)

    def test_setup_mounts_repo_resources_into_local_registry(self) -> None:
        with tempfile.TemporaryDirectory() as tmp:
            env = os.environ.copy()
            env["AISWMM_CONFIG_DIR"] = tmp
            proc = subprocess.run(
                [
                    sys.executable,
                    "-m",
                    "agentic_swmm.cli",
                    "setup",
                    "--provider",
                    "openai",
                    "--model",
                    "gpt-test",
                    "--json",
                ],
                cwd=REPO_ROOT,
                env=env,
                capture_output=True,
                text=True,
                check=True,
            )
            payload = json.loads(proc.stdout)

            self.assertIn(payload["status"], {"ready", "ready_with_warnings"})
            expected_skills = sum(
                1
                for entry in (REPO_ROOT / "skills").iterdir()
                if entry.is_dir() and (entry / "SKILL.md").is_file()
            )
            self.assertEqual(payload["resources"]["skills"], expected_skills)
            expected_mcp_servers = sum(
                1
                for entry in (REPO_ROOT / "mcp").iterdir()
                if entry.is_dir() and (entry / "server.js").is_file()
            )
            self.assertEqual(payload["resources"]["mcp_servers"], expected_mcp_servers)
            # #79 P1-1 expanded LONG_TERM_MEMORY_FILES from 3 to 7. Use
            # dynamic counts from the runtime registry so future expansions
            # don't drift the test.
            from agentic_swmm.runtime.registry import (
                LONG_TERM_MEMORY_FILES as _LONG_TERM_MEMORY_FILES,
                MODELING_MEMORY_FILES as _MODELING_MEMORY_FILES,
            )
            expected_long_term = len(_LONG_TERM_MEMORY_FILES)
            expected_project_modeling = len(_MODELING_MEMORY_FILES)
            self.assertEqual(
                payload["resources"]["memory_files"],
                expected_long_term + expected_project_modeling,
            )
            self.assertEqual(
                payload["resources"]["memory_layers"]["long_term"],
                expected_long_term,
            )
            self.assertEqual(
                payload["resources"]["memory_layers"]["project_modeling"],
                expected_project_modeling,
            )
            self.assertTrue((Path(tmp) / "config.toml").exists())
            self.assertTrue((Path(tmp) / "skills.json").exists())
            self.assertTrue((Path(tmp) / "mcp.json").exists())
            self.assertTrue((Path(tmp) / "memory.json").exists())
            self.assertTrue((Path(tmp) / "setup_state.json").exists())

    def test_audit_command_writes_artifacts_without_obsidian_by_default(self) -> None:
        with tempfile.TemporaryDirectory() as tmp:
            run_dir = Path(tmp) / "runs" / "case-a"
            runner_dir = run_dir / "05_runner"
            runner_dir.mkdir(parents=True)

            rpt = runner_dir / "model.rpt"
            out = runner_dir / "model.out"
            stdout = runner_dir / "stdout.txt"
            stderr = runner_dir / "stderr.txt"
            rpt.write_text(
                """
                ***** Node Inflow Summary *****
                ------------------------------------------------
                  O1              OUTFALL       0.001       1.250      2    12:47

                ***** Flow Routing Continuity *****
                Continuity Error (%) ............. 0.00
                """,
                encoding="utf-8",
            )
            out.write_text("binary-placeholder", encoding="utf-8")
            stdout.write_text("", encoding="utf-8")
            stderr.write_text("", encoding="utf-8")
            (runner_dir / "manifest.json").write_text(
                json.dumps(
                    {
                        "files": {
                            "rpt": str(rpt),
                            "out": str(out),
                            "stdout": str(stdout),
                            "stderr": str(stderr),
                        },
                        "metrics": {
                            "peak": {
                                "node": "O1",
                                "peak": 1.25,
                                "time_hhmm": "12:47",
                                "source": "Node Inflow Summary",
                            }
                        },
                        "return_code": 0,
                    }
                ),
                encoding="utf-8",
            )

            proc = subprocess.run(
                [sys.executable, "-m", "agentic_swmm.cli", "audit", "--run-dir", str(run_dir)],
                cwd=REPO_ROOT,
                capture_output=True,
                text=True,
                check=True,
            )
            payload = json.loads(proc.stdout)

            audit_dir = run_dir / "09_audit"
            self.assertTrue((audit_dir / "experiment_provenance.json").exists())
            self.assertTrue((audit_dir / "comparison.json").exists())
            self.assertTrue((audit_dir / "experiment_note.md").exists())
            self.assertTrue((run_dir / "command_trace.json").exists())
            self.assertIsNone(payload["obsidian_note"])

    def test_run_imports_external_inp_before_execution_and_audit(self) -> None:
        with tempfile.TemporaryDirectory() as tmp:
            tmp_path = Path(tmp)
            external_dir = tmp_path / "external models"
            external_dir.mkdir()
            external_inp = external_dir / "user case.inp"
            external_inp.write_text("[TITLE]\nExternal user model\n", encoding="utf-8")

            fake_bin = tmp_path / "bin"
            fake_bin.mkdir()
            if os.name == "nt":
                swmm5 = fake_bin / "swmm5.cmd"
                swmm5.write_text(
                    "\n".join(
                        [
                            "@echo off",
                            "if \"%1\"==\"--version\" (echo EPA SWMM 5.2.4 & exit /b 0)",
                            "(",
                            "echo ***** Node Inflow Summary *****",
                            "echo ------------------------------------------------",
                            "echo   O1              OUTFALL       0.001       1.250      2    12:47",
                            "echo.",
                            "echo ***** Flow Routing Continuity *****",
                            "echo Continuity Error (%%) ............. 0.00",
                            ") > \"%2\"",
                            "echo binary-placeholder > \"%3\"",
                            "exit /b 0",
                        ]
                    ),
                    encoding="utf-8",
                )
            else:
                swmm5 = fake_bin / "swmm5"
                swmm5.write_text(
                    "\n".join(
                        [
                            "#!/usr/bin/env sh",
                            "if [ \"$1\" = \"--version\" ]; then echo 'EPA SWMM 5.2.4'; exit 0; fi",
                            "{",
                            "echo '***** Node Inflow Summary *****'",
                            "echo '------------------------------------------------'",
                            "echo '  O1              OUTFALL       0.001       1.250      2    12:47'",
                            "echo",
                            "echo '***** Flow Routing Continuity *****'",
                            "echo 'Continuity Error (%) ............. 0.00'",
                            "} > \"$2\"",
                            "echo 'binary-placeholder' > \"$3\"",
                            "exit 0",
                        ]
                    ),
                    encoding="utf-8",
                )
                swmm5.chmod(0o755)

            run_dir = tmp_path / "runs" / "external-case"
            env = os.environ.copy()
            env["PATH"] = str(fake_bin) + os.pathsep + env["PATH"]
            subprocess.run(
                [
                    sys.executable,
                    "-m",
                    "agentic_swmm.cli",
                    "run",
                    "--inp",
                    str(external_inp),
                    "--run-dir",
                    str(run_dir),
                    "--node",
                    "O1",
                ],
                cwd=REPO_ROOT,
                env=env,
                capture_output=True,
                text=True,
                check=True,
            )

            imported_inp = run_dir / "00_inputs" / "model.inp"
            self.assertTrue(imported_inp.exists())
            manifest = json.loads((run_dir / "manifest.json").read_text(encoding="utf-8"))
            self.assertEqual(manifest["pipeline"], "external_inp_import")
            self.assertEqual(manifest["inputs"]["source_inp"]["path"], str(external_inp.resolve()))
            self.assertEqual(manifest["inputs"]["source_inp"]["sha256"], manifest["inputs"]["run_inp"]["sha256"])
            self.assertEqual(manifest["inputs"]["run_inp"]["path"], str(imported_inp.resolve()))

            subprocess.run(
                [
                    sys.executable,
                    "-m",
                    "agentic_swmm.cli",
                    "audit",
                    "--run-dir",
                    str(run_dir),
                ],
                cwd=REPO_ROOT,
                env=env,
                capture_output=True,
                text=True,
                check=True,
            )
            audit_dir = run_dir / "09_audit"
            provenance = json.loads((audit_dir / "experiment_provenance.json").read_text(encoding="utf-8"))
            note = (audit_dir / "experiment_note.md").read_text(encoding="utf-8")
            self.assertEqual(provenance["workflow_mode"], "external_inp_import")
            self.assertIn("Workflow mode | external_inp_import", note)
            self.assertIn("External INP imports are copied into the run directory before execution", note)


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