"""Detection, argv assembly, and dry-run tests for `specify self upgrade`."""

import importlib.metadata
import json
import os
import subprocess
from pathlib import Path
from unittest.mock import patch

import pytest

import specify_cli
from specify_cli import app

from tests.self_upgrade_helpers import (
    _InstallMethod,
    _assemble_installer_argv,
    _completed_process,
    _detect_install_method,
    mock_urlopen_response,
    runner,
    strip_ansi,
)


class TestDetectionUvTool:
    """Tier-1 path-prefix detection for uv-tool installs."""

    def test_posix_uv_tool_prefix_matches(self, uv_tool_argv0):
        method, signals = _detect_install_method(include_signals=True)
        assert method == _InstallMethod.UV_TOOL
        assert signals.matched_tier == 1
        assert "uv/tools/specify-cli" in signals.matched_prefix.replace("\\", "/")

    def test_detection_is_deterministic(self, uv_tool_argv0):
        a = _detect_install_method()
        b = _detect_install_method()
        assert a == b == _InstallMethod.UV_TOOL

    def test_no_argv_match_falls_through_to_unsupported(self, unsupported_argv0):
        with patch("specify_cli._version.shutil.which", return_value=None), patch(
            "specify_cli._version._editable_marker_seen", return_value=False
        ):
            method = _detect_install_method()
        assert method == _InstallMethod.UNSUPPORTED

    def test_include_signals_false_returns_bare_enum(self, uv_tool_argv0):
        result = _detect_install_method(include_signals=False)
        assert isinstance(result, _InstallMethod)

    def test_bare_argv0_is_resolved_via_path_lookup(self, monkeypatch, tmp_path):
        if os.name == "nt":
            monkeypatch.setenv("LOCALAPPDATA", str(tmp_path))
            fake_dir = tmp_path / "uv" / "tools" / "specify-cli" / "bin"
        else:
            monkeypatch.setenv("HOME", str(tmp_path))
            fake_dir = (
                tmp_path / ".local" / "share" / "uv" / "tools" / "specify-cli" / "bin"
            )
        fake_dir.mkdir(parents=True)
        fake_specify = fake_dir / "specify"
        fake_specify.write_text("#!/usr/bin/env python\n")
        monkeypatch.setattr("sys.argv", ["specify"])
        with patch(
            "specify_cli._version.shutil.which",
            side_effect=lambda name: str(fake_specify) if name == "specify" else None,
        ):
            method = _detect_install_method()
        assert method == _InstallMethod.UV_TOOL

    def test_prefix_match_does_not_accept_sibling_directory(self, monkeypatch, tmp_path):
        monkeypatch.setenv("HOME", str(tmp_path))
        fake_dir = tmp_path / ".local" / "share" / "uv" / "tools" / "specify-cli2" / "bin"
        fake_dir.mkdir(parents=True)
        fake_specify = fake_dir / "specify"
        fake_specify.write_text("#!/usr/bin/env python\n")
        monkeypatch.setattr("sys.argv", [str(fake_specify)])
        with patch("specify_cli._version.shutil.which", return_value=None), patch(
            "specify_cli._version._editable_marker_seen", return_value=False
        ):
            method = _detect_install_method()
        assert method == _InstallMethod.UNSUPPORTED

    def test_tier3_uv_tool_when_registry_lists_exact_name(
        self,
        monkeypatch,
        tmp_path,
    ):
        monkeypatch.setattr("sys.argv", [str(tmp_path / "missing" / "specify")])

        def fake_which(name):
            return "uv" if name == "uv" else None

        def fake_run(argv, *args, **kwargs):
            if argv[:3] == ["uv", "tool", "list"]:
                return subprocess.CompletedProcess(
                    args=argv,
                    returncode=0,
                    stdout="specify-cli v0.7.6\nother-tool v1.2.3\n",
                    stderr="",
                )
            return subprocess.CompletedProcess(
                args=argv, returncode=1, stdout="", stderr=""
            )

        with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch(
            "specify_cli._version.subprocess.run", side_effect=fake_run
        ), patch("specify_cli._version._editable_marker_seen", return_value=False):
            method, signals = _detect_install_method(include_signals=True)
        assert method == _InstallMethod.UV_TOOL
        assert signals.matched_tier == 3
        assert "uv tool list" in signals.installer_registries_consulted

    def test_unresolved_bare_argv0_skips_tier3_registry_detection(self, monkeypatch):
        monkeypatch.setattr("sys.argv", ["specify"])

        def fake_which(name):
            return "uv" if name == "uv" else None

        def fake_run(argv, *args, **kwargs):
            return subprocess.CompletedProcess(
                args=argv,
                returncode=0,
                stdout="specify-cli v0.7.6\n",
                stderr="",
            )

        with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch(
            "specify_cli._version.subprocess.run", side_effect=fake_run
        ), patch("specify_cli._version._editable_marker_seen", return_value=False):
            method, signals = _detect_install_method(include_signals=True)
        assert method == _InstallMethod.UNSUPPORTED
        assert signals.installer_registries_consulted == ()

    def test_bare_argv0_missing_path_resolution_allows_tier3_registry_detection(
        self, monkeypatch, tmp_path
    ):
        missing_specify = tmp_path / "missing" / "specify"
        monkeypatch.setattr("sys.argv", ["specify"])

        def fake_which(name):
            if name == "specify":
                return str(missing_specify)
            if name == "uv":
                return "uv"
            return None

        def fake_run(argv, *args, **kwargs):
            if argv[:3] == ["uv", "tool", "list"]:
                return subprocess.CompletedProcess(
                    args=argv,
                    returncode=0,
                    stdout="specify-cli v0.7.6\n",
                    stderr="",
                )
            return subprocess.CompletedProcess(
                args=argv, returncode=1, stdout="", stderr=""
            )

        with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch(
            "specify_cli._version.subprocess.run", side_effect=fake_run
        ), patch("specify_cli._version._editable_marker_seen", return_value=False):
            method, signals = _detect_install_method(include_signals=True)

        assert method == _InstallMethod.UV_TOOL
        assert signals.matched_tier == 3
        assert "uv tool list" in signals.installer_registries_consulted

    def test_missing_relative_argv0_falls_back_to_entrypoint_name_lookup(
        self, monkeypatch, tmp_path
    ):
        if os.name == "nt":
            monkeypatch.setenv("LOCALAPPDATA", str(tmp_path))
            fake_dir = tmp_path / "uv" / "tools" / "specify-cli" / "bin"
        else:
            monkeypatch.setenv("HOME", str(tmp_path))
            fake_dir = (
                tmp_path / ".local" / "share" / "uv" / "tools" / "specify-cli" / "bin"
            )
        fake_dir.mkdir(parents=True)
        fake_specify = fake_dir / "specify"
        fake_specify.write_text("#!/usr/bin/env python\n")
        monkeypatch.setattr("sys.argv", ["./bin/specify"])

        def fake_which(name):
            return str(fake_specify) if name == "specify" else None

        with patch("specify_cli._version.shutil.which", side_effect=fake_which):
            method = _detect_install_method()

        assert method == _InstallMethod.UV_TOOL

    def test_tier3_uv_tool_ignores_substring_false_positive(
        self,
        unsupported_argv0,
    ):
        def fake_which(name):
            return "uv" if name == "uv" else None

        def fake_run(argv, *args, **kwargs):
            if argv[:3] == ["uv", "tool", "list"]:
                return subprocess.CompletedProcess(
                    args=argv,
                    returncode=0,
                    stdout="my-specify-cli-helper v0.1.0\n",
                    stderr="",
                )
            return subprocess.CompletedProcess(
                args=argv, returncode=1, stdout="", stderr=""
            )

        with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch(
            "specify_cli._version.subprocess.run", side_effect=fake_run
        ), patch("specify_cli._version._editable_marker_seen", return_value=False):
            method = _detect_install_method()
        assert method == _InstallMethod.UNSUPPORTED

    def test_tier3_uv_tool_does_not_override_absolute_unsupported_entrypoint(
        self,
        unsupported_argv0,
    ):
        def fake_which(name):
            return "uv" if name == "uv" else None

        def fake_run(argv, *args, **kwargs):
            if argv[:3] == ["uv", "tool", "list"]:
                return subprocess.CompletedProcess(
                    args=argv,
                    returncode=0,
                    stdout="specify-cli v0.7.6\n",
                    stderr="",
                )
            return subprocess.CompletedProcess(
                args=argv, returncode=1, stdout="", stderr=""
            )

        with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch(
            "specify_cli._version.subprocess.run", side_effect=fake_run
        ), patch("specify_cli._version._editable_marker_seen", return_value=False):
            method = _detect_install_method()
        assert method == _InstallMethod.UNSUPPORTED

    def test_tier3_uv_tool_does_not_override_resolved_bare_unsupported_entrypoint(
        self,
        monkeypatch,
        tmp_path,
    ):
        venv_bin = tmp_path / "venv" / "bin"
        venv_bin.mkdir(parents=True)
        fake_specify = venv_bin / "specify"
        fake_specify.write_text("#!/usr/bin/env python\n")
        fake_specify.chmod(0o755)
        monkeypatch.setattr("sys.argv", ["specify"])

        def fake_which(name):
            if name == "specify":
                return str(fake_specify)
            if name == "uv":
                return "uv"
            return None

        def fake_run(argv, *args, **kwargs):
            if argv[:3] == ["uv", "tool", "list"]:
                return subprocess.CompletedProcess(
                    args=argv,
                    returncode=0,
                    stdout="specify-cli v0.7.6\n",
                    stderr="",
                )
            return subprocess.CompletedProcess(
                args=argv, returncode=1, stdout="", stderr=""
            )

        with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch(
            "specify_cli._version.subprocess.run", side_effect=fake_run
        ), patch("specify_cli._version._editable_marker_seen", return_value=False):
            method, signals = _detect_install_method(include_signals=True)
        assert method == _InstallMethod.UNSUPPORTED
        assert signals.matched_tier is None
        assert signals.installer_registries_consulted == ()


