from __future__ import annotations

import argparse
import re
import shutil
import subprocess
import sys
from pathlib import Path

from mcp_template_cli import __version__

ROOT = Path(__file__).resolve().parents[2]
PACKAGE_ROOT = Path(__file__).resolve().parent
BUNDLED_DATA_DIR = PACKAGE_ROOT / "data"
IGNORED_NAMES = {"__pycache__", ".DS_Store"}
SERVER_FILENAME = "mcp_server.py"
SUPPORT_DIRNAME = "support"
HELP_INSPECTION_TIMEOUT_SEC = 5.0


def _resolve_template_dir() -> Path:
    source_template_dir = ROOT / "python-cli-template"
    if source_template_dir.exists():
        return source_template_dir
    return BUNDLED_DATA_DIR / "python-cli-template"


def _resolve_examples_dir() -> Path:
    source_examples_dir = ROOT / "examples"
    if source_examples_dir.exists():
        return source_examples_dir
    return BUNDLED_DATA_DIR / "examples"


TEMPLATE_DIR = _resolve_template_dir()
EXAMPLES_DIR = _resolve_examples_dir()


def _server_slug(value: str) -> str:
    slug = re.sub(r"[^a-z0-9]+", "-", value.strip().lower()).strip("-")
    if not slug:
        raise ValueError("server name must include at least one letter or number")
    return slug


def _tool_slug(value: str) -> str:
    slug = re.sub(r"[^a-z0-9]+", "_", value.strip().lower()).strip("_")
    if not slug:
        raise ValueError("tool name must include at least one letter or number")
    if slug[0].isdigit():
        raise ValueError("tool name must start with a letter")
    return slug


def _subcommand_name(value: str) -> str:
    name = re.sub(r"[^a-z0-9-]+", "-", value.strip().lower()).strip("-")
    if not name:
        raise ValueError("tool subcommand must include at least one letter or number")
    return name


def _parse_tool_subcommand_overrides(values: list[str] | None) -> dict[str, str]:
    overrides: dict[str, str] = {}
    for raw_value in values or []:
        if "=" not in raw_value:
            raise ValueError("tool subcommand overrides must use TOOL=SUBCOMMAND")
        tool_name_raw, subcommand_raw = raw_value.split("=", 1)
        tool_name = _tool_slug(tool_name_raw)
        subcommand = _subcommand_name(subcommand_raw)
        overrides[tool_name] = subcommand
    return overrides


def _resolve_generator_binary_path(bin_path: str | None, binary_name: str) -> str:
    if bin_path:
        return str(Path(bin_path).expanduser().resolve())
    located = shutil.which(binary_name)
    if not located:
        raise ValueError(
            f"could not locate binary for --infer-tools-from-help: {binary_name}. "
            "Pass --bin-path with the executable to inspect."
        )
    return str(Path(located).expanduser().resolve())


def _python_identifier(name: str) -> str:
    return _tool_slug(name)


def _parse_default_value(raw: str) -> str | int:
    value = raw.strip()
    if re.fullmatch(r"-?\d+", value):
        return int(value)
    return value


def _option_path_role(name: str) -> str | None:
    if name.endswith("format") or name.endswith("_format"):
        return None
    if name in {"out", "output", "output_path", "out_path"}:
        return "write"
    if any(token in name for token in ("out_", "_out", "output_")):
        return "write"
    if any(token in name for token in ("file", "json", "csv", "input", "config", "base", "new", "analysis")):
        return "read"
    return None


def _parse_help_spec(help_text: str) -> tuple[set[str], list[dict[str, object]]]:
    required_flags = {
        match.group("flag")
        for match in re.finditer(r"--(?P<flag>[a-z0-9-]+)\s+<[^>]+>", help_text.splitlines()[0])
    }
    options: list[dict[str, object]] = []
    current: dict[str, object] | None = None
    current_meta: list[str] = []
    for raw_line in help_text.splitlines():
        line = raw_line.rstrip()
        match = re.match(r"^\s+--(?P<flag>[a-z0-9-]+)(?:\s+<(?P<metavar>[^>]+)>)?", line)
        if match:
            if current is not None:
                current["meta"] = " ".join(current_meta)
                options.append(current)
            current = {
                "flag": match.group("flag"),
                "metavar": match.group("metavar"),
            }
            remainder = line[match.end() :].strip()
            current_meta = [remainder] if remainder else []
            continue
        if current is not None:
            stripped = line.strip()
            if stripped:
                current_meta.append(stripped)
    if current is not None:
        current["meta"] = " ".join(current_meta)
        options.append(current)

    parsed_options: list[dict[str, object]] = []
    for option in options:
        meta = str(option.get("meta", ""))
        default_match = re.search(r"\[default:\s*([^\]]+)\]", meta)
        possible_match = re.search(r"\[possible values:\s*([^\]]+)\]", meta)
        parsed_options.append(
            {
                "flag": option["flag"],
                "param": _python_identifier(str(option["flag"])),
                "takes_value": option["metavar"] is not None,
                "required": option["flag"] in required_flags,
                "default": _parse_default_value(default_match.group(1)) if default_match else None,
                "choices": [value.strip() for value in possible_match.group(1).split(",")] if possible_match else [],
                "path_role": _option_path_role(_python_identifier(str(option["flag"]))),
            }
        )
    return required_flags, parsed_options


