"""Tests for activate script PATH patching, shim placement, and legacy cleanup.

Covers:
1. _patch_activate() / _unpatch_activate() — kept for legacy cleanup of old installs
2. Tool shims now placed directly in bin/ (no subdirectory needed)
3. Legacy shim cleanup — restore_venv removes old bin/.specter-tools/ and bin/specter/ dirs
4. Shim relative paths — bin/<tool> shims reference ./python.specter-original
5. Full patch/restore lifecycle
"""

from __future__ import annotations

import stat
from pathlib import Path

import pytest

from specter_cli.venv import (
    _ACTIVATE_BEGIN,
    _ACTIVATE_END,
    _FORWARDED_TOOLS,
    _TOOL_SHIM_DIR,
    _patch_activate,
    _unpatch_activate,
    _write_tool_shims,
    patch_venv,
    restore_venv,
)

# Standard activate PATH line used as anchor for injection.
_ACTIVATE_ANCHOR = 'export PATH="$VIRTUAL_ENV/bin:$PATH"'


def _make_activate(bin_dir: Path, content: str | None = None) -> Path:
    """Create a minimal activate script in bin_dir."""
    activate = bin_dir / "activate"
    if content is None:
        content = (
            'VIRTUAL_ENV="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"\n'
            "export VIRTUAL_ENV\n"
            f"{_ACTIVATE_ANCHOR}\n"
        )
    activate.write_text(content)
    return activate


@pytest.fixture
def bin_dir(tmp_path: Path) -> Path:
    d = tmp_path / "bin"
    d.mkdir()
    return d


@pytest.fixture
def fake_venv(tmp_path: Path) -> Path:
    """Create a minimal fake venv with python binary and activate script."""
    venv = tmp_path / "fakevenv"
    bin_dir = venv / "bin"
    bin_dir.mkdir(parents=True)
    python = bin_dir / "python"
    python.write_text("#!/bin/bash\necho fake python\n")
    python.chmod(0o755)
    python3 = bin_dir / "python3"
    python3.symlink_to("python")
    _make_activate(bin_dir)
    return venv


# ---------------------------------------------------------------------------
# _patch_activate() tests — kept for legacy cleanup support
# ---------------------------------------------------------------------------


class TestPatchActivate:
    """Tests for _patch_activate() — legacy function kept for old installs."""

    def test_injects_after_path_export(self, bin_dir: Path) -> None:
        """Should inject specter PATH block after the standard PATH export."""
        _make_activate(bin_dir)
        _patch_activate(bin_dir)

        text = (bin_dir / "activate").read_text()
        assert _ACTIVATE_BEGIN in text
        assert _ACTIVATE_END in text
        assert f"$VIRTUAL_ENV/bin/{_TOOL_SHIM_DIR}" in text

    def test_injection_follows_anchor(self, bin_dir: Path) -> None:
        """The specter block should appear after the PATH export line."""
        _make_activate(bin_dir)
        _patch_activate(bin_dir)

        text = (bin_dir / "activate").read_text()
        anchor_pos = text.index(_ACTIVATE_ANCHOR)
        begin_pos = text.index(_ACTIVATE_BEGIN)
        assert begin_pos > anchor_pos

    def test_idempotent_double_patch(self, bin_dir: Path) -> None:
        """Calling _patch_activate twice should not duplicate the block."""
        _make_activate(bin_dir)
        _patch_activate(bin_dir)
        _patch_activate(bin_dir)

        text = (bin_dir / "activate").read_text()
        assert text.count(_ACTIVATE_BEGIN) == 1
        assert text.count(_ACTIVATE_END) == 1

    def test_idempotent_guard_in_bash(self, bin_dir: Path) -> None:
        """The injected block should have a bash idempotency guard."""
        _make_activate(bin_dir)
        _patch_activate(bin_dir)

        text = (bin_dir / "activate").read_text()
        # The guard prevents duplicate PATH entries when activate is re-sourced.
        assert '":$PATH:"' in text
        assert f"$VIRTUAL_ENV/bin/{_TOOL_SHIM_DIR}" in text

    def test_missing_activate_skips_silently(self, bin_dir: Path) -> None:
        """No activate script → should not crash."""
        # Don't create activate.
        _patch_activate(bin_dir)
        # Should not raise.

    def test_fallback_appends_when_anchor_missing(self, bin_dir: Path) -> None:
        """If the standard PATH export line is missing, append to end."""
        activate = bin_dir / "activate"
        activate.write_text("# custom activate without standard PATH line\n")

        _patch_activate(bin_dir)

        text = activate.read_text()
        assert _ACTIVATE_BEGIN in text
        assert _ACTIVATE_END in text

    def test_preserves_existing_content(self, bin_dir: Path) -> None:
        """Patching should not modify existing activate content."""
        original = f"# my custom header\n{_ACTIVATE_ANCHOR}\n# my custom footer\n"
        _make_activate(bin_dir, original)
        _patch_activate(bin_dir)

        text = (bin_dir / "activate").read_text()
        assert "# my custom header" in text
        assert "# my custom footer" in text
        assert _ACTIVATE_ANCHOR in text


