"""Extended tests for git operations: version checks, stale branches, pull strategies,
ref validation, bounded parallelism, and remote parsing."""

from __future__ import annotations

import asyncio
import os
import subprocess
from pathlib import Path
from unittest.mock import AsyncMock, patch

import pytest

from project_hub.core.discovery import _parse_remote
from project_hub.git.ops import (
    GitVersionError,
    _validate_ref,
    check_git_version,
    fetch_all,
    git_status,
    pull_safe_all,
    pull_safe_one,
)
from project_hub.core.models import PullStatus


_GIT_ENV = {
    **os.environ,
    "GIT_AUTHOR_NAME": "Test",
    "GIT_AUTHOR_EMAIL": "t@t.com",
    "GIT_COMMITTER_NAME": "Test",
    "GIT_COMMITTER_EMAIL": "t@t.com",
}


def _git(repo: Path, *args: str) -> subprocess.CompletedProcess:
    return subprocess.run(
        ["git", *args],
        cwd=repo,
        capture_output=True,
        text=True,
        env=_GIT_ENV,
    )


def _init_repo(path: Path, name: str = "test-repo") -> Path:
    """Create a minimal git repo with one commit."""
    repo = path / name
    repo.mkdir(parents=True, exist_ok=True)
    _git(repo, "init")
    _git(repo, "config", "user.email", "t@t.com")
    _git(repo, "config", "user.name", "Test")
    (repo / "README.md").write_text("init")
    _git(repo, "add", ".")
    _git(repo, "commit", "-m", "init")
    return repo


# ---------------------------------------------------------------------------
# 1. check_git_version failure paths
# ---------------------------------------------------------------------------

def test_check_git_version_old_version():
    """Old git version (2.30.0) raises GitVersionError."""
    fake_result = subprocess.CompletedProcess(
        args=["git", "--version"],
        returncode=0,
        stdout="git version 2.30.0",
        stderr="",
    )
    with patch("project_hub.git.ops.subprocess.run", return_value=fake_result):
        with pytest.raises(GitVersionError, match="2.30 is too old"):
            check_git_version()


def test_check_git_version_unparseable():
    """Unparseable git --version output raises GitVersionError."""
    fake_result = subprocess.CompletedProcess(
        args=["git", "--version"],
        returncode=0,
        stdout="not a version string",
        stderr="",
    )
    with patch("project_hub.git.ops.subprocess.run", return_value=fake_result):
        with pytest.raises(GitVersionError, match="Could not parse git version"):
            check_git_version()


# ---------------------------------------------------------------------------
# 2. git_status — stale branch detection
# ---------------------------------------------------------------------------

def test_git_status_stale_branch(tmp_path: Path):
    """Branch tracking a deleted remote ref → stale=True."""
    # Create bare "remote" and clone it
    bare = tmp_path / "bare.git"
    bare.mkdir()
    _git(bare, "init", "--bare")

    clone = tmp_path / "clone"
    subprocess.run(
        ["git", "clone", str(bare), str(clone)],
        capture_output=True, text=True, env=_GIT_ENV,
    )
    _git(clone, "config", "user.email", "t@t.com")
    _git(clone, "config", "user.name", "Test")

    # Create a feature branch on the remote
    (clone / "file.txt").write_text("hello")
    _git(clone, "add", ".")
    _git(clone, "commit", "-m", "first")
    _git(clone, "push", "-u", "origin", "HEAD:main")
    _git(clone, "checkout", "-b", "feature/stale")
    (clone / "feat.txt").write_text("feat")
    _git(clone, "add", ".")
    _git(clone, "commit", "-m", "feat")
    _git(clone, "push", "-u", "origin", "feature/stale")

    # Delete the remote branch, then fetch --prune
    _git(bare, "branch", "-D", "feature/stale")
    _git(clone, "fetch", "--prune")

    # Stay on the stale branch
    status = git_status(clone)
    assert status.branch == "feature/stale"
    assert status.stale is True