class TestPrefixExpansion:
    """Path-prefix expansion edge cases."""

    def test_literal_dollar_without_variable_name_is_preserved(self, tmp_path):
        prefix_path = tmp_path / "specify-$-cache" / "tools" / "specify-cli"
        prefix = str(prefix_path)

        expanded = specify_cli._version._expand_prefix(prefix)

        assert expanded == prefix_path.resolve()

    def test_unresolved_posix_variable_is_rejected(self):
        assert specify_cli._version._expand_prefix("$SPECIFY_MISSING/specify-cli/") is None

    def test_absolute_prefix_resolve_oserror_is_rejected(self, tmp_path):
        prefix = str(tmp_path / "specify-cli")

        with patch("pathlib.Path.resolve", side_effect=OSError("bad path")):
            assert specify_cli._version._expand_prefix(prefix) is None


class TestArgv0Resolution:
    """Entrypoint path resolution edge cases."""

    def test_absolute_argv0_resolve_oserror_returns_original_path(self, tmp_path):
        argv0 = tmp_path / "specify"

        with patch("pathlib.Path.resolve", side_effect=OSError("bad path")):
            assert specify_cli._version._resolved_argv0_path(str(argv0)) == argv0

    def test_path_lookup_resolve_oserror_returns_unresolved_lookup_path(self):
        with patch(
            "specify_cli._version.shutil.which", return_value="/broken/specify"
        ), patch("pathlib.Path.resolve", side_effect=OSError("bad path")):
            result = specify_cli._version._resolved_argv0_path("specify")

        # Compare as Path objects: on Windows the same logical path renders
        # with backslashes, so a raw string compare against the POSIX form
        # would spuriously fail.
        assert result == Path("/broken/specify")