def _param_signature(option: dict[str, object]) -> str:
    param = str(option["param"])
    if not option["takes_value"]:
        return f"    {param}: bool = False,"
    default = option["default"]
    if default is not None:
        if isinstance(default, int):
            return f"    {param}: int = {default},"
        return f'    {param}: str = "{default}",'
    if option["required"]:
        return f"    {param}: str,"
    return f"    {param}: str | None = None,"


def _choice_validation_block(option: dict[str, object]) -> list[str]:
    choices = list(option["choices"])
    if not choices:
        return []
    param = str(option["param"])
    rendered = ", ".join(repr(choice) for choice in choices)
    if option["takes_value"] and option["required"] and option["default"] is None:
        return [
            f"    if {param} not in {{{rendered}}}:",
            f'        raise ValueError("{param} must be one of: {", ".join(choices)}")',
            "",
        ]
    return [
        f"    if {param} is not None and {param} not in {{{rendered}}}:",
        f'        raise ValueError("{param} must be one of: {", ".join(choices)}")',
        "",
    ]


def _numeric_validation_block(option: dict[str, object]) -> list[str]:
    param = str(option["param"])
    default = option["default"]
    if isinstance(default, int) and default >= 1:
        return [
            f"    if {param} < 1:",
            f'        raise ValueError("{param} must be >= 1")',
            "",
        ]
    return []


def _value_expr(param: str, path_role: str | None) -> str:
    if path_role == "read":
        return f"str({param}_path)"
    if path_role == "write":
        return f"str({param}_path)"
    return param


def _command_value_expr(option: dict[str, object]) -> str:
    param = str(option["param"])
    value_expr = _value_expr(param, option["path_role"])
    default = option["default"]
    if isinstance(default, int):
        return f"str({value_expr})"
    return value_expr


def _path_setup_block(option: dict[str, object]) -> list[str]:
    param = str(option["param"])
    path_role = option["path_role"]
    if path_role == "read":
        if option["required"] and option["default"] is None:
            return [f'    {param}_path = _validate_read_path({param}, "{param}")', ""]
        return [f"    {param}_path = None", f"    if {param} is not None:", f'        {param}_path = _validate_read_path({param}, "{param}")', ""]
    if path_role == "write":
        if option["required"] and option["default"] is None:
            return [f'    {param}_path = _validate_write_path({param}, "{param}")', ""]
        return [f"    {param}_path = None", f"    if {param} is not None:", f'        {param}_path = _validate_write_path({param}, "{param}")', ""]
    return []


def _append_command_block(option: dict[str, object]) -> list[str]:
    flag = str(option["flag"])
    param = str(option["param"])
    if not option["takes_value"]:
        return [f"    if {param}:", f'        cmd.append("--{flag}")']
    value_expr = _command_value_expr(option)
    if option["required"] and option["default"] is None:
        return [f'    cmd.extend(["--{flag}", {value_expr}])']
    return [f"    if {param} is not None:", f'        cmd.extend(["--{flag}", {value_expr}])']


def _tool_function_block_from_help(tool_name: str, command_name: str, help_text: str) -> str:
    _required_flags, options = _parse_help_spec(help_text)
    ordered_options = sorted(
        options,
        key=lambda option: (
            not bool(option["required"] and option["default"] is None),
            bool(option["default"] is not None or not option["required"]),
            str(option["param"]),
        ),
    )
    lines = ["@mcp.tool()", f"def {tool_name}("]
    for option in ordered_options:
        lines.append(_param_signature(option))
    lines.append("    timeout_sec: int | None = None,")
    lines.append(") -> str:")
    for option in ordered_options:
        lines.extend(_choice_validation_block(option))
        lines.extend(_numeric_validation_block(option))
    for option in ordered_options:
        lines.extend(_path_setup_block(option))
    lines.append(f'    cmd = ["{command_name}"]')
    for option in ordered_options:
        lines.extend(_append_command_block(option))
    lines.append("    return json.dumps(_run(cmd, timeout_sec), ensure_ascii=True)")
    lines.append("")
    return "\n".join(lines)


def _tool_help_text(binary_path: str, command_name: str, timeout_sec: float = HELP_INSPECTION_TIMEOUT_SEC) -> str:
    try:
        proc = subprocess.run(
            [binary_path, command_name, "--help"],
            capture_output=True,
            text=True,
            timeout=timeout_sec,
            check=False,
        )
    except subprocess.TimeoutExpired as exc:
        raise ValueError(
            f"timed out reading help for subcommand {command_name} after {timeout_sec:g}s: "
            f"{exc.cmd[0]}"
        ) from exc
    if proc.returncode != 0:
        raise ValueError(f"failed to read help for subcommand {command_name}: {proc.stderr.strip() or proc.stdout.strip()}")
    return proc.stdout