# ---------------------------------------------------------------------------
# _unpatch_activate() tests
# ---------------------------------------------------------------------------


class TestUnpatchActivate:
    """Tests for _unpatch_activate() — remove specter PATH from activate."""

    def test_removes_injected_block(self, bin_dir: Path) -> None:
        """Should remove the entire specter block."""
        _make_activate(bin_dir)
        _patch_activate(bin_dir)

        text = (bin_dir / "activate").read_text()
        assert _ACTIVATE_BEGIN in text

        _unpatch_activate(bin_dir)

        text = (bin_dir / "activate").read_text()
        assert _ACTIVATE_BEGIN not in text
        assert _ACTIVATE_END not in text
        assert _TOOL_SHIM_DIR not in text

    def test_preserves_original_content(self, bin_dir: Path) -> None:
        """Unpatching should restore activate to (roughly) original state."""
        original = f"# header\n{_ACTIVATE_ANCHOR}\n# footer\n"
        _make_activate(bin_dir, original)
        _patch_activate(bin_dir)
        _unpatch_activate(bin_dir)

        text = (bin_dir / "activate").read_text()
        assert "# header" in text
        assert "# footer" in text
        assert _ACTIVATE_ANCHOR in text

    def test_noop_when_not_patched(self, bin_dir: Path) -> None:
        """Unpatching an unpatched activate should be a no-op."""
        _make_activate(bin_dir)
        original_text = (bin_dir / "activate").read_text()

        _unpatch_activate(bin_dir)

        assert (bin_dir / "activate").read_text() == original_text

    def test_noop_when_no_activate(self, bin_dir: Path) -> None:
        """No activate file → should not crash."""
        _unpatch_activate(bin_dir)
        # Should not raise.

    def test_double_unpatch_is_safe(self, bin_dir: Path) -> None:
        """Calling _unpatch_activate twice should not crash."""
        _make_activate(bin_dir)
        _patch_activate(bin_dir)
        _unpatch_activate(bin_dir)
        _unpatch_activate(bin_dir)

        text = (bin_dir / "activate").read_text()
        assert _ACTIVATE_BEGIN not in text


# ---------------------------------------------------------------------------
# Legacy shim cleanup
# ---------------------------------------------------------------------------