class TestArgvAssemblyUvTool:
    """uv-tool installer argv shape."""

    def test_stable_tag_produces_expected_argv(self):
        with patch("specify_cli._version.shutil.which", return_value="uv"):
            argv = _assemble_installer_argv(_InstallMethod.UV_TOOL, "v0.7.6")
        assert argv == [
            "uv",
            "tool",
            "install",
            "specify-cli",
            "--force",
            "--from",
            "git+https://github.com/github/spec-kit.git@v0.7.6",
        ]

    def test_dev_suffix_tag_embedded_literally(self):
        with patch("specify_cli._version.shutil.which", return_value="uv"):
            argv = _assemble_installer_argv(_InstallMethod.UV_TOOL, "v0.8.0.dev0")
        assert "git+https://github.com/github/spec-kit.git@v0.8.0.dev0" in argv
        assert (
            "upgrade" not in argv
        )  # never `uv tool upgrade` — does not accept --tag pinning

    def test_missing_uv_returns_no_installer_argv(self):
        with patch("specify_cli._version.shutil.which", return_value=None):
            assert _assemble_installer_argv(_InstallMethod.UV_TOOL, "v0.7.6") is None


class TestBareUpgradeUvTool:
    """uv-tool happy path, bare invocation."""

    def test_happy_path_end_to_end(self, uv_tool_argv0, clean_environ):
        with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
            "specify_cli._version.shutil.which", return_value="uv"
        ), patch("specify_cli._version.subprocess.run") as mock_run, patch(
            "specify_cli._version._get_installed_version", return_value="0.7.5"
        ):
            mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
            mock_run.side_effect = [
                _completed_process(0),  # installer
                _completed_process(0, stdout="specify 0.7.6\n"),  # verify
            ]
            result = runner.invoke(app, ["self", "upgrade"])

        assert result.exit_code == 0
        out = strip_ansi(result.output)
        assert "Upgrading specify-cli 0.7.5 → v0.7.6 via uv tool:" in out
        assert "Upgraded specify-cli: 0.7.5 → 0.7.6" in out
        assert mock_run.call_count == 2
        for call in mock_run.call_args_list:
            assert call.kwargs.get("shell", False) is False

    def test_one_user_action_no_prompt(self, uv_tool_argv0, clean_environ):
        # The single `invoke` represents the single user action — no prompt.
        # If a prompt existed, runner.invoke would hang waiting for input.
        with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
            "specify_cli._version.shutil.which", return_value="uv"
        ), patch("specify_cli._version.subprocess.run") as mock_run, patch(
            "specify_cli._version._get_installed_version", return_value="0.7.5"
        ):
            mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
            mock_run.side_effect = [
                _completed_process(0),
                _completed_process(0, stdout="specify 0.7.6\n"),
            ]
            result = runner.invoke(app, ["self", "upgrade"])
        assert result.exit_code == 0