def _tool_function_block(tool_name: str, command_name: str) -> str:
    return "\n".join(
        [
            "@mcp.tool()",
            f"def {tool_name}(",
            "    input_path: str,",
            "    out_path: str,",
            '    output_format: str = "text",',
            "    timeout_sec: int | None = None,",
            ") -> str:",
            "    if output_format not in VALID_OUTPUT_FORMATS:",
            '        raise ValueError(f"output_format must be one of {sorted(VALID_OUTPUT_FORMATS)}")',
            "",
            '    in_path = _validate_read_path(input_path, "input_path")',
            '    out = _validate_write_path(out_path, "out_path")',
            "",
            "    # Replace this command mapping with your own project command shape.",
            f'    cmd = ["{command_name}", "--input", str(in_path), "--out", str(out), "--format", output_format]',
            "    return json.dumps(_run(cmd, timeout_sec), ensure_ascii=True)",
            "",
        ]
    )


def _tool_blocks(
    tool_names: list[str],
    tool_subcommands: dict[str, str],
    infer_tools_from_help: bool,
    binary_path: str,
) -> str:
    return "\n".join(
        (
            _tool_function_block_from_help(
                tool_name,
                tool_subcommands.get(tool_name, tool_name.replace("_", "-")),
                _tool_help_text(binary_path, tool_subcommands.get(tool_name, tool_name.replace("_", "-"))),
            )
            if infer_tools_from_help
            else _tool_function_block(tool_name, tool_subcommands.get(tool_name, tool_name.replace("_", "-")))
        )
        for tool_name in tool_names
    ).rstrip()


def _env_prefix(value: str) -> str:
    prefix = re.sub(r"[^A-Z0-9]+", "_", value.upper()).strip("_")
    if not prefix:
        raise ValueError("env prefix must include at least one letter or number")
    if prefix[0].isdigit():
        raise ValueError("env prefix must start with a letter")
    return prefix


def _copy_template(dest: Path) -> None:
    def ignore(_dir: str, names: list[str]) -> set[str]:
        ignored = {name for name in names if name in IGNORED_NAMES}
        ignored.update(name for name in names if name.endswith((".pyc", ".pyo")))
        return ignored

    shutil.copytree(TEMPLATE_DIR, dest, ignore=ignore)


def _replace_in_file(path: Path, replacements: list[tuple[str, str]]) -> None:
    content = path.read_text(encoding="utf-8")
    for old, new in replacements:
        content = content.replace(old, new)
    path.write_text(content, encoding="utf-8")


def _support_dir(dest: Path) -> Path:
    return dest / SUPPORT_DIRNAME


def _server_path(dest: Path) -> Path:
    return dest / SERVER_FILENAME


def _doctor_path(dest: Path) -> Path:
    return _support_dir(dest) / "doctor.py"


def _dockerfile_path(dest: Path) -> Path:
    return _support_dir(dest) / "Dockerfile"


def _makefile_path(dest: Path) -> Path:
    return _support_dir(dest) / "Makefile"


def _requirements_path(dest: Path) -> Path:
    return _support_dir(dest) / "requirements.txt"


def _env_example_path(dest: Path) -> Path:
    return dest / ".env.example"


def _sample_input_path(dest: Path) -> Path:
    return _support_dir(dest) / "sample-input.txt"


def _sample_output_path(dest: Path) -> Path:
    return _support_dir(dest) / "sample-output" / "README.txt"


def _bin_dir(dest: Path) -> Path:
    return _support_dir(dest) / "bin"


def _deploy_dir(dest: Path) -> Path:
    return dest / "deploy"


def _docker_compose_path(dest: Path) -> Path:
    return _deploy_dir(dest) / "docker-compose.yml"


def _docker_env_example_path(dest: Path) -> Path:
    return _deploy_dir(dest) / "docker.env.example"


def _deploy_readme_path(dest: Path) -> Path:
    return _deploy_dir(dest) / "README.md"


def _runtime_dir(dest: Path) -> Path:
    return _deploy_dir(dest) / "runtime"


def _restructure_generated_layout(dest: Path) -> None:
    support_dir = _support_dir(dest)
    support_dir.mkdir(parents=True, exist_ok=True)
    (dest / "server.py").rename(_server_path(dest))
    (dest / "doctor.py").rename(_doctor_path(dest))
    (dest / "Dockerfile").rename(_dockerfile_path(dest))
    dockerignore = dest / ".dockerignore"
    if dockerignore.exists():
        dockerignore.rename(support_dir / ".dockerignore")
    (dest / "bin").rename(_bin_dir(dest))