class TestLegacyShimCleanup:
    """restore_venv should clean up shims in bin/ and legacy directories."""

    def test_restore_removes_bin_shim(self, fake_venv: Path) -> None:
        """bin/nvidia-smi shim should be removed by restore."""
        patch_venv(fake_venv, "h100")

        shim = fake_venv / "bin" / "nvidia-smi"
        assert shim.exists(), "Shim should exist after patch"

        restore_venv(fake_venv)

        assert not shim.exists(), "bin/nvidia-smi should be removed by restore"

    def test_restore_removes_legacy_specter_tools_dir(self, fake_venv: Path) -> None:
        """Legacy bin/.specter-tools/ dir from pre-0.2.4 should be cleaned up."""
        patch_venv(fake_venv, "h100")

        # Simulate leftover legacy .specter-tools dir from pre-0.2.4.
        legacy_dir = fake_venv / "bin" / ".specter-tools"
        legacy_dir.mkdir(exist_ok=True)
        (legacy_dir / "nvidia-smi").write_text("#!/bin/bash\nlegacy\n")

        restore_venv(fake_venv)

        assert not legacy_dir.exists(), ".specter-tools/ dir should be cleaned up"

    def test_restore_removes_legacy_specter_dir(self, fake_venv: Path) -> None:
        """Legacy bin/specter/ dir from pre-0.2.2 should be cleaned up."""
        patch_venv(fake_venv, "h100")

        legacy_dir = fake_venv / "bin" / "specter"
        legacy_dir.mkdir(exist_ok=True)
        (legacy_dir / "nvidia-smi").write_text("#!/bin/bash\nlegacy\n")

        restore_venv(fake_venv)

        assert not legacy_dir.exists(), "Legacy bin/specter/ dir should be cleaned up"

    def test_restore_tolerates_missing_specter_dir(self, fake_venv: Path) -> None:
        """restore_venv should not crash if legacy dirs don't exist."""
        patch_venv(fake_venv, "h100")

        # Should not raise.
        restore_venv(fake_venv)


# ---------------------------------------------------------------------------
# Shim relative path correctness
# ---------------------------------------------------------------------------


class TestShimRelativePaths:
    """Shims in bin/ must reference python.specter-original correctly."""

    def test_shim_references_python_in_same_dir(self, bin_dir: Path) -> None:
        """Shim should use $(dirname $0)/python.specter-original."""
        _write_tool_shims(bin_dir, "/fake/lib")

        content = (bin_dir / "nvidia-smi").read_text()
        assert "python.specter-original" in content
        # Should NOT have ../ prefix — shim is in same dir as python
        assert "/../python" not in content

    def test_shim_virtual_env_goes_up_one_level(self, bin_dir: Path) -> None:
        """VIRTUAL_ENV should resolve to ../ from bin/."""
        _write_tool_shims(bin_dir, "/fake/lib")

        content = (bin_dir / "nvidia-smi").read_text()
        # From bin/, need to go up one level to reach venv root.
        assert '$(cd "$(dirname "$0")/.."' in content

    def test_shim_created_directly_in_bin(self, bin_dir: Path) -> None:
        """_write_tool_shims should create shims directly in bin/."""
        _write_tool_shims(bin_dir, "/fake/lib")

        assert (bin_dir / "nvidia-smi").exists()
        # Should NOT create .specter-tools/ subdirectory.
        assert not (bin_dir / ".specter-tools").exists()

    def test_idempotent(self, bin_dir: Path) -> None:
        """Calling _write_tool_shims twice should not crash."""
        _write_tool_shims(bin_dir, "/fake/lib")
        _write_tool_shims(bin_dir, "/fake/lib")

        assert (bin_dir / "nvidia-smi").exists()


# ---------------------------------------------------------------------------
# Full patch/restore cycle
# ---------------------------------------------------------------------------


