"""Tests for repo_sync.py — T04 Push Seal.

Verifies the push-seal invariant and all four git lifecycle primitives.

The invariant under test
------------------------
Every fix_repo() run that creates commits must push before returning, so the
next run starts with ``local ≡ remote``.  Three observable properties flow
from this:

  1. After a successful push, count_local_ahead returns 0.
  2. Before any local commits, count_local_ahead returns 0.
  3. When a push fails (diverged), C-17 skips further writes so local does
     not accumulate additional commits on top of the backlog.

Test anatomy
------------
All tests use real git repos via tmp_path — no mocks, no subprocess patches.
The ``git_pair`` fixture creates a bare remote + one clone wired with an
initial pushed commit, matching the setup used in production repos.

A ``second_clone`` helper creates a second independent worker clone for
scenarios that require concurrent pushes (divergence simulation).
"""
from __future__ import annotations

import subprocess
import sys
from pathlib import Path

import pytest

sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))

from repo_sync import count_local_ahead, count_remote_ahead, pull_ff, push_ff


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _git(args: list[str], cwd: str | None = None) -> str:
    """Run a git command, return stdout. Raises on non-zero exit."""
    cmd = ["git"] + args
    if cwd:
        cmd = ["git", "-C", cwd] + args[1:] if args[0] == "-C" else ["git", "-C", cwd] + args
    result = subprocess.run(cmd, capture_output=True, text=True)
    return result.stdout.strip()


def _commit(repo: Path, message: str = "auto") -> None:
    """Make an empty commit in *repo*."""
    subprocess.run(
        ["git", "-C", str(repo), "commit", "--allow-empty", "-m", message],
        capture_output=True, check=True,
    )


def _push(repo: Path) -> None:
    subprocess.run(
        ["git", "-C", str(repo), "push"],
        capture_output=True, check=True,
    )


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------

@pytest.fixture
def git_pair(tmp_path):
    """Bare remote + one clone with an initial pushed commit.

    Returns (bare: Path, clone: Path).
    """
    bare = tmp_path / "bare.git"
    clone = tmp_path / "clone"
    bare.mkdir()
    subprocess.run(["git", "init", "--bare", str(bare)], capture_output=True, check=True)
    subprocess.run(["git", "clone", str(bare), str(clone)], capture_output=True, check=True)
    subprocess.run(
        ["git", "-C", str(clone), "config", "user.email", "test@test.com"],
        capture_output=True, check=True,
    )
    subprocess.run(
        ["git", "-C", str(clone), "config", "user.name", "Test"],
        capture_output=True, check=True,
    )
    _commit(clone, "init")
    _push(clone)
    return bare, clone


def _make_second_clone(bare: Path, tmp_path: Path) -> Path:
    """Clone bare into a second independent working directory."""
    worker = tmp_path / "worker"
    subprocess.run(["git", "clone", str(bare), str(worker)], capture_output=True, check=True)
    subprocess.run(
        ["git", "-C", str(worker), "config", "user.email", "worker@test.com"],
        capture_output=True, check=True,
    )
    subprocess.run(
        ["git", "-C", str(worker), "config", "user.name", "Worker"],
        capture_output=True, check=True,
    )
    return worker


# ---------------------------------------------------------------------------
# Error resilience — all functions must never raise
# ---------------------------------------------------------------------------

class TestErrorResilience:
    """All four functions must return safe defaults for bad inputs."""

    def test_count_local_ahead_nonexistent_path(self):
        assert count_local_ahead("/nonexistent/xyz") == 0

    def test_count_remote_ahead_nonexistent_path(self):
        assert count_remote_ahead("/nonexistent/xyz") == 0

    def test_pull_ff_nonexistent_path(self):
        ok, msg = pull_ff("/nonexistent/xyz")
        assert ok is False
        assert msg  # some error message

    def test_push_ff_nonexistent_path(self):
        ok, msg = push_ff("/nonexistent/xyz")
        assert ok is False
        assert msg

    def test_count_local_ahead_non_git_dir(self, tmp_path):
        assert count_local_ahead(str(tmp_path)) == 0

    def test_count_remote_ahead_non_git_dir(self, tmp_path):
        assert count_remote_ahead(str(tmp_path)) == 0

    def test_count_local_ahead_no_upstream(self, tmp_path):
        """Local-only repo with no remote → no upstream ref → returns 0."""
        subprocess.run(["git", "init", str(tmp_path)], capture_output=True, check=True)
        subprocess.run(
            ["git", "-C", str(tmp_path), "config", "user.email", "t@t.com"],
            capture_output=True,
        )
        subprocess.run(
            ["git", "-C", str(tmp_path), "config", "user.name", "T"],
            capture_output=True,
        )
        _commit(tmp_path, "local only")
        assert count_local_ahead(str(tmp_path)) == 0

    def test_count_remote_ahead_no_upstream(self, tmp_path):
        subprocess.run(["git", "init", str(tmp_path)], capture_output=True, check=True)
        assert count_remote_ahead(str(tmp_path)) == 0