def _update_support_dockerfile(dest: Path, binary_name: str) -> None:
    _replace_in_file(
        _dockerfile_path(dest),
        [
            ("COPY server.py /app/server.py", f"COPY {SERVER_FILENAME} /app/{SERVER_FILENAME}"),
            ("COPY doctor.py /app/doctor.py", f"COPY {SUPPORT_DIRNAME}/doctor.py /app/doctor.py"),
            (f"COPY bin/{binary_name} /usr/local/bin/{binary_name}", f"COPY {SUPPORT_DIRNAME}/bin/{binary_name} /usr/local/bin/{binary_name}"),
            ('CMD ["python", "/app/server.py"]', f'CMD ["python", "/app/{SERVER_FILENAME}"]'),
        ],
    )


def _write_sample_configs(
    dest: Path,
    replacements: list[tuple[str, str]],
    bin_path: str,
    python_path: str,
    read_dirs: str,
    write_dirs: str,
) -> None:
    sample_dir = dest / "examples"
    sample_dir.mkdir(parents=True, exist_ok=True)
    config_replacements = list(replacements)
    config_replacements.extend(
        [
            ("/ABS/PATH/TO/.venv/bin/python", python_path),
            ("/ABS/PATH/TO/server.py", str(_server_path(dest).resolve())),
            ("/ABS/PATH/TO/your_binary", bin_path),
            ("/ABS/PATH/TO/data,/ABS/PATH/TO/profiles,/ABS/PATH/TO/artifacts", read_dirs),
            ("/ABS/PATH/TO/artifacts", write_dirs),
        ]
    )
    for name in ("claude_desktop_config.sample.json", "claude_code_mcp.sample.json"):
        source = EXAMPLES_DIR / name
        target = sample_dir / name
        shutil.copy2(source, target)
        _replace_in_file(target, config_replacements)


def _write_env_example(dest: Path, env_prefix: str, bin_path: str, read_dirs: str, write_dirs: str) -> None:
    env_example = _env_example_path(dest)
    env_example.write_text(
        "\n".join(
            [
                f"{env_prefix}_BIN={bin_path}",
                f"{env_prefix}_ALLOWED_READ_DIRS={read_dirs}",
                f"{env_prefix}_ALLOWED_WRITE_DIRS={write_dirs}",
                f"{env_prefix}_CMD_TIMEOUT_SEC=180",
                "",
            ]
        ),
        encoding="utf-8",
    )


def _write_gitignore(dest: Path) -> None:
    gitignore = dest / ".gitignore"
    gitignore.write_text(
        "\n".join(
            [
                "__pycache__/",
                "*.py[cod]",
                ".venv/",
                ".env",
                "",
            ]
        ),
        encoding="utf-8",
    )


def _write_requirements(dest: Path) -> None:
    requirements = _requirements_path(dest)
    requirements.parent.mkdir(parents=True, exist_ok=True)
    requirements.write_text("mcp\n", encoding="utf-8")


def _write_sample_files(dest: Path) -> None:
    sample_input = _sample_input_path(dest)
    sample_input.parent.mkdir(parents=True, exist_ok=True)
    sample_input.write_text("Replace this file with a real input for your CLI.\n", encoding="utf-8")
    sample_output_dir = _sample_output_path(dest).parent
    sample_output_dir.mkdir(exist_ok=True)
    readme = _sample_output_path(dest)
    readme.write_text("Use this directory for local wrapper output during setup.\n", encoding="utf-8")


def _write_makefile(dest: Path) -> None:
    makefile = _makefile_path(dest)
    makefile.parent.mkdir(parents=True, exist_ok=True)
    makefile.write_text(
        "\n".join(
            [
                "PYTHON ?= python3",
                "",
                ".PHONY: install doctor run",
                "",
                "install:",
                f"\t$(PYTHON) -m pip install -r {SUPPORT_DIRNAME}/requirements.txt",
                "",
                "doctor:",
                f"\t$(PYTHON) {SUPPORT_DIRNAME}/doctor.py --server {SERVER_FILENAME}",
                "",
                "run:",
                f"\t$(PYTHON) {SERVER_FILENAME}",
                "",
            ]
        ),
        encoding="utf-8",
    )


def _write_start_here(
    dest: Path,
    server_name: str,
    tool_names: list[str],
    tool_subcommands: dict[str, str],
    env_prefix: str,
) -> None:
    guide = dest / "START_HERE.md"
    tool_list = ", ".join(f"`{tool_name}`" for tool_name in tool_names)
    subcommand_lines = [
        f"- `{tool_name}` -> `{tool_subcommands.get(tool_name, tool_name.replace('_', '-'))}`"
        for tool_name in tool_names
    ]
    guide.write_text(
        "\n".join(
            [
                f"# {server_name} MCP Wrapper",
                "",
                "## Start Here",
                "",
                f"1. Open `{dest / SERVER_FILENAME}`. This is the main MCP server file.",
                "2. Replace the placeholder command mapping with your real CLI arguments.",
                "3. Fill in `.env.example` with your real paths and copy those values into your runtime environment.",
                "4. Adapt one of the sample client configs in `examples/`.",
                f"5. Run `python { _doctor_path(dest) } --server { _server_path(dest) }`.",
                f"6. Call {tool_list} only after `version` and `healthcheck` both succeed.",
                "",
                "## Core Files",
                "",
                f"- `{SERVER_FILENAME}`: the MCP wrapper entrypoint you will edit",
                "- `.env.example`: runtime env template",
                "- `examples/`: sample client configs",
                f"- `{SUPPORT_DIRNAME}/`: secondary tooling like doctor, packaging, and sample files",
                "",
                "## Generated Tool Stubs",
                "",
                *subcommand_lines,
                "",
                "## Runtime Variables",
                "",
                f"- `{env_prefix}_BIN`",
                f"- `{env_prefix}_ALLOWED_READ_DIRS`",
                f"- `{env_prefix}_ALLOWED_WRITE_DIRS`",
                f"- `{env_prefix}_CMD_TIMEOUT_SEC`",
                "",
            ]
        ),
        encoding="utf-8",
    )