class TestFullPatchRestoreCycle:
    """End-to-end: patch_venv creates shims in bin/, restore_venv cleans them."""

    def test_patch_creates_shim_in_bin(self, fake_venv: Path) -> None:
        """patch_venv should create nvidia-smi shim directly in bin/."""
        patch_venv(fake_venv, "h100")

        shim = fake_venv / "bin" / "nvidia-smi"
        assert shim.exists()
        assert shim.stat().st_mode & stat.S_IEXEC

    def test_patch_does_not_modify_activate(self, fake_venv: Path) -> None:
        """patch_venv should NOT inject PATH block into activate (shims are in bin/)."""
        original_text = (fake_venv / "bin" / "activate").read_text()
        patch_venv(fake_venv, "h100")

        text = (fake_venv / "bin" / "activate").read_text()
        assert _ACTIVATE_BEGIN not in text

    def test_restore_removes_shim(self, fake_venv: Path) -> None:
        """restore_venv should remove the nvidia-smi shim from bin/."""
        patch_venv(fake_venv, "h100")
        restore_venv(fake_venv)

        assert not (fake_venv / "bin" / "nvidia-smi").exists()

    def test_restore_cleans_legacy_activate_patch(self, fake_venv: Path) -> None:
        """restore_venv should clean up legacy activate patches from older versions."""
        # Simulate a legacy activate patch (from pre-0.2.4 installs).
        _patch_activate(fake_venv / "bin")
        text = (fake_venv / "bin" / "activate").read_text()
        assert _ACTIVATE_BEGIN in text

        patch_venv(fake_venv, "h100")
        restore_venv(fake_venv)

        text = (fake_venv / "bin" / "activate").read_text()
        assert _ACTIVATE_BEGIN not in text

    def test_re_patch_after_restore(self, fake_venv: Path) -> None:
        """patch → restore → patch should result in clean single patch."""
        patch_venv(fake_venv, "h100")
        restore_venv(fake_venv)
        patch_venv(fake_venv, "a100")

        assert (fake_venv / "bin" / "nvidia-smi").exists()

    def test_re_patch_without_restore_is_clean(self, fake_venv: Path) -> None:
        """Double patch (auto-restore) should produce clean state."""
        patch_venv(fake_venv, "h100")
        patch_venv(fake_venv, "a100")  # triggers auto-restore

        assert (fake_venv / "bin" / "nvidia-smi").exists()

    def test_full_structure_after_patch(self, fake_venv: Path) -> None:
        """Verify complete directory structure after patch_venv."""
        patch_venv(fake_venv, "h100")

        # All forwarded tools have shims directly in bin/.
        for tool in _FORWARDED_TOOLS:
            shim = fake_venv / "bin" / tool
            assert shim.exists()
            assert shim.stat().st_mode & stat.S_IEXEC

        # No .specter-tools/ subdirectory created.
        assert not (fake_venv / "bin" / ".specter-tools").exists()


# ---------------------------------------------------------------------------
# FileExistsError regression — bin/specter file vs directory collision
# ---------------------------------------------------------------------------


class TestFileExistsErrorRegression:
    """Regression tests for the bin/specter entry point vs shim dir collision.

    pip installs a ``bin/specter`` **file** (entry point from
    ``[project.scripts]``). The old ``_TOOL_SHIM_DIR = "specter"`` tried
    to create ``bin/specter`` as a **directory**, causing FileExistsError.
    Fixed by placing shims directly in bin/ (no subdirectory).
    """

    def test_entry_point_not_affected_by_shims(self, fake_venv: Path) -> None:
        """bin/specter (entry point file) should survive patch_venv."""
        # Simulate pip-installed entry point at bin/specter.
        entry_point = fake_venv / "bin" / "specter"
        entry_point.write_text("#!/usr/bin/env python3\nfrom specter_cli.main import cli\ncli()\n")
        entry_point.chmod(0o755)

        patch_venv(fake_venv, "h100")

        # Entry point should still be a file (not replaced by a directory).
        assert entry_point.is_file(), "bin/specter entry point should remain a file"
        content = entry_point.read_text()
        assert "specter_cli.main" in content, "Entry point content should be unchanged"

        # nvidia-smi shim should be in bin/ directly.
        assert (fake_venv / "bin" / "nvidia-smi").exists()

    def test_restore_cleans_up_legacy_specter_directory(self, fake_venv: Path) -> None:
        """restore_venv should remove old bin/specter/ dir from pre-0.2.2 installs."""
        patch_venv(fake_venv, "h100")

        # Simulate leftover legacy shim directory from pre-0.2.2.
        legacy_dir = fake_venv / "bin" / "specter"
        legacy_dir.mkdir(exist_ok=True)
        (legacy_dir / "nvidia-smi").write_text("#!/bin/bash\nlegacy\n")

        restore_venv(fake_venv)

        assert not legacy_dir.exists(), "Legacy bin/specter/ dir should be cleaned up"

    def test_entry_point_survives_full_patch_restore_cycle(self, fake_venv: Path) -> None:
        """bin/specter entry point should survive patch → restore cycle."""
        entry_point = fake_venv / "bin" / "specter"
        entry_point.write_text("#!/usr/bin/env python3\nfrom specter_cli.main import cli\ncli()\n")
        entry_point.chmod(0o755)

        patch_venv(fake_venv, "h100")
        restore_venv(fake_venv)

        assert entry_point.is_file(), "Entry point should survive patch/restore"
        assert "specter_cli.main" in entry_point.read_text()

    def test_upgrade_from_old_specter_dir(self, fake_venv: Path) -> None:
        """Simulate upgrade: old bin/specter/ dir + re-patch with new code."""
        # First patch creates shims in bin/ directly (new code).
        patch_venv(fake_venv, "h100")

        # Simulate leftover from old install (bin/specter/ directory).
        legacy_dir = fake_venv / "bin" / "specter"
        legacy_dir.mkdir(exist_ok=True)
        (legacy_dir / "nvidia-smi").write_text("#!/bin/bash\nold\n")

        # Restore should clean up both.
        restore_venv(fake_venv)

        assert not legacy_dir.exists()

        # Re-patch should work cleanly.
        patch_venv(fake_venv, "a100")
        assert (fake_venv / "bin" / "nvidia-smi").exists()