# ---------------------------------------------------------------------------
# count_local_ahead — ahead count
# ---------------------------------------------------------------------------

class TestCountLocalAhead:
    """count_local_ahead measures unpushed local commits."""

    def test_zero_when_in_sync(self, git_pair):
        _, clone = git_pair
        assert count_local_ahead(str(clone)) == 0

    def test_one_after_one_local_commit(self, git_pair):
        _, clone = git_pair
        _commit(clone, "local change")
        assert count_local_ahead(str(clone)) == 1

    def test_three_after_three_local_commits(self, git_pair):
        _, clone = git_pair
        for i in range(3):
            _commit(clone, f"local {i}")
        assert count_local_ahead(str(clone)) == 3

    def test_zero_after_push(self, git_pair):
        _, clone = git_pair
        _commit(clone, "to push")
        assert count_local_ahead(str(clone)) == 1
        _push(clone)
        assert count_local_ahead(str(clone)) == 0


# ---------------------------------------------------------------------------
# count_remote_ahead — behind count
# ---------------------------------------------------------------------------

class TestCountRemoteAhead:
    """count_remote_ahead measures how far behind local is from remote."""

    def test_zero_when_in_sync(self, git_pair):
        _, clone = git_pair
        assert count_remote_ahead(str(clone)) == 0

    def test_zero_when_local_is_ahead(self, git_pair):
        """Local having unpushed commits does NOT count as being behind."""
        _, clone = git_pair
        _commit(clone, "local only")
        assert count_remote_ahead(str(clone)) == 0

    def test_one_when_remote_has_one_new_commit(self, git_pair, tmp_path):
        bare, clone = git_pair
        worker = _make_second_clone(bare, tmp_path)
        _commit(worker, "remote work")
        _push(worker)
        assert count_remote_ahead(str(clone)) == 1

    def test_three_when_remote_has_three_new_commits(self, git_pair, tmp_path):
        bare, clone = git_pair
        worker = _make_second_clone(bare, tmp_path)
        for i in range(3):
            _commit(worker, f"remote {i}")
        _push(worker)
        assert count_remote_ahead(str(clone)) == 3

    def test_nonzero_when_diverged(self, git_pair, tmp_path):
        """Diverged repo: remote has commits local lacks — counted as behind."""
        bare, clone = git_pair
        worker = _make_second_clone(bare, tmp_path)
        _commit(worker, "remote fork")
        _push(worker)
        _commit(clone, "local fork")  # clone diverges
        assert count_remote_ahead(str(clone)) > 0


# ---------------------------------------------------------------------------
# push_ff — T04 push seal
# ---------------------------------------------------------------------------

class TestPushFf:
    """push_ff enforces the push-seal invariant."""

    def test_succeeds_when_local_is_ahead(self, git_pair):
        _, clone = git_pair
        _commit(clone, "to seal")
        ok, msg = push_ff(str(clone))
        assert ok is True
        assert msg

    def test_succeeds_when_already_in_sync(self, git_pair):
        """Push on an already-in-sync repo is a no-op and still returns True."""
        _, clone = git_pair
        ok, msg = push_ff(str(clone))
        assert ok is True

    def test_push_seal_invariant(self, git_pair):
        """After a successful push, count_local_ahead must be 0."""
        _, clone = git_pair
        for i in range(3):
            _commit(clone, f"writeback {i}")
        assert count_local_ahead(str(clone)) == 3

        ok, _ = push_ff(str(clone))
        assert ok is True
        assert count_local_ahead(str(clone)) == 0  # invariant holds

    def test_push_seal_idempotent(self, git_pair):
        """Calling push_ff twice produces the same end state."""
        _, clone = git_pair
        _commit(clone, "once")
        push_ff(str(clone))
        ok, _ = push_ff(str(clone))
        assert ok is True
        assert count_local_ahead(str(clone)) == 0

    def test_fails_when_diverged(self, git_pair, tmp_path):
        """push_ff fails (non-fast-forward) when remote and local have forked."""
        bare, clone = git_pair
        worker = _make_second_clone(bare, tmp_path)
        _commit(worker, "remote fork")
        _push(worker)
        _commit(clone, "local fork")  # clone diverges
        ok, msg = push_ff(str(clone))
        assert ok is False
        assert msg  # git explains the rejection

    def test_c17_scenario(self, git_pair, tmp_path):
        """C-17: local has unpushed commits from a prior run; push fails (diverged).

        The guard detects count_local_ahead > 0, tries push_ff, gets False,
        and must skip all further writes for this run.
        """
        bare, clone = git_pair
        worker = _make_second_clone(bare, tmp_path)
        # Simulate prior run: made commits but never pushed
        _commit(clone, "prior auto-commit 1")
        _commit(clone, "prior auto-commit 2")
        # Meanwhile remote moved on
        _commit(worker, "remote progress")
        _push(worker)
        # Now clone is diverged: 2 ahead, 1 behind
        ahead = count_local_ahead(str(clone))
        behind = count_remote_ahead(str(clone))
        assert ahead == 2
        assert behind == 1
        # Push attempt fails (non-ff)
        ok, _ = push_ff(str(clone))
        assert ok is False
        # End state: still diverged (guard did not make it worse)
        assert count_local_ahead(str(clone)) == 2