def test_git_status_not_stale_when_remote_exists(tmp_path: Path):
    """Branch with a live remote ref → stale=False."""
    bare = tmp_path / "bare.git"
    bare.mkdir()
    _git(bare, "init", "--bare")

    clone = tmp_path / "clone"
    subprocess.run(
        ["git", "clone", str(bare), str(clone)],
        capture_output=True, text=True, env=_GIT_ENV,
    )
    _git(clone, "config", "user.email", "t@t.com")
    _git(clone, "config", "user.name", "Test")
    (clone / "file.txt").write_text("hello")
    _git(clone, "add", ".")
    _git(clone, "commit", "-m", "first")
    _git(clone, "push", "-u", "origin", "HEAD:main")
    _git(clone, "checkout", "main")

    status = git_status(clone)
    assert status.branch == "main"
    assert status.stale is False


# ---------------------------------------------------------------------------
# 3. pull_safe_one — strategy parameter
# ---------------------------------------------------------------------------

def _setup_behind_repo(tmp_path: Path, clone_name: str = "clone") -> Path:
    """Create a bare repo + clone where clone is 1 commit behind origin."""
    bare = tmp_path / "bare.git"
    bare.mkdir()
    _git(bare, "init", "--bare", "-b", "main")

    clone = tmp_path / clone_name
    subprocess.run(
        ["git", "clone", str(bare), str(clone)],
        capture_output=True, text=True, env=_GIT_ENV,
    )
    _git(clone, "config", "user.email", "t@t.com")
    _git(clone, "config", "user.name", "Test")
    (clone / "file.txt").write_text("hello")
    _git(clone, "add", ".")
    _git(clone, "commit", "-m", "first")
    _git(clone, "push", "-u", "origin", "main")

    # Push a second commit from a separate clone so origin is ahead
    clone2 = tmp_path / f"{clone_name}-pusher"
    subprocess.run(
        ["git", "clone", str(bare), str(clone2)],
        capture_output=True, text=True, env=_GIT_ENV,
    )
    _git(clone2, "config", "user.email", "t@t.com")
    _git(clone2, "config", "user.name", "Test")
    (clone2 / "extra.txt").write_text("from remote")
    _git(clone2, "add", ".")
    _git(clone2, "commit", "-m", "remote commit")
    _git(clone2, "push", "origin", "main")

    # Fetch in original clone so it knows it's behind
    _git(clone, "fetch", "--all")
    return clone


@pytest.mark.asyncio
async def test_pull_safe_strategy_rebase(tmp_path: Path):
    """strategy='rebase' passes --rebase to git pull."""
    clone = _setup_behind_repo(tmp_path)

    captured_args = []
    original_create = asyncio.create_subprocess_exec

    async def spy_create(*args, **kwargs):
        captured_args.append(list(args))
        return await original_create(*args, **kwargs)

    with patch("project_hub.git.ops.asyncio.create_subprocess_exec", side_effect=spy_create):
        await pull_safe_one(clone, strategy="rebase")

    pull_cmds = [a for a in captured_args if "pull" in a]
    assert len(pull_cmds) == 1
    assert "--rebase" in pull_cmds[0]
    assert "--no-rebase" not in pull_cmds[0]


@pytest.mark.asyncio
async def test_pull_safe_strategy_merge(tmp_path: Path):
    """strategy='merge' passes --no-rebase to git pull."""
    clone = _setup_behind_repo(tmp_path)

    captured_args = []
    original_create = asyncio.create_subprocess_exec

    async def spy_create(*args, **kwargs):
        captured_args.append(list(args))
        return await original_create(*args, **kwargs)

    with patch("project_hub.git.ops.asyncio.create_subprocess_exec", side_effect=spy_create):
        await pull_safe_one(clone, strategy="merge")

    pull_cmds = [a for a in captured_args if "pull" in a]
    assert len(pull_cmds) == 1
    assert "--no-rebase" in pull_cmds[0]
    assert "--rebase" not in pull_cmds[0]


# ---------------------------------------------------------------------------
# 4. pull_safe_one — SKIPPED_AHEAD_ONLY and SKIPPED_CONFLICT
# ---------------------------------------------------------------------------