def _print_dry_run(
    dest: Path,
    server_name: str,
    tool_names: str,
    binary_name: str,
    env_prefix: str,
    bin_path: str,
    python_path: str,
    read_dirs: str,
    write_dirs: str,
) -> None:
    print("Dry run only. No files were written.")
    print(f"destination: {dest}")
    print(f"server name: {server_name}")
    print(f"tool names: {tool_names}")
    print(f"binary name: {binary_name}")
    print(f"env prefix: {env_prefix}")
    print(f"bin path: {bin_path}")
    print(f"python path: {python_path}")
    print(f"read dirs: {read_dirs}")
    print(f"write dirs: {write_dirs}")
    print("Files that would be created:")
    for path in (
        _server_path(dest),
        _doctor_path(dest),
        _dockerfile_path(dest),
        _env_example_path(dest),
        dest / ".gitignore",
        _makefile_path(dest),
        _requirements_path(dest),
        _sample_input_path(dest),
        _sample_output_path(dest),
        dest / "START_HERE.md",
        dest / "examples" / "claude_desktop_config.sample.json",
        dest / "examples" / "claude_code_mcp.sample.json",
        _bin_dir(dest) / binary_name,
    ):
        print(f"- {path}")


def generate_template(args: argparse.Namespace) -> int:
    server_name = _server_slug(args.server_name)
    tool_names = [_tool_slug(tool_name) for tool_name in args.tool_name]
    tool_subcommands = _parse_tool_subcommand_overrides(args.tool_subcommand)
    binary_name = _server_slug(args.binary_name or server_name)
    env_prefix = _env_prefix(args.env_prefix or server_name)
    bin_path = str(Path(args.bin_path).expanduser().resolve()) if args.bin_path else f"/ABS/PATH/TO/{binary_name}"
    generator_binary_path = _resolve_generator_binary_path(args.bin_path, binary_name) if args.infer_tools_from_help else ""
    python_path = str(Path(args.python_path).expanduser().resolve()) if args.python_path else sys.executable
    read_dirs = args.read_dirs or "/ABS/PATH/TO/data,/ABS/PATH/TO/profiles,/ABS/PATH/TO/artifacts"
    write_dirs = args.write_dirs or "/ABS/PATH/TO/artifacts"
    dest = args.dest.expanduser().resolve()

    if dest.exists():
        raise SystemExit(f"destination already exists: {dest}")

    if args.dry_run:
        _print_dry_run(
            dest,
            server_name,
            ", ".join(tool_names),
            binary_name,
            env_prefix,
            bin_path,
            python_path,
            read_dirs,
            write_dirs,
        )
        return 0

    _copy_template(dest)
    _restructure_generated_layout(dest)

    replacements = [
        ("project-template", server_name),
        ("PROJECT_ALLOWED_READ_DIRS", f"{env_prefix}_ALLOWED_READ_DIRS"),
        ("PROJECT_ALLOWED_WRITE_DIRS", f"{env_prefix}_ALLOWED_WRITE_DIRS"),
        ("PROJECT_CMD_TIMEOUT_SEC", f"{env_prefix}_CMD_TIMEOUT_SEC"),
        ("PROJECT_BIN", f"{env_prefix}_BIN"),
        ("project-cli", binary_name),
    ]

    _replace_in_file(_server_path(dest), replacements)
    _replace_in_file(_doctor_path(dest), replacements)
    _replace_in_file(_dockerfile_path(dest), replacements)
    _replace_in_file(
        _server_path(dest),
        [
            (
                "\n@mcp.tool()\ndef run_example(\n    input_path: str,\n    out_path: str,\n    output_format: str = \"text\",\n    timeout_sec: int | None = None,\n) -> str:\n    if output_format not in VALID_OUTPUT_FORMATS:\n        raise ValueError(f\"output_format must be one of {sorted(VALID_OUTPUT_FORMATS)}\")\n\n    in_path = _validate_read_path(input_path, \"input_path\")\n    out = _validate_write_path(out_path, \"out_path\")\n\n    # Replace this command mapping with your own project command shape.\n    cmd = [\"run\", \"--input\", str(in_path), \"--out\", str(out), \"--format\", output_format]\n    return json.dumps(_run(cmd, timeout_sec), ensure_ascii=True)\n",
                "\n" + _tool_blocks(tool_names, tool_subcommands, args.infer_tools_from_help, generator_binary_path) + "\n",
            )
        ],
    )
    _update_support_dockerfile(dest, binary_name)
    _write_gitignore(dest)
    _write_makefile(dest)
    _write_requirements(dest)
    _write_sample_files(dest)
    _write_sample_configs(dest, replacements, bin_path, python_path, read_dirs, write_dirs)
    _write_env_example(dest, env_prefix, bin_path, read_dirs, write_dirs)
    _write_start_here(dest, server_name, tool_names, tool_subcommands, env_prefix)

    source_stub = _bin_dir(dest) / "project-cli"
    target_stub = _bin_dir(dest) / binary_name
    source_stub.rename(target_stub)

    print(f"Created template at {dest}")
    print(f"server name: {server_name}")
    print(f"tool names: {', '.join(tool_names)}")
    print(f"binary name: {binary_name}")
    print(f"env prefix: {env_prefix}")
    print(f"bin path: {bin_path}")
    print(f"python path: {python_path}")
    print(f"read dirs: {read_dirs}")
    print(f"write dirs: {write_dirs}")
    print(f"Next: update the command mapping in {SERVER_FILENAME}, check START_HERE.md,")
    print("adapt the sample client config in examples/, fill in .env.example, and use support/ for secondary tooling.")
    return 0