# ---------------------------------------------------------------------------
# pull_ff — T02 pull gate
# ---------------------------------------------------------------------------

class TestPullFf:
    """pull_ff enables the T02 pull gate: pull before fixing when behind."""

    def test_succeeds_when_already_up_to_date(self, git_pair):
        _, clone = git_pair
        ok, msg = pull_ff(str(clone))
        assert ok is True

    def test_pulls_new_remote_commits(self, git_pair, tmp_path):
        bare, clone = git_pair
        worker = _make_second_clone(bare, tmp_path)
        _commit(worker, "remote work")
        _push(worker)
        ok, _ = pull_ff(str(clone))
        assert ok is True
        log = subprocess.run(
            ["git", "-C", str(clone), "log", "--oneline", "-1"],
            capture_output=True, text=True,
        ).stdout
        assert "remote work" in log

    def test_pull_resolves_behind_state(self, git_pair, tmp_path):
        """After pull_ff, count_remote_ahead should drop to 0."""
        bare, clone = git_pair
        worker = _make_second_clone(bare, tmp_path)
        _commit(worker, "new remote")
        _push(worker)
        assert count_remote_ahead(str(clone)) == 1
        pull_ff(str(clone))
        assert count_remote_ahead(str(clone)) == 0

    def test_fails_when_diverged(self, git_pair, tmp_path):
        """pull_ff (ff-only) fails when local and remote have forked."""
        bare, clone = git_pair
        worker = _make_second_clone(bare, tmp_path)
        _commit(worker, "remote fork")
        _push(worker)
        _commit(clone, "local fork")
        ok, msg = pull_ff(str(clone))
        assert ok is False
        assert msg

    def test_returns_false_no_remote(self, tmp_path):
        """Local-only repo with no remote configured."""
        subprocess.run(["git", "init", str(tmp_path)], capture_output=True, check=True)
        subprocess.run(
            ["git", "-C", str(tmp_path), "config", "user.email", "t@t.com"],
            capture_output=True,
        )
        subprocess.run(
            ["git", "-C", str(tmp_path), "config", "user.name", "T"],
            capture_output=True,
        )
        _commit(tmp_path)
        ok, _ = pull_ff(str(tmp_path))
        assert ok is False


# ---------------------------------------------------------------------------
# End-to-end: push seal closes the loop
# ---------------------------------------------------------------------------

class TestPushSealLoop:
    """Integration tests verifying the full timer-loop stability property.

    These mirror the exact sequence the custodian-sync.service runs:
      1. Detect state (count_remote_ahead, count_local_ahead)
      2. Pull if behind
      3. Make writeback commits
      4. Push (seal)
      5. Next run sees local ≡ remote → skipped clean
    """

    def test_full_cycle_leaves_repo_clean(self, git_pair):
        """Simulate one complete fix run: commit + push → next check is clean."""
        _, clone = git_pair
        # Simulate writeback commits made during fix_repo()
        _commit(clone, "chore(consistency): sync task status from DB [auto]")
        assert count_local_ahead(str(clone)) == 1

        # Push seal
        ok, _ = push_ff(str(clone))
        assert ok is True

        # Next timer run: no behind, no ahead → clean
        assert count_remote_ahead(str(clone)) == 0
        assert count_local_ahead(str(clone)) == 0

    def test_multiple_writebacks_sealed_in_one_push(self, git_pair):
        """Multiple commits in one run are all sealed by a single push."""
        _, clone = git_pair
        for i in range(5):
            _commit(clone, f"writeback {i}")
        assert count_local_ahead(str(clone)) == 5
        push_ff(str(clone))
        assert count_local_ahead(str(clone)) == 0
        assert count_remote_ahead(str(clone)) == 0

    def test_no_commit_means_no_push_needed(self, git_pair):
        """If fix_repo() made no commits, push is a no-op and state is unchanged."""
        _, clone = git_pair
        assert count_local_ahead(str(clone)) == 0
        ok, _ = push_ff(str(clone))
        assert ok is True
        assert count_local_ahead(str(clone)) == 0

    def test_runaway_prevention_on_failed_push(self, git_pair, tmp_path):
        """Diverged repo: C-17 fires, skips writes, push backlog does not grow."""
        bare, clone = git_pair
        worker = _make_second_clone(bare, tmp_path)

        # Prior failed push left two commits locally
        _commit(clone, "auto 1")
        _commit(clone, "auto 2")
        # Remote progressed independently
        _commit(worker, "remote progress")
        _push(worker)

        ahead_before = count_local_ahead(str(clone))

        # C-17 guard: try to push backlog, fail
        ok, _ = push_ff(str(clone))
        assert ok is False

        # Guard must not have made things worse
        assert count_local_ahead(str(clone)) == ahead_before