class TestAlreadyLatestUvTool:
    """already on latest, no installer launched."""

    def test_already_latest_exits_zero_no_subprocess(
        self, uv_tool_argv0, clean_environ
    ):
        with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
            "specify_cli._version.subprocess.run"
        ) as mock_run, patch(
            "specify_cli._version.shutil.which", return_value="uv"
        ), patch("specify_cli._version._get_installed_version", return_value="0.7.6"):
            mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
            result = runner.invoke(app, ["self", "upgrade"])

        assert result.exit_code == 0
        assert "Already on latest release: v0.7.6" in strip_ansi(result.output)
        assert mock_run.call_count == 0

    def test_trailing_zero_equivalent_version_reports_latest_not_newer(
        self, uv_tool_argv0, clean_environ
    ):
        # Version("1.0") == Version("1.0.0") under packaging even though their
        # canonical strings differ. The no-op message must use Version equality
        # so this prints "Already on latest release", not "... or newer".
        with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
            "specify_cli._version.subprocess.run"
        ) as mock_run, patch(
            "specify_cli._version.shutil.which", return_value="uv"
        ), patch("specify_cli._version._get_installed_version", return_value="1.0"):
            mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v1.0.0"})
            result = runner.invoke(app, ["self", "upgrade"])

        assert result.exit_code == 0
        out = strip_ansi(result.output)
        assert "Already on latest release: v1.0.0" in out
        assert "or newer" not in out
        assert mock_run.call_count == 0

    def test_dev_build_ahead_of_release_reports_newer_noop(
        self, uv_tool_argv0, clean_environ
    ):
        with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
            "specify_cli._version.subprocess.run"
        ) as mock_run, patch(
            "specify_cli._version.shutil.which", return_value="uv"
        ), patch("specify_cli._version._get_installed_version", return_value="0.7.7.dev0"):
            mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
            result = runner.invoke(app, ["self", "upgrade"])

        assert result.exit_code == 0
        assert "Already on latest release or newer: 0.7.7.dev0" in strip_ansi(result.output)
        assert mock_run.call_count == 0

    def test_unparseable_current_version_does_not_false_noop(
        self, uv_tool_argv0, clean_environ
    ):
        with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
            "specify_cli._version.subprocess.run"
        ) as mock_run, patch(
            "specify_cli._version.shutil.which", return_value="uv"
        ), patch("specify_cli._version._get_installed_version", return_value="release-main"):
            mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
            mock_run.side_effect = [
                _completed_process(0),
                _completed_process(0, stdout="specify 0.7.6\n"),
            ]
            result = runner.invoke(app, ["self", "upgrade"])

        assert result.exit_code == 0
        out = strip_ansi(result.output)
        assert "Already on latest release" not in out
        assert "Upgrading specify-cli release-main → v0.7.6 via uv tool:" in out
        assert mock_run.call_count == 2

    def test_unparseable_resolved_target_fails_before_literal_noop(
        self, uv_tool_argv0, clean_environ
    ):
        with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
            "specify_cli._version.subprocess.run"
        ) as mock_run, patch(
            "specify_cli._version.shutil.which", return_value="uv"
        ), patch("specify_cli._version._get_installed_version", return_value="release-main"):
            mock_urlopen.return_value = mock_urlopen_response({"tag_name": "release-main"})
            result = runner.invoke(app, ["self", "upgrade"])

        assert result.exit_code == 1
        out = strip_ansi(result.output)
        assert "not a comparable version" in out
        assert "release-main" not in out
        assert "Already on latest release" not in out
        assert mock_run.call_count == 0

    def test_pinned_older_tag_still_runs_installer(
        self, uv_tool_argv0, clean_environ
    ):
        with patch("specify_cli._version.shutil.which", return_value="uv"), patch(
            "specify_cli._version.subprocess.run"
        ) as mock_run, patch(
            "specify_cli._version._get_installed_version", return_value="0.7.6"
        ):
            mock_run.side_effect = [
                _completed_process(0),
                _completed_process(0, stdout="specify 0.7.5\n"),
            ]
            result = runner.invoke(app, ["self", "upgrade", "--tag", "v0.7.5"])

        assert result.exit_code == 0
        out = strip_ansi(result.output)
        assert "Already on latest release" not in out
        # A pinned older tag is a downgrade and must be labelled as such.
        assert "Downgrading specify-cli 0.7.6 → v0.7.5 via uv tool:" in out
        assert "Upgrading specify-cli" not in out
        assert mock_run.call_count == 2

    def test_pinned_rc_tag_uses_canonical_version_equality_for_noop(
        self, uv_tool_argv0, clean_environ
    ):
        with patch("specify_cli._version.shutil.which", return_value="uv"), patch(
            "specify_cli._version._get_installed_version", return_value="1.0.0rc1"
        ):
            result = runner.invoke(app, ["self", "upgrade", "--tag", "v1.0.0-rc1"])

        assert result.exit_code == 0
        assert "Already on requested release: v1.0.0-rc1" in strip_ansi(result.output)


