"""Tests for the Jujutsu (jj) VCS backend."""

from __future__ import annotations

import os
from pathlib import Path
from typing import Any
from unittest.mock import patch

import pytest
from vcs_versioning import Configuration
from vcs_versioning._backends import _jj as jj
from vcs_versioning._backends._discover_vcs import discover
from vcs_versioning._file_finders._jj import jj_find_files
from vcs_versioning._run_cmd import CommandNotFoundError, CompletedProcess, run
from vcs_versioning.test_api import DebugMode, WorkDir


@pytest.fixture
def wd(wd: WorkDir, debug_mode: DebugMode) -> WorkDir:
    """Set up jj for jj-specific tests."""
    debug_mode.disable()
    wd.setup_jj()
    debug_mode.enable()
    return wd


def test_jj_gone(wd: WorkDir, monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.setenv("PATH", str(wd.cwd / "not-existing"))
    with pytest.raises(CommandNotFoundError, match=r"jj"):
        jj.parse(wd.cwd, Configuration())


def test_version_from_jj(wd: WorkDir) -> None:
    # Initial empty repo -- no commits, no files, jj considers it clean
    # but we force dirty=True when node is None (no meaningful commit)
    wd.expect_parse(tag="0.0", distance=0, dirty=True)

    wd.commit_testfile()
    wd.expect_parse(tag="0.0", distance=1, dirty=False, node_prefix="j")

    # Tag the commit (after jj commit, the tagged commit is @-)
    wd("jj tag set v0.1 -r @-")
    wd.expect_parse(tag="0.1", distance=0, dirty=False, exact=True)

    # Dirty state -- write without committing.
    # In jj, the working copy IS a commit.  When it has changes, jj
    # snapshots them into @, so distance=1 (one non-empty commit ahead).
    wd.write("test.txt", "test2")
    wd.expect_parse(tag="0.1", distance=1, dirty=True)

    # Commit the change and tag
    wd.commit_testfile()
    wd("jj tag set version-0.2 -r @-")
    wd.expect_parse(tag="0.2", distance=0, dirty=False, exact=True)


def test_jj_no_tags(wd: WorkDir) -> None:
    wd.commit_testfile()
    wd.commit_testfile()
    wd.commit_testfile()

    version = jj.parse(wd.cwd, Configuration())
    assert version is not None
    assert version.distance >= 3
    assert str(version.tag) == "0.0"


def test_jj_exact_tag(wd: WorkDir) -> None:
    wd.commit_testfile()
    wd("jj tag set v1.0.0 -r @-")

    version = jj.parse(wd.cwd, Configuration())
    assert version is not None
    assert version.distance == 0
    assert str(version.tag) == "1.0.0"


def test_jj_dirty_state(wd: WorkDir) -> None:
    wd.commit_testfile()
    wd("jj tag set v1.0.0 -r @-")

    # Clean state
    version = jj.parse(wd.cwd, Configuration())
    assert version is not None
    assert not version.dirty

    # Dirty state -- write a file (jj auto-tracks it)
    wd.write("dirty.txt", "dirty content")
    version = jj.parse(wd.cwd, Configuration())
    assert version is not None
    assert version.dirty


def test_jj_distance(wd: WorkDir) -> None:
    wd.commit_testfile()
    wd("jj tag set v1.0.0 -r @-")

    wd.commit_testfile()
    wd.commit_testfile()

    version = jj.parse(wd.cwd, Configuration())
    assert version is not None
    assert version.distance == 2
    assert str(version.tag) == "1.0.0"


@pytest.mark.issue(1477)
def test_jj_distance_counts_merges_like_git(wd: WorkDir) -> None:
    """Merges are empty in jj terms but git counts them -- so do we."""
    wd.commit_testfile()
    wd("jj tag set v1.0.0 -r @-")
    wd.commit_testfile()
    main = wd("jj log --no-graph -r @- -T commit_id")

    wd("jj new v1.0.0 -m side")
    wd.write("side.txt", "side")
    wd.add_and_commit("side")
    side = wd("jj log --no-graph -r @- -T commit_id")

    wd(f"jj new {main} {side} -m merge")
    wd("jj commit -m merged")

    version = jj.parse(wd.cwd, Configuration())
    assert version is not None
    assert version.distance == int(wd("git rev-list --count v1.0.0..HEAD"))
    assert version.node == "j" + wd("git rev-parse HEAD")[:12]


@pytest.mark.issue(1477)
def test_jj_avoids_scanning_the_whole_history(
    wd: WorkDir, monkeypatch: pytest.MonkeyPatch
) -> None:
    """``empty()`` costs one commit diff per candidate -- keep it bounded.

    An unbounded ``empty()`` filter turns version inference into a
    minutes-long operation on large repositories.
    """
    wd.commit_testfile()
    wd("jj tag set v1.0.0 -r @-")
    wd.commit_testfile()

    commands: list[list[str]] = []

    def record(cmd: list[str], cwd: Path, **kwargs: Any) -> CompletedProcess:
        commands.append([str(part) for part in cmd])
        return run(cmd, cwd, **kwargs)

    monkeypatch.setattr("vcs_versioning._backends._jj._run", record)
    assert jj.parse(wd.cwd, Configuration()) is not None

    revsets = [
        cmd[index + 1]
        for cmd in commands
        for index, arg in enumerate(cmd)
        if arg == "-r"
    ]
    assert revsets
    bound = f"ancestors(@, {jj.ANCESTOR_SCAN_LIMIT})"
    assert all("empty()" not in revset or bound in revset for revset in revsets)

    # the working copy is snapshotted once, the history queries reuse it
    snapshotting = [cmd for cmd in commands if "--ignore-working-copy" not in cmd]
    assert len(snapshotting) == 1


def test_jj_branch_detection(wd: WorkDir) -> None:
    wd.commit_testfile()
    wd("jj bookmark create test-branch -r @-")

    version = jj.parse(wd.cwd, Configuration())
    assert version is not None
    # The bookmark is on @-, not @, so branch may or may not be set
    # depending on jj's model. Just verify the call doesn't fail.
    assert version.branch is None or isinstance(version.branch, str)


def test_jj_node_prefix(wd: WorkDir) -> None:
    """jj nodes should be prefixed with 'j' to distinguish from git's 'g'."""
    wd.commit_testfile()
    version = jj.parse(wd.cwd, Configuration())
    assert version is not None
    assert version.node is not None
    assert version.node.startswith("j")


def test_jj_file_finder(wd: WorkDir) -> None:
    wd.commit_testfile()
    files = jj_find_files(str(wd.cwd))
    assert len(files) > 0
    basenames = [os.path.basename(f) for f in files]
    assert "test.txt" in basenames


def test_jj_file_finder_no_repo(tmp_path: Path) -> None:
    files = jj_find_files(str(tmp_path))
    assert files == []


def test_jj_missing_binary_errors(tmp_path: Path) -> None:
    """When .jj/ exists but jj is not on PATH, discovery should error."""
    (tmp_path / ".jj").mkdir()
    config = Configuration(root=tmp_path)

    with patch(
        "vcs_versioning._backends._discover_vcs.has_command", return_value=False
    ), pytest.raises(LookupError, match="jj.*not available"):
        discover(tmp_path, config=config)


def test_jj_colocated_prefers_jj(wd: WorkDir) -> None:
    """In a colocated repo (.jj + .git), jj should be preferred."""
    wd.commit_testfile()
    wd("jj tag set v2.0.0 -r @-")

    resolved = wd.cwd.resolve()
    assert (resolved / ".jj").is_dir()
    assert (resolved / ".git").exists()

    config = Configuration(root=resolved)
    result = discover(resolved, config=config)

    assert result is not None
    assert type(result).__name__ == "JjWorkdir"


def test_jj_disable_jj_falls_back_to_git(wd: WorkDir) -> None:
    """DISABLE_JJ=1 should skip jj and fall back to git in colocated repos."""
    from vcs_versioning._environment import VcsEnvironment

    wd.commit_testfile()

    resolved = wd.cwd.resolve()
    assert (resolved / ".jj").is_dir()
    assert (resolved / ".git").exists()

    env = VcsEnvironment(disable_jj=True)
    config = env.build_config(root=resolved)
    result = discover(resolved, config=config)

    assert result is not None
    assert type(result).__name__ == "GitWorkdir"