@pytest.mark.asyncio
async def test_pull_safe_skipped_ahead_only(tmp_path: Path):
    """Repo with local-only commits (ahead, not behind) → SKIPPED_AHEAD_ONLY."""
    bare = tmp_path / "bare.git"
    bare.mkdir()
    _git(bare, "init", "--bare", "-b", "main")

    clone = tmp_path / "clone"
    subprocess.run(
        ["git", "clone", str(bare), str(clone)],
        capture_output=True, text=True, env=_GIT_ENV,
    )
    _git(clone, "config", "user.email", "t@t.com")
    _git(clone, "config", "user.name", "Test")
    (clone / "file.txt").write_text("hello")
    _git(clone, "add", ".")
    _git(clone, "commit", "-m", "first")
    _git(clone, "push", "-u", "origin", "main")

    # Add a local-only commit
    (clone / "local.txt").write_text("local change")
    _git(clone, "add", ".")
    _git(clone, "commit", "-m", "local only")

    result = await pull_safe_one(clone)
    assert result.status == PullStatus.SKIPPED_AHEAD_ONLY


@pytest.mark.asyncio
async def test_pull_safe_skipped_conflict(tmp_path: Path):
    """Repo with diverged branches and conflicting changes → SKIPPED_CONFLICT."""
    bare = tmp_path / "bare.git"
    bare.mkdir()
    _git(bare, "init", "--bare", "-b", "main")

    clone = tmp_path / "clone-conflict"
    subprocess.run(
        ["git", "clone", str(bare), str(clone)],
        capture_output=True, text=True, env=_GIT_ENV,
    )
    _git(clone, "config", "user.email", "t@t.com")
    _git(clone, "config", "user.name", "Test")
    (clone / "file.txt").write_text("hello")
    _git(clone, "add", ".")
    _git(clone, "commit", "-m", "first")
    _git(clone, "push", "-u", "origin", "main")

    # Push a conflicting change from a second clone
    clone2 = tmp_path / "clone-conflict-pusher"
    subprocess.run(
        ["git", "clone", str(bare), str(clone2)],
        capture_output=True, text=True, env=_GIT_ENV,
    )
    _git(clone2, "config", "user.email", "t@t.com")
    _git(clone2, "config", "user.name", "Test")
    (clone2 / "file.txt").write_text("remote change")
    _git(clone2, "add", ".")
    _git(clone2, "commit", "-m", "remote conflict")
    _git(clone2, "push", "origin", "main")

    # Make a conflicting local commit on the SAME file
    (clone / "file.txt").write_text("local conflicting change")
    _git(clone, "add", ".")
    _git(clone, "commit", "-m", "local conflict")

    # Fetch to know about remote changes (now clone is ahead=1, behind=1)
    _git(clone, "fetch", "--all")

    # Verify diverged state
    status = git_status(clone)
    assert status.ahead > 0
    assert status.behind > 0

    result = await pull_safe_one(clone)
    assert result.status == PullStatus.SKIPPED_CONFLICT


# ---------------------------------------------------------------------------
# 5. _validate_ref
# ---------------------------------------------------------------------------

def test_validate_ref_origin_main():
    """Valid ref 'origin/main' passes."""
    _validate_ref("origin/main")  # should not raise


def test_validate_ref_full_ref():
    """Valid ref 'refs/heads/feature/foo-bar' passes."""
    _validate_ref("refs/heads/feature/foo-bar")  # should not raise


def test_validate_ref_with_spaces():
    """Ref with spaces raises ValueError."""
    with pytest.raises(ValueError, match="Invalid git ref"):
        _validate_ref("origin/ main")


def test_validate_ref_with_semicolon():
    """Ref with shell metacharacter ';' raises ValueError."""
    with pytest.raises(ValueError, match="Invalid git ref"):
        _validate_ref("origin/main; rm -rf /")


def test_validate_ref_with_backtick():
    """Ref with backtick raises ValueError."""
    with pytest.raises(ValueError, match="Invalid git ref"):
        _validate_ref("`whoami`")


def test_validate_ref_with_pipe():
    """Ref with pipe raises ValueError."""
    with pytest.raises(ValueError, match="Invalid git ref"):
        _validate_ref("main|cat")