class TestDryRunUvTool:
    """--dry-run preview path + --dry-run combined with --tag."""

    def test_dry_run_without_tag_resolves_network_but_no_subprocess(
        self,
        uv_tool_argv0,
        clean_environ,
    ):
        with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
            "specify_cli._version.subprocess.run"
        ) as mock_run, patch(
            "specify_cli._version.shutil.which", return_value="uv"
        ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"):
            mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
            result = runner.invoke(app, ["self", "upgrade", "--dry-run"])

        assert result.exit_code == 0
        out = strip_ansi(result.output)
        assert "Dry run — no changes will be made." in out
        assert "Detected install method: uv tool" in out
        assert "Current version: 0.7.5" in out
        assert "Target version: v0.7.6" in out
        assert "Command that would be executed:" in out
        assert mock_run.call_count == 0

    def test_dry_run_with_tag_skips_network(self, uv_tool_argv0, clean_environ):
        # --dry-run with --tag must NOT hit the network.
        with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
            "specify_cli._version.subprocess.run"
        ), patch("specify_cli._version.shutil.which", return_value="uv"), patch(
            "specify_cli._version._get_installed_version", return_value="0.7.5"
        ):
            result = runner.invoke(
                app,
                ["self", "upgrade", "--dry-run", "--tag", "v0.8.0"],
            )
        assert result.exit_code == 0
        assert "Target version: v0.8.0" in strip_ansi(result.output)
        mock_urlopen.assert_not_called()

    def test_dry_run_rejects_unparseable_network_tag_before_preview(
        self, uv_tool_argv0, clean_environ
    ):
        with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
            "specify_cli._version.subprocess.run"
        ) as mock_run, patch(
            "specify_cli._version.shutil.which", return_value="uv"
        ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"):
            mock_urlopen.return_value = mock_urlopen_response(
                {"tag_name": "v0.9.0;echo unsafe"}
            )
            result = runner.invoke(app, ["self", "upgrade", "--dry-run"])

        out = strip_ansi(result.output)
        assert result.exit_code == 1
        assert "not a comparable version" in out
        assert "v0.9.0;echo unsafe" not in out
        assert "Command that would be executed:" not in out
        assert mock_run.call_count == 0

    def test_dry_run_with_missing_uv_flags_unresolved_installer(
        self, uv_tool_argv0, clean_environ
    ):
        with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
            "specify_cli._version.subprocess.run"
        ) as mock_run, patch(
            "specify_cli._version.shutil.which", return_value=None
        ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"):
            mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
            result = runner.invoke(app, ["self", "upgrade", "--dry-run"])

        assert result.exit_code == 0
        out = strip_ansi(result.output)
        assert "Command that would be executed: (installer uv not found on PATH)" in out
        assert "uv tool install" not in out
        assert mock_run.call_count == 0


# ===========================================================================
# Phase 4 — User Story 2: `pipx` immediate upgrade (P2)
# ===========================================================================


class TestDetectionPipx:
    """Pipx detection — tier 1 (path) and tier 3 (registry)."""

    def test_posix_pipx_prefix_matches(self, pipx_argv0):
        method, signals = _detect_install_method(include_signals=True)
        assert method == _InstallMethod.PIPX
        assert signals.matched_tier == 1

    def test_tier3_pipx_when_no_prefix_match_but_registry_lists_it(
        self,
        monkeypatch,
        tmp_path,
    ):
        monkeypatch.setattr("sys.argv", [str(tmp_path / "missing" / "specify")])

        def fake_which(name):
            return "pipx" if name == "pipx" else None

        def fake_run(argv, *args, **kwargs):
            if argv[:3] == ["pipx", "list", "--json"]:
                return subprocess.CompletedProcess(
                    args=argv,
                    returncode=0,
                    stdout='{"venvs":{"specify-cli":{}}}',
                    stderr="",
                )
            return subprocess.CompletedProcess(
                args=argv, returncode=1, stdout="", stderr=""
            )

        with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch(
            "specify_cli._version.subprocess.run", side_effect=fake_run
        ), patch("specify_cli._version._editable_marker_seen", return_value=False):
            method, signals = _detect_install_method(include_signals=True)
        assert method == _InstallMethod.PIPX
        assert signals.matched_tier == 3
        assert "pipx list --json" in signals.installer_registries_consulted

    def test_tier3_pipx_does_not_override_absolute_unsupported_entrypoint(
        self,
        unsupported_argv0,
    ):
        def fake_which(name):
            return "pipx" if name == "pipx" else None

        def fake_run(argv, *args, **kwargs):
            if argv[:3] == ["pipx", "list", "--json"]:
                return subprocess.CompletedProcess(
                    args=argv,
                    returncode=0,
                    stdout='{"venvs":{"specify-cli":{}}}',
                    stderr="",
                )
            return subprocess.CompletedProcess(
                args=argv, returncode=1, stdout="", stderr=""
            )

        with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch(
            "specify_cli._version.subprocess.run", side_effect=fake_run
        ), patch("specify_cli._version._editable_marker_seen", return_value=False):
            method = _detect_install_method()
        assert method == _InstallMethod.UNSUPPORTED

    def test_tier3_pipx_ignores_malformed_json_output(
        self,
        unsupported_argv0,
    ):
        def fake_which(name):
            return "pipx" if name == "pipx" else None

        def fake_run(argv, *args, **kwargs):
            if argv[:3] == ["pipx", "list", "--json"]:
                return subprocess.CompletedProcess(
                    args=argv,
                    returncode=0,
                    stdout="not json but mentions specify-cli",
                    stderr="",
                )
            return subprocess.CompletedProcess(
                args=argv, returncode=1, stdout="", stderr=""
            )

        with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch(
            "specify_cli._version.subprocess.run", side_effect=fake_run
        ), patch("specify_cli._version._editable_marker_seen", return_value=False):
            method = _detect_install_method()
        assert method == _InstallMethod.UNSUPPORTED

    def test_tier3_both_uv_tool_and_pipx_match_is_treated_as_unsupported(
        self,
        monkeypatch,
        tmp_path,
    ):
        monkeypatch.setattr("sys.argv", [str(tmp_path / "missing" / "specify")])

        def fake_which(name):
            if name == "uv":
                return "uv"
            if name == "pipx":
                return "pipx"
            return None

        def fake_run(argv, *args, **kwargs):
            if argv[:3] == ["uv", "tool", "list"]:
                return subprocess.CompletedProcess(
                    args=argv,
                    returncode=0,
                    stdout="specify-cli v0.7.6\n",
                    stderr="",
                )
            if argv[:3] == ["pipx", "list", "--json"]:
                return subprocess.CompletedProcess(
                    args=argv,
                    returncode=0,
                    stdout='{"venvs":{"specify-cli":{}}}',
                    stderr="",
                )
            return subprocess.CompletedProcess(
                args=argv, returncode=1, stdout="", stderr=""
            )

        with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch(
            "specify_cli._version.subprocess.run", side_effect=fake_run
        ), patch("specify_cli._version._editable_marker_seen", return_value=False):
            method, signals = _detect_install_method(include_signals=True)
        assert method == _InstallMethod.UNSUPPORTED
        assert signals.matched_tier is None
        assert "uv tool list" in signals.installer_registries_consulted
        assert "pipx list --json" in signals.installer_registries_consulted


class TestEditableInstallMetadata:
    @pytest.mark.skipif(
        not hasattr(importlib.metadata, "InvalidMetadataError"),
        reason=(
            "importlib.metadata.InvalidMetadataError does not exist on this "
            "Python; _editable_direct_url_path only catches it when present, so "
            "fabricating it would exercise a path that cannot fire in production"
        ),
    )
    def test_editable_marker_false_when_metadata_is_invalid(self):
        invalid_metadata_error = importlib.metadata.InvalidMetadataError

        with patch(
            "importlib.metadata.distribution",
            side_effect=invalid_metadata_error("bad metadata"),
        ):
            assert specify_cli._version._editable_marker_seen() is False
            assert specify_cli._version._source_checkout_path() is None

    def test_direct_url_editable_install_marks_source_checkout(self, tmp_path):
        project_root = tmp_path / "spec-kit"
        project_root.mkdir()
        (project_root / ".git").mkdir()

        class FakeDist:
            files = []

            def read_text(self, name):
                if name == "direct_url.json":
                    return json.dumps(
                        {
                            "dir_info": {"editable": True},
                            "url": project_root.as_uri(),
                        }
                    )
                return None

            def locate_file(self, file):
                return file

        with patch("importlib.metadata.distribution", return_value=FakeDist()):
            assert specify_cli._version._editable_marker_seen() is True
            assert specify_cli._version._source_checkout_path() == project_root.resolve()

    def test_editable_marker_false_without_explicit_editable_metadata(self, tmp_path):
        repo_root = tmp_path / "repo"
        repo_root.mkdir()
        (repo_root / ".git").mkdir()
        venv_file = repo_root / ".venv" / "lib" / "python3.13" / "site-packages" / "specify_cli.py"
        venv_file.parent.mkdir(parents=True)
        venv_file.write_text("# installed module\n")

        class FakeDist:
            files = ["specify_cli.py"]

            def read_text(self, name):
                return None

            def locate_file(self, file):
                return venv_file

        with patch("importlib.metadata.distribution", return_value=FakeDist()):
            assert specify_cli._version._editable_marker_seen() is False


class TestTagValidationWhitespace:
    def test_tag_whitespace_is_trimmed_before_validation(self, uv_tool_argv0, clean_environ):
        with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
            "specify_cli._version.shutil.which", return_value="uv"
        ), patch("specify_cli._version.subprocess.run") as mock_run, patch(
            "specify_cli._version._get_installed_version", return_value="0.7.5"
        ):
            mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v9.9.9"})
            mock_run.side_effect = [
                _completed_process(0),
                _completed_process(0, stdout="specify 0.8.0\n"),
            ]
            result = runner.invoke(app, ["self", "upgrade", "--tag", " v0.8.0 "])

        assert result.exit_code == 0
        assert "v0.8.0" in strip_ansi(result.output)