def list_templates(_args: argparse.Namespace) -> int:
    print("python-cli")
    print(f"template dir: {TEMPLATE_DIR}")
    print("description: Production-style Python MCP wrapper for existing CLIs.")
    return 0


def _parse_env_file(path: Path) -> dict[str, str]:
    values: dict[str, str] = {}
    for raw_line in path.read_text(encoding="utf-8").splitlines():
        line = raw_line.strip()
        if not line or line.startswith("#") or "=" not in line:
            continue
        key, value = line.split("=", 1)
        values[key.strip()] = value.strip()
    return values


def _wrapper_binary_name(wrapper_dir: Path, env_prefix: str, env_values: dict[str, str], override: str | None = None) -> str:
    bin_dir = _bin_dir(wrapper_dir)
    if not bin_dir.exists():
        raise ValueError(f"wrapper is missing support/bin: {bin_dir}")
    binaries = sorted(path.name for path in bin_dir.iterdir() if path.is_file() and not path.name.startswith("."))
    if not binaries:
        raise ValueError(f"wrapper does not contain a binary stub in {bin_dir}")
    if override:
        binary_name = _server_slug(override)
        if binary_name not in binaries:
            raise ValueError(f"requested binary '{binary_name}' was not found in {bin_dir}")
        return binary_name

    configured_bin = env_values.get(f"{env_prefix}_BIN", "").strip()
    configured_name = Path(configured_bin).name if configured_bin else ""
    if configured_name:
        if configured_name in binaries:
            return configured_name
        if len(binaries) == 1:
            return configured_name

    if len(binaries) == 1:
        return binaries[0]

    raise ValueError(
        f"could not infer which binary to package from {bin_dir}. "
        "Pass --binary-name to select one explicitly."
    )


def _wrapper_env_prefix(wrapper_dir: Path) -> str:
    env_path = _env_example_path(wrapper_dir)
    if not env_path.exists():
        raise ValueError(f"wrapper is missing .env.example: {env_path}")
    values = _parse_env_file(env_path)
    for key in values:
        if key.endswith("_BIN"):
            return key[: -len("_BIN")]
    raise ValueError(f"could not infer env prefix from {env_path}")


def _docker_package_metadata(wrapper_dir: Path, binary_override: str | None = None) -> dict[str, str]:
    server_path = _server_path(wrapper_dir)
    dockerfile_path = _dockerfile_path(wrapper_dir)
    if not server_path.exists():
        raise ValueError(f"wrapper is missing {SERVER_FILENAME}: {server_path}")
    if not dockerfile_path.exists():
        raise ValueError(f"wrapper is missing support/Dockerfile: {dockerfile_path}")

    env_prefix = _wrapper_env_prefix(wrapper_dir)
    env_values = _parse_env_file(_env_example_path(wrapper_dir))
    binary_name = _wrapper_binary_name(wrapper_dir, env_prefix, env_values, binary_override)
    wrapper_slug = _server_slug(wrapper_dir.name)

    return {
        "env_prefix": env_prefix,
        "binary_name": binary_name,
        "timeout_sec": env_values.get(f"{env_prefix}_CMD_TIMEOUT_SEC", "180"),
        "service_name": wrapper_slug,
        "image_name": f"{wrapper_slug}:latest",
    }