def test_validate_ref_with_dollar():
    """Ref with $ raises ValueError."""
    with pytest.raises(ValueError, match="Invalid git ref"):
        _validate_ref("$HOME")


# ---------------------------------------------------------------------------
# 6. Bounded parallelism
# ---------------------------------------------------------------------------

@pytest.mark.asyncio
async def test_fetch_all_bounded_concurrency():
    """fetch_all with 20+ repos never exceeds semaphore limit of 10 concurrent."""
    max_concurrent = 0
    current = 0
    lock = asyncio.Lock()

    async def mock_fetch_one(repo_path):
        nonlocal max_concurrent, current
        from project_hub.core.models import RepoResult
        async with lock:
            current += 1
            if current > max_concurrent:
                max_concurrent = current
        await asyncio.sleep(0.01)  # simulate work
        async with lock:
            current -= 1
        return RepoResult(repo=repo_path.name, status="ok")

    fake_paths = [Path(f"/fake/repo-{i}") for i in range(25)]

    with patch("project_hub.git.ops.fetch_one", side_effect=mock_fetch_one):
        results = await fetch_all(fake_paths)

    assert len(results) == 25
    assert max_concurrent <= 10


@pytest.mark.asyncio
async def test_pull_safe_all_bounded_concurrency():
    """pull_safe_all with 20+ repos never exceeds semaphore limit of 10 concurrent."""
    max_concurrent = 0
    current = 0
    lock = asyncio.Lock()
    sem = asyncio.Semaphore(10)

    async def mock_bounded_pull(repo_path, strategy="merge"):
        nonlocal max_concurrent, current
        from project_hub.core.models import RepoResult, PullStatus
        async with sem:
            async with lock:
                current += 1
                if current > max_concurrent:
                    max_concurrent = current
            await asyncio.sleep(0.01)
            async with lock:
                current -= 1
        return RepoResult(repo=repo_path.name, status=PullStatus.ALREADY_UP_TO_DATE)

    fake_paths = [Path(f"/fake/repo-{i}") for i in range(25)]

    with patch("project_hub.git.ops._bounded_pull", side_effect=mock_bounded_pull):
        results = await pull_safe_all(fake_paths)

    assert len(results) == 25
    assert max_concurrent <= 10


# ---------------------------------------------------------------------------
# 7. _parse_remote edge cases
# ---------------------------------------------------------------------------

def test_parse_remote_local_path():
    """Local path remote returns (None, None)."""
    host, path = _parse_remote("/tmp/repo.git")
    assert host is None
    assert path is None


def test_parse_remote_ssh():
    """SSH-style remote parsed correctly."""
    host, path = _parse_remote("git@gitlab.com:group/subgroup/repo.git")
    assert host == "gitlab.com"
    assert path == "group/subgroup/repo"


def test_parse_remote_ssh_no_dot_git():
    """SSH-style remote without .git suffix."""
    host, path = _parse_remote("git@gitlab.com:group/repo")
    assert host == "gitlab.com"
    assert path == "group/repo"


def test_parse_remote_https():
    """HTTPS remote parsed correctly."""
    host, path = _parse_remote("https://gitlab.com/group/repo.git")
    assert host == "gitlab.com"
    assert path == "group/repo"


def test_parse_remote_https_no_dot_git():
    """HTTPS remote without .git suffix."""
    host, path = _parse_remote("https://gitlab.com/group/repo")
    assert host == "gitlab.com"
    assert path == "group/repo"


def test_parse_remote_http():
    """HTTP remote parsed correctly (not just HTTPS)."""
    host, path = _parse_remote("http://gitlab.internal/team/project.git")
    assert host == "gitlab.internal"
    assert path == "team/project"


def test_parse_remote_file_protocol():
    """file:// protocol returns (None, None)."""
    host, path = _parse_remote("file:///tmp/repo.git")
    assert host is None
    assert path is None


def test_parse_remote_empty_string():
    """Empty string returns (None, None)."""
    host, path = _parse_remote("")
    assert host is None
    assert path is None