class TestArgvAssemblyPipx:
    """pipx installer argv shape — pipx 1.5+ uses positional PACKAGE_SPEC, never `--spec` or `upgrade`."""

    def test_pipx_argv_uses_install_force_positional_not_upgrade(self):
        with patch("specify_cli._version.shutil.which", return_value="pipx"):
            argv = _assemble_installer_argv(_InstallMethod.PIPX, "v0.7.6")
        assert argv == [
            "pipx",
            "install",
            "--force",
            "git+https://github.com/github/spec-kit.git@v0.7.6",
        ]
        assert "upgrade" not in argv  # pipx upgrade does not accept arbitrary refs
        assert "--spec" not in argv  # pipx 1.5+ dropped the --spec flag

    def test_missing_pipx_returns_no_installer_argv(self):
        with patch("specify_cli._version.shutil.which", return_value=None):
            assert _assemble_installer_argv(_InstallMethod.PIPX, "v0.7.6") is None


class TestBareUpgradePipx:
    """pipx happy path."""

    def test_happy_path(self, pipx_argv0, clean_environ):
        with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
            "specify_cli._version.shutil.which", return_value="pipx"
        ), patch("specify_cli._version.subprocess.run") as mock_run, patch(
            "specify_cli._version._get_installed_version", return_value="0.7.5"
        ):
            mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
            mock_run.side_effect = [
                _completed_process(0),
                _completed_process(0, stdout="specify 0.7.6\n"),
            ]
            result = runner.invoke(app, ["self", "upgrade"])

        assert result.exit_code == 0
        out = strip_ansi(result.output)
        assert "via pipx:" in out
        assert "Upgraded specify-cli: 0.7.5 → 0.7.6" in out


class TestDetectionShortCircuit:
    """Tier-1 path-prefix matches short-circuit before registry checks."""

    def test_pipx_argv0_prefix_short_circuits_before_registry_checks(
        self,
        pipx_argv0,
        clean_environ,
    ):
        with patch("specify_cli._version.shutil.which", return_value="/usr/bin/X"), patch(
            "specify_cli._version.subprocess.run"
        ) as mock_run:
            method = _detect_install_method()
        assert method == _InstallMethod.PIPX
        mock_run.assert_not_called()


class TestDryRunPipx:
    def test_dry_run_preview_names_pipx(self, pipx_argv0, clean_environ):
        with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
            "specify_cli._version.shutil.which", return_value="pipx"
        ), patch("specify_cli._version.subprocess.run") as mock_run, patch(
            "specify_cli._version._get_installed_version", return_value="0.7.5"
        ):
            mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
            result = runner.invoke(app, ["self", "upgrade", "--dry-run"])
        assert result.exit_code == 0
        assert "Detected install method: pipx" in strip_ansi(result.output)
        assert mock_run.call_count == 0