# ---------------------------------------------------------------------------
# Dangling symlink regression
# ---------------------------------------------------------------------------


class TestDanglingSymlinkRegression:
    """Regression tests for dangling symlink handling in patch_venv.

    When python.specter-original is a dangling symlink (target removed
    from a previous session), .exists() returns False but the symlink
    node still exists. _backup() calling symlink_to() then throws
    FileExistsError.
    """

    def test_dangling_python_backup_does_not_crash(self, fake_venv: Path) -> None:
        """patch_venv should handle a dangling python.specter-original symlink."""
        bin_dir = fake_venv / "bin"
        backup = bin_dir / "python.specter-original"

        # Create a dangling symlink (target doesn't exist).
        backup.symlink_to("/nonexistent/python3.99")
        assert backup.is_symlink()
        assert not backup.exists()  # dangling

        # Should not raise FileExistsError.
        patch_venv(fake_venv, "h100")

        # Wrapper should be written.
        wrapper = (bin_dir / "python").read_text()
        assert "specter" in wrapper.lower()

    def test_dangling_python3_backup_does_not_crash(self, fake_venv: Path) -> None:
        """patch_venv should handle a dangling python3.specter-original symlink."""
        bin_dir = fake_venv / "bin"
        backup = bin_dir / "python3.specter-original"

        backup.symlink_to("/nonexistent/python3.99")
        assert backup.is_symlink()
        assert not backup.exists()

        patch_venv(fake_venv, "h100")

        # python3 should be symlinked to python (the wrapper).
        assert (bin_dir / "python3").is_symlink()

    def test_dangling_versioned_backup_does_not_crash(self, fake_venv: Path) -> None:
        """patch_venv should handle a dangling python3.12.specter-original symlink."""
        bin_dir = fake_venv / "bin"

        # Create a versioned binary and its dangling backup.
        versioned = bin_dir / "python3.12"
        versioned.symlink_to("python")
        versioned_backup = bin_dir / "python3.12.specter-original"
        versioned_backup.symlink_to("/nonexistent/python3.12")
        assert versioned_backup.is_symlink()
        assert not versioned_backup.exists()

        patch_venv(fake_venv, "h100")

        # Versioned should now point to wrapper.
        assert (bin_dir / "python3.12").is_symlink()

    def test_repatch_after_target_removed(self, fake_venv: Path) -> None:
        """Simulate: patch, manually remove target binary, re-patch."""
        bin_dir = fake_venv / "bin"

        # First patch.
        patch_venv(fake_venv, "h100")

        # Simulate: user removed the original python binary target.
        backup = bin_dir / "python.specter-original"
        if backup.exists():
            backup.unlink()
        # Create a dangling backup symlink.
        backup.symlink_to("/removed/python3.12")

        # Write a fresh python binary (simulates venv recreation).
        python_bin = bin_dir / "python"
        if python_bin.exists() or python_bin.is_symlink():
            python_bin.unlink()
        python_bin.write_text("#!/bin/bash\necho fresh python\n")
        python_bin.chmod(0o755)

        # Remove marker so patch_venv doesn't auto-restore.
        marker = fake_venv / ".specter.json"
        if marker.exists():
            marker.unlink()

        # Re-patch should work without FileExistsError.
        patch_venv(fake_venv, "a100")

        wrapper = (bin_dir / "python").read_text()
        assert "specter" in wrapper.lower()