def _write_docker_runtime_layout(wrapper_dir: Path) -> None:
    runtime_dir = _runtime_dir(wrapper_dir)
    for dirname, description in (
        ("data", "Read-only input data for the containerized wrapper.\n"),
        ("profiles", "Read-only profiles or config fragments for the containerized wrapper.\n"),
        ("artifacts", "Writable output directory for the containerized wrapper.\n"),
    ):
        target_dir = runtime_dir / dirname
        target_dir.mkdir(parents=True, exist_ok=True)
        (target_dir / "README.txt").write_text(description, encoding="utf-8")


def _write_docker_compose(wrapper_dir: Path, service_name: str, image_name: str) -> None:
    compose_path = _docker_compose_path(wrapper_dir)
    compose_path.parent.mkdir(parents=True, exist_ok=True)
    compose_path.write_text(
        "\n".join(
            [
                "services:",
                f"  {service_name}:",
                f"    image: {image_name}",
                "    build:",
                "      context: ..",
                "      dockerfile: support/Dockerfile",
                "    env_file:",
                "      - docker.env",
                "    volumes:",
                "      - ./runtime/data:/workspace/data:ro",
                "      - ./runtime/profiles:/workspace/profiles:ro",
                "      - ./runtime/artifacts:/workspace/artifacts",
                "    stdin_open: true",
                "    tty: true",
                "",
            ]
        ),
        encoding="utf-8",
    )


def _write_docker_env_example(wrapper_dir: Path, env_prefix: str, binary_name: str, timeout_sec: str) -> None:
    env_path = _docker_env_example_path(wrapper_dir)
    env_path.parent.mkdir(parents=True, exist_ok=True)
    env_path.write_text(
        "\n".join(
            [
                f"{env_prefix}_BIN=/usr/local/bin/{binary_name}",
                f"{env_prefix}_ALLOWED_READ_DIRS=/workspace/data,/workspace/profiles,/workspace/artifacts",
                f"{env_prefix}_ALLOWED_WRITE_DIRS=/workspace/artifacts",
                f"{env_prefix}_CMD_TIMEOUT_SEC={timeout_sec}",
                "",
            ]
        ),
        encoding="utf-8",
    )


def _write_docker_deploy_readme(wrapper_dir: Path, service_name: str) -> None:
    readme_path = _deploy_readme_path(wrapper_dir)
    readme_path.parent.mkdir(parents=True, exist_ok=True)
    compose_filename = _docker_compose_path(wrapper_dir).name
    readme_path.write_text(
        "\n".join(
            [
                "# Docker Deployment",
                "",
                "This folder gives you a repeatable local container packaging path for the generated MCP wrapper.",
                "",
                "## Files",
                "",
                "- `docker-compose.yml`: build and run the wrapper image",
                "- `docker.env.example`: container-specific runtime env values",
                "- `runtime/`: starter bind-mounted directories for local smoke tests",
                "",
                "## First Run",
                "",
                "1. Replace the stub binary under `../support/bin/` with your real CLI binary.",
                "2. Copy `docker.env.example` to `docker.env` and tweak any paths or timeout values.",
                f"3. Build the image with `docker compose -f {compose_filename} build {service_name}` from this directory.",
                f"4. Run the containerized doctor check with `docker compose -f {compose_filename} run --rm {service_name} python /app/doctor.py --server /app/{SERVER_FILENAME}`.",
                f"5. Start the wrapper with `docker compose -f {compose_filename} up {service_name}` once doctor succeeds.",
                "",
                "## Notes",
                "",
                "- The generated wrapper still assumes stdio transport by default; Docker here is mainly a packaging and smoke-test path.",
                "- The runtime directories mount into `/workspace/...` so the generated env file can stay container-specific and predictable.",
                "- For Kubernetes or hosted transports, adapt this image and runtime contract after the wrapper itself is working locally.",
                "",
            ]
        ),
        encoding="utf-8",
    )


def _print_package_docker_dry_run(wrapper_dir: Path, service_name: str, image_name: str, env_prefix: str) -> None:
    print("Dry run only. No files were written.")
    print(f"wrapper: {wrapper_dir}")
    print(f"service name: {service_name}")
    print(f"image name: {image_name}")
    print(f"env prefix: {env_prefix}")
    print("Files that would be created:")
    for path in (
        _docker_compose_path(wrapper_dir),
        _docker_env_example_path(wrapper_dir),
        _deploy_readme_path(wrapper_dir),
        _runtime_dir(wrapper_dir) / "data" / "README.txt",
        _runtime_dir(wrapper_dir) / "profiles" / "README.txt",
        _runtime_dir(wrapper_dir) / "artifacts" / "README.txt",
    ):
        print(f"- {path}")


def package_docker(args: argparse.Namespace) -> int:
    wrapper_dir = args.wrapper.expanduser().resolve()
    if not wrapper_dir.exists():
        raise SystemExit(f"wrapper directory does not exist: {wrapper_dir}")

    deploy_dir = _deploy_dir(wrapper_dir)
    if deploy_dir.exists() and not args.force:
        raise SystemExit(f"deploy directory already exists: {deploy_dir}. Pass --force to overwrite.")

    metadata = _docker_package_metadata(wrapper_dir, args.binary_name)
    service_name = _server_slug(args.service_name or metadata["service_name"])
    image_name = args.image_name or metadata["image_name"]

    if args.dry_run:
        _print_package_docker_dry_run(wrapper_dir, service_name, image_name, metadata["env_prefix"])
        return 0

    _write_docker_compose(wrapper_dir, service_name, image_name)
    _write_docker_env_example(wrapper_dir, metadata["env_prefix"], metadata["binary_name"], metadata["timeout_sec"])
    _write_docker_runtime_layout(wrapper_dir)
    _write_docker_deploy_readme(wrapper_dir, service_name)

    print(f"Created Docker deployment scaffold at {_deploy_dir(wrapper_dir)}")
    print(f"service name: {service_name}")
    print(f"image name: {image_name}")
    print(f"env prefix: {metadata['env_prefix']}")
    print("Next: replace the stub binary in support/bin/, copy deploy/docker.env.example to deploy/docker.env,")
    print("then run docker compose build and the containerized doctor check from deploy/.")
    return 0


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="mcp-template",
        description="Generate and inspect starter templates for wrapping existing CLIs as MCP servers.",
    )
    parser.add_argument(
        "--version",
        action="version",
        version=f"%(prog)s {__version__}",
    )
    subparsers = parser.add_subparsers(dest="command", required=True)

    generate_parser = subparsers.add_parser(
        "generate",
        help="Generate a project-specific MCP wrapper from a template.",
    )
    generate_parser.add_argument("dest", type=Path, help="Destination directory for the generated template.")
    generate_parser.add_argument(
        "--server-name",
        required=True,
        help="MCP server name. Example: factor-tools",
    )
    generate_parser.add_argument(
        "--tool-name",
        action="append",
        required=True,
        help="Generated MCP tool function name. Repeat this flag to create multiple tool stubs.",
    )
    generate_parser.add_argument(
        "--tool-subcommand",
        action="append",
        help="Override the CLI subcommand for a generated tool with TOOL=SUBCOMMAND. Repeat as needed.",
    )
    generate_parser.add_argument(
        "--infer-tools-from-help",
        action="store_true",
        help="Generate richer tool stubs by reading '<binary> <subcommand> --help' for each generated tool.",
    )
    generate_parser.add_argument(
        "--binary-name",
        help="CLI binary name or fallback executable. Defaults to the server name.",
    )
    generate_parser.add_argument(
        "--env-prefix",
        help="Override the generated env var prefix. Example: FACTOR_TOOLS",
    )
    generate_parser.add_argument(
        "--dry-run",
        action="store_true",
        help="Validate inputs and print what would be generated without writing files.",
    )
    generate_parser.add_argument(
        "--bin-path",
        help="Prefill the generated env/config files with the real executable path.",
    )
    generate_parser.add_argument(
        "--python-path",
        help="Prefill generated client configs with a specific Python interpreter path.",
    )
    generate_parser.add_argument(
        "--read-dirs",
        help="Prefill allowed read directories as a comma-separated list.",
    )
    generate_parser.add_argument(
        "--write-dirs",
        help="Prefill allowed write directories as a comma-separated list.",
    )
    generate_parser.set_defaults(func=generate_template)

    list_parser = subparsers.add_parser(
        "list-templates",
        help="Show the templates currently available in this repo.",
    )
    list_parser.set_defaults(func=list_templates)

    package_parser = subparsers.add_parser(
        "package",
        help="Scaffold deployment assets for an existing generated wrapper.",
    )
    package_subparsers = package_parser.add_subparsers(dest="package_command", required=True)

    docker_parser = package_subparsers.add_parser(
        "docker",
        help="Create a deploy/ folder with Docker Compose and runtime scaffolding.",
    )
    docker_parser.add_argument("wrapper", type=Path, help="Path to an existing generated wrapper directory.")
    docker_parser.add_argument(
        "--service-name",
        help="Override the generated Docker Compose service name. Defaults to the wrapper directory slug.",
    )
    docker_parser.add_argument(
        "--image-name",
        help="Override the generated image tag. Defaults to '<wrapper-name>:latest'.",
    )
    docker_parser.add_argument(
        "--binary-name",
        help="Select which executable under support/bin should be treated as the packaged runtime binary.",
    )
    docker_parser.add_argument(
        "--dry-run",
        action="store_true",
        help="Print the Docker deployment files that would be created without writing them.",
    )
    docker_parser.add_argument(
        "--force",
        action="store_true",
        help="Overwrite an existing deploy/ directory.",
    )
    docker_parser.set_defaults(func=package_docker)

    return parser


def main(argv: list[str] | None = None) -> int:
    parser = build_parser()
    args = parser.parse_args(argv)
    return args.func(args)


if __name__ == "__main__":
    raise SystemExit(main())
