#!/usr/bin/env python3
"""
clipcp — transfer files/folders via clipboard.

  Sender:    clipcp <file_or_folder> [--chunk-size KB] [--timeout S] [--skip PAT …]
  Receiver:  clipcp [--dest DIR] [--timeout S]
  Resume:    clipcp --resume <transfer_id>   (on either machine)
  Clear:     clipcp --clear

Both sides poll the clipboard and handshake automatically — no interaction needed.

─── PROTOCOL ────────────────────────────────────────────────────────────────────

Control messages (single-line):
  CLIPCP_READY|<tid>|<last_rx>   receiver → sender  (ready; last_rx = last received index)
  CLIPCP_ACK|<tid>|<index>       receiver → sender  (part N received OK)
  CLIPCP_DONE|<tid>              receiver → sender  (all parts received)
  CLIPCP_ERR|<tid>|<reason>      receiver → sender  (abort)

DATA chunk format (multi-line):
  ===CLIPCP_START|v<ver>|<tid>|<total>|<index>|<full_hash>|<name>|<ck>===
  <base64 payload>
  ===CLIPCP_END|<tid>|<index>|<ck>===

Resume protocol:
  Receiver signals READY with its last successfully received index.
  Sender resumes from that index + 1, loading chunks from disk.

State storage:  ~/.clipcp/transfers/<tid>/sender/  and  …/receiver/
"""

import os
import sys
import io
import json
import base64
import hashlib
import tarfile
import argparse
import shutil
import tempfile
import secrets
import subprocess
import time
from pathlib import Path
from getpass import getpass
from typing import TypedDict
from fnmatch import fnmatch

try:
    import pyperclip
except ImportError:
    print("ERROR: pyperclip not installed. Run: pip install pyperclip")
    sys.exit(1)

try:
    from cryptography.hazmat.primitives.ciphers.aead import AESGCM
    from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
    from cryptography.hazmat.primitives import hashes
except ImportError:
    print("ERROR: cryptography not installed. Run: pip install cryptography")
    sys.exit(1)

# ── Constants ──────────────────────────────────────────────────────────────────
VERSION            = 1
ENV_PASSWORD_VAR   = "CLIPCP_PASSWORD"
DEFAULT_CHUNK_KB   = 64
PBKDF2_ITERATIONS  = 260_000
SALT_SIZE          = 16
NONCE_SIZE         = 12
POLL_INTERVAL      = 0.25
DEFAULT_TIMEOUT    = 60

CLIPCP_DIR = Path.home() / ".clipcp" / "transfers"

HEADER_PREFIX = "===CLIPCP_START|"
HEADER_SUFFIX = "==="
FOOTER_PREFIX = "===CLIPCP_END|"
FOOTER_SUFFIX = "==="

CTL_READY   = "CLIPCP_READY"
CTL_ACK     = "CLIPCP_ACK"
CTL_DONE    = "CLIPCP_DONE"
CTL_ERR     = "CLIPCP_ERR"
CTL_GIT_SRV     = "CLIPCP_GIT_SRV"     # sender → receiver: server HEAD hash
CTL_GIT_CLI     = "CLIPCP_GIT_CLI"     # receiver → sender: client HEAD hash (implicit READY)
CTL_GIT_REVERSE    = "CLIPCP_GIT_REVERSE"    # server→client: please send bundle from base_hash
CTL_GIT_CONFIRM    = "CLIPCP_GIT_CONFIRM"    # client→server: confirmed, sending bundle
CTL_GIT_CANDIDATES = "CLIPCP_GIT_CANDIDATES" # server→client: list of recent server commit hashes
CTL_GIT_BASE       = "CLIPCP_GIT_BASE"       # client→server: agreed common base hash
CTL_JOB            = "CLIPCP_JOB"            # server→client: job announcement (type/name/chunks)


# ── Crypto ────────────────────────────────────────────────────────────────────

def derive_key(password: str, salt: bytes) -> bytes:
    kdf = PBKDF2HMAC(algorithm=hashes.SHA256(), length=32,
                     salt=salt, iterations=PBKDF2_ITERATIONS)
    return kdf.derive(password.encode())

def encrypt(data: bytes, password: str) -> bytes:
    salt, nonce = os.urandom(SALT_SIZE), os.urandom(NONCE_SIZE)
    ct = AESGCM(derive_key(password, salt)).encrypt(nonce, data, None)
    return salt + nonce + ct

def decrypt(data: bytes, password: str) -> bytes:
    salt, nonce, ct = data[:SALT_SIZE], data[SALT_SIZE:SALT_SIZE+NONCE_SIZE], data[SALT_SIZE+NONCE_SIZE:]
    return AESGCM(derive_key(password, salt)).decrypt(nonce, ct, None)


# ── Compression ───────────────────────────────────────────────────────────────

def compress_path(path: Path, skip: list[str] | None = None) -> tuple[bytes, str]:
    def is_skipped(item: Path) -> bool:
        return any(fnmatch(part, pat) for part in item.parts for pat in (skip or []))

    buf = io.BytesIO()
    with tarfile.open(fileobj=buf, mode="w:gz") as tar:
        if path.is_file():
            tar.add(str(path), arcname=path.name)
        else:
            tar.add(str(path), arcname=path.name, recursive=False)
            for item in sorted(path.rglob("*")):
                rel = item.relative_to(path)
                if is_skipped(rel):
                    print(f"   ⏭  Skipping (--skip match): {item}")
                    continue
                arcname = path.name + "/" + rel.as_posix()
                try:
                    tar.add(str(item), arcname=arcname, recursive=False)
                except PermissionError:
                    print(f"   ⚠️  Skipping (permission denied): {item}")
    return buf.getvalue(), path.name

def decompress_blob(data: bytes, dest_dir: Path) -> list[Path]:
    buf = io.BytesIO(data)
    with tarfile.open(fileobj=buf, mode="r:gz") as tar:
        top_names = {Path(m.name).parts[0] for m in tar.getmembers()}
        for member in tar.getmembers():
            try:
                tar.extract(member, path=str(dest_dir))
            except PermissionError:
                print(f"   ⚠️  Skipping (permission denied): {member.name}")
    return [dest_dir / n for n in top_names]


# ── State persistence ─────────────────────────────────────────────────────────

def _sender_dir(transfer_id: str) -> Path:
    return CLIPCP_DIR / transfer_id / "sender"

def _receiver_dir(transfer_id: str) -> Path:
    return CLIPCP_DIR / transfer_id / "receiver"

def save_sender_state(transfer_id: str, archive_name: str, total: int,
                      full_hash: str, chunk_kb: int, raws: list[str],
                      order: int = 0) -> None:
    d = _sender_dir(transfer_id)
    chunks_dir = d / "chunks"
    chunks_dir.mkdir(parents=True, exist_ok=True)
    meta = {"transfer_id": transfer_id, "archive_name": archive_name,
            "total": total, "full_hash": full_hash, "chunk_kb": chunk_kb,
            "order": order}
    (d / "meta.json").write_text(json.dumps(meta))
    for i, raw in enumerate(raws, 1):
        (chunks_dir / f"{i:04d}.txt").write_text(raw)
    print(f"   💾 Sender state saved → {d}")

def load_sender_state(transfer_id: str) -> tuple[str, int, str, int, int, dict[int, str]]:
    """Returns (archive_name, total, full_hash, chunk_kb, order, chunk_data).
    chunk_data contains only the chunk files still on disk (unACKed ones)."""
    d = _sender_dir(transfer_id)
    meta = json.loads((d / "meta.json").read_text())
    chunks_dir = d / "chunks"
    chunk_data: dict[int, str] = {}
    if chunks_dir.exists():
        for f in chunks_dir.glob("*.txt"):
            chunk_data[int(f.stem)] = f.read_text()
    return (meta["archive_name"], int(meta["total"]), meta["full_hash"],
            int(meta["chunk_kb"]), int(meta.get("order", 0)), chunk_data)

def delete_sender_chunk(transfer_id: str, idx: int) -> None:
    chunk_file = _sender_dir(transfer_id) / "chunks" / f"{idx:04d}.txt"
    if chunk_file.exists():
        chunk_file.unlink()

def save_receiver_chunk(transfer_id: str, archive_name: str, total: int,
                        full_hash: str, idx: int, payload: str) -> None:
    d = _receiver_dir(transfer_id)
    chunks_dir = d / "chunks"
    chunks_dir.mkdir(parents=True, exist_ok=True)
    meta = {"transfer_id": transfer_id, "archive_name": archive_name,
            "total": total, "full_hash": full_hash}
    (d / "meta.json").write_text(json.dumps(meta))
    (chunks_dir / f"{idx:04d}.txt").write_text(payload)

def load_receiver_state(transfer_id: str) -> tuple[str, int, str, dict[int, str]]:
    d = _receiver_dir(transfer_id)
    meta = json.loads((d / "meta.json").read_text())
    chunks_dir = d / "chunks"
    payloads: dict[int, str] = {}
    if chunks_dir.exists():
        for f in chunks_dir.glob("*.txt"):
            payloads[int(f.stem)] = f.read_text()
    return meta["archive_name"], int(meta["total"]), meta["full_hash"], payloads

def clear_transfer_state(transfer_id: str) -> None:
    d = CLIPCP_DIR / transfer_id
    if d.exists():
        shutil.rmtree(d)

def clear_all_state() -> None:
    if CLIPCP_DIR.exists():
        shutil.rmtree(CLIPCP_DIR)
        print(f"   🗑️  Cleared all transfer state from {CLIPCP_DIR}")
    else:
        print("   Nothing to clear.")

def list_transfers() -> None:
    if not CLIPCP_DIR.exists():
        print("No saved transfers.")
        return
    entries = sorted(CLIPCP_DIR.iterdir())
    if not entries:
        print("No saved transfers.")
        return
    print(f"{'#':<4}  {'ID':<12}  {'Role':<8}  {'Archive':<30}  {'Chunks'}")
    print("-" * 76)
    for d in entries:
        tid = d.name
        for role in ("sender", "receiver"):
            role_dir = d / role
            if not (role_dir / "meta.json").exists():
                continue
            try:
                if role == "sender":
                    archive_name, total, _, _, order, chunk_data = load_sender_state(tid)
                    remaining = len(chunk_data)
                    chunk_info = f"{total - remaining}/{total} ACKed  ({remaining} on disk)"
                else:
                    archive_name, total, _, payloads = load_receiver_state(tid)
                    order = 0
                    received = len(payloads)
                    chunk_info = f"{received}/{total} received"
                order_str = f"#{order}" if order else "—"
                print(f"{order_str:<4}  {tid:<12}  {role:<8}  {archive_name[:30]:<30}  {chunk_info}")
            except Exception as e:
                print(f"{'—':<4}  {tid:<12}  {role:<8}  (error reading state: {e})")


# ── Transfer ID resolver ───────────────────────────────────────────────────────

def resolve_transfer_id(tid: str | None, command: str, role: str | None = None) -> str:
    """Return tid if given; otherwise auto-resolve from saved state.
    tid may be a hex transfer ID or a numeric order number.
    role: "sender" or "receiver" to narrow the search; None = either.
    Exits with an error listing all options if 0 or >1 transfers match.
    """
    if tid is not None:
        if tid.isdigit():
            order = int(tid)
            if not CLIPCP_DIR.exists():
                print(f"❌ No transfer with order #{order} found.")
                sys.exit(1)
            for d in sorted(CLIPCP_DIR.iterdir()):
                if not d.is_dir():
                    continue
                roles = [role] if role else ["sender", "receiver"]
                for r in roles:
                    meta_file = d / r / "meta.json"
                    if meta_file.exists():
                        try:
                            meta = json.loads(meta_file.read_text())
                            if meta.get("order") == order:
                                print(f"   (resolved order #{order} → {d.name})")
                                return d.name
                        except Exception:
                            pass
            print(f"❌ No transfer with order #{order} found.")
            sys.exit(1)
        return tid
    if not CLIPCP_DIR.exists():
        print("❌ No saved transfers found.")
        sys.exit(1)
    candidates = [d for d in sorted(CLIPCP_DIR.iterdir()) if d.is_dir()]
    if role:
        candidates = [d for d in candidates if (d / role / "meta.json").exists()]
    else:
        candidates = [d for d in candidates
                      if (d / "sender" / "meta.json").exists()
                      or (d / "receiver" / "meta.json").exists()]
    if not candidates:
        print("❌ No saved transfers found.")
        sys.exit(1)
    if len(candidates) > 1:
        ids = [d.name for d in candidates]
        print(f"❌ Multiple transfers found — please specify a transfer ID:")
        for t in ids:
            print(f"   {t}")
        print(f"\n   Example: clipcp {command} {ids[0]}")
        sys.exit(1)
    resolved = candidates[0].name
    print(f"   (auto-resolved transfer ID: {resolved})")
    return resolved


# ── Job order counter ─────────────────────────────────────────────────────────

COUNTER_FILE = Path.home() / ".clipcp" / "counter"

def next_order_number() -> int:
    COUNTER_FILE.parent.mkdir(parents=True, exist_ok=True)
    n = int(COUNTER_FILE.read_text().strip()) if COUNTER_FILE.exists() else 0
    n += 1
    COUNTER_FILE.write_text(str(n))
    return n

def make_job(transfer_id: str, order: int, job_type: str, name: str,
             total: int, size_kb: float) -> str:
    return f"{CTL_JOB}|{transfer_id}|{order}|{job_type}|{name}|{total}|{size_kb:.1f}"

def parse_job_msg(clip: str) -> dict | None:
    if not clip.startswith(f"{CTL_JOB}|"):
        return None
    parts = clip.split("|", 6)
    if len(parts) != 7:
        return None
    return {"transfer_id": parts[1], "order": int(parts[2]), "type": parts[3],
            "name": parts[4], "total": int(parts[5]), "size_kb": float(parts[6])}


# ── Git helpers ───────────────────────────────────────────────────────────────

NULL_HASH = "0" * 40

def git_get_head(repo_path: Path) -> str:
    """Return the HEAD commit hash, or NULL_HASH if the repo has no commits."""
    r = subprocess.run(
        ["git", "-C", str(repo_path), "rev-parse", "HEAD"],
        capture_output=True, text=True,
    )
    return r.stdout.strip() if r.returncode == 0 else NULL_HASH

def git_get_recent_commits(repo_path: Path, n: int = 200) -> list[str]:
    """Return up to n most recent commit hashes, newest first."""
    r = subprocess.run(
        ["git", "-C", str(repo_path), "log", f"--max-count={n}", "--format=%H"],
        capture_output=True, text=True,
    )
    if r.returncode != 0:
        return []
    return [h for h in r.stdout.strip().splitlines() if h]

def git_hash_exists(repo_path: Path, hash_str: str) -> bool:
    """Return True if hash_str exists as a commit in repo_path."""
    if hash_str == NULL_HASH:
        return True
    r = subprocess.run(
        ["git", "-C", str(repo_path), "cat-file", "-e", f"{hash_str}^{{commit}}"],
        capture_output=True,
    )
    return r.returncode == 0

def git_is_ancestor(repo_path: Path, ancestor: str, descendant: str) -> bool:
    """Return True if ancestor is reachable from descendant (i.e. ancestor is behind or equal)."""
    if ancestor == NULL_HASH:
        return True  # empty history is always an ancestor
    r = subprocess.run(
        ["git", "-C", str(repo_path), "merge-base", "--is-ancestor", ancestor, descendant],
        capture_output=True,
    )
    return r.returncode == 0

def git_create_bundle(repo_path: Path, bundle_path: Path, since_hash: str) -> None:
    """Create a git bundle from since_hash..HEAD (or all commits if since_hash is null)."""
    if since_hash == NULL_HASH:
        cmd = ["git", "-C", str(repo_path), "bundle", "create", str(bundle_path), "--all"]
    else:
        cmd = ["git", "-C", str(repo_path), "bundle", "create", str(bundle_path),
               f"{since_hash}..HEAD"]
    r = subprocess.run(cmd, capture_output=True, text=True)
    if r.returncode != 0:
        raise RuntimeError(r.stderr.strip() or r.stdout.strip())

def git_apply_bundle(repo_path: Path, bundle_path: Path) -> None:
    """Apply a bundle to repo_path, initialising the repo if necessary."""
    repo_path.mkdir(parents=True, exist_ok=True)
    has_git = (repo_path / ".git").exists()
    if not has_git:
        subprocess.run(["git", "-C", str(repo_path), "init"], check=True, capture_output=True)

    # Verify bundle integrity
    v = subprocess.run(
        ["git", "-C", str(repo_path), "bundle", "verify", str(bundle_path)],
        capture_output=True, text=True,
    )
    if v.returncode != 0:
        raise RuntimeError(f"bundle verify failed: {v.stderr.strip()}")

    # Fetch into a temporary ref
    f = subprocess.run(
        ["git", "-C", str(repo_path), "fetch", str(bundle_path),
         "HEAD:refs/clipcp/incoming"],
        capture_output=True, text=True,
    )
    if f.returncode != 0:
        raise RuntimeError(f"git fetch failed: {f.stderr.strip()}")

    if not has_git or git_get_head(repo_path) == NULL_HASH:
        # Fresh repo — reset directly
        r = subprocess.run(
            ["git", "-C", str(repo_path), "reset", "--hard", "refs/clipcp/incoming"],
            capture_output=True, text=True,
        )
    else:
        # Abort any in-progress merge before attempting a new one
        if (repo_path / ".git" / "MERGE_HEAD").exists():
            subprocess.run(["git", "-C", str(repo_path), "merge", "--abort"],
                           capture_output=True)

        # Try fast-forward first, fall back to merge
        r = subprocess.run(
            ["git", "-C", str(repo_path), "merge", "--ff-only", "refs/clipcp/incoming"],
            capture_output=True, text=True,
        )
        if r.returncode != 0:
            r = subprocess.run(
                ["git", "-C", str(repo_path), "merge", "refs/clipcp/incoming",
                 "-m", "clipcp: sync from server"],
                capture_output=True, text=True,
            )
    if r.returncode != 0:
        subprocess.run(["git", "-C", str(repo_path), "merge", "--abort"], capture_output=True)
        detail = r.stderr.strip() or r.stdout.strip()
        raise RuntimeError(
            f"Merge conflict — changes from both sides overlap.\n"
            f"{detail}\n\n"
            f"The merge was aborted. To resolve manually:\n"
            f"  1. cd into the server repo\n"
            f"  2. git merge refs/clipcp/incoming\n"
            f"  3. Edit conflicting files and remove conflict markers (<<<<<<<)\n"
            f"  4. git add <resolved files>\n"
            f"  5. git commit\n\n"
            f"To take one side entirely:\n"
            f"  git merge -X ours refs/clipcp/incoming    # keep server version\n"
            f"  git merge -X theirs refs/clipcp/incoming  # keep incoming version"
        )
    if r.stdout.strip():
        print(f"   {r.stdout.strip()}")


# ── DATA chunk encode / decode ────────────────────────────────────────────────

def chunk_checksum(payload: str) -> str:
    return hashlib.sha256(payload.encode()).hexdigest()[:16]

def new_transfer_id() -> str:
    return secrets.token_hex(4).upper()

def build_chunk(payload: str, transfer_id: str, index: int,
                total: int, full_hash: str, archive_name: str) -> str:
    ck = chunk_checksum(payload)
    header = f"{HEADER_PREFIX}v{VERSION}|{transfer_id}|{total}|{index}|{full_hash}|{archive_name}|{ck}{HEADER_SUFFIX}"
    footer = f"{FOOTER_PREFIX}{transfer_id}|{index}|{ck}{FOOTER_SUFFIX}"
    return f"{header}\n{payload}\n{footer}"

class ChunkDict(TypedDict):
    version:      str
    transfer_id:  str
    total:        int
    index:        int
    full_hash:    str
    archive_name: str
    payload:      str

def parse_chunk_verbose(text: str) -> tuple[ChunkDict | None, str]:
    text = text.strip()
    lines = text.splitlines()
    if len(lines) < 3:
        return None, "too few lines"
    header_line, footer_line = lines[0], lines[-1]
    b64_payload = "\n".join(lines[1:-1]).strip()

    if not (header_line.startswith(HEADER_PREFIX) and header_line.endswith(HEADER_SUFFIX)):
        return None, "header prefix/suffix not recognised"
    parts = header_line[len(HEADER_PREFIX):-len(HEADER_SUFFIX)].split("|")
    if len(parts) != 7:
        return None, f"header has {len(parts)} fields, expected 7"
    version_str, transfer_id, total_str, idx_str, full_hash, archive_name, hdr_ck = parts

    if not (footer_line.startswith(FOOTER_PREFIX) and footer_line.endswith(FOOTER_SUFFIX)):
        return None, "footer prefix/suffix not recognised"
    fparts = footer_line[len(FOOTER_PREFIX):-len(FOOTER_SUFFIX)].split("|")
    if len(fparts) != 3:
        return None, f"footer has {len(fparts)} fields, expected 3"
    ftr_tid, ftr_idx, ftr_ck = fparts

    if transfer_id != ftr_tid:
        return None, f"transfer_id mismatch: header={transfer_id} footer={ftr_tid}"
    if idx_str != ftr_idx:
        return None, f"index mismatch: header={idx_str} footer={ftr_idx}"
    if hdr_ck != ftr_ck:
        return None, "checksum differs between header and footer"
    computed = chunk_checksum(b64_payload)
    if computed != hdr_ck:
        return None, f"payload checksum mismatch (got {computed}, expected {hdr_ck})"

    return {"version": version_str, "transfer_id": transfer_id,
            "total": int(total_str), "index": int(idx_str),
            "full_hash": full_hash, "archive_name": archive_name,
            "payload": b64_payload}, ""

def parse_chunk(text: str) -> ChunkDict | None:
    r, _ = parse_chunk_verbose(text)
    return r


# ── Control message encode / decode ───────────────────────────────────────────

def make_ready(transfer_id: str, last_received: int = 0) -> str:
    return f"{CTL_READY}|{transfer_id}|{last_received}"

def make_ack(transfer_id: str, index: int) -> str:
    return f"{CTL_ACK}|{transfer_id}|{index}"

def make_done(transfer_id: str) -> str:
    return f"{CTL_DONE}|{transfer_id}"

def make_err(transfer_id: str, reason: str) -> str:
    return f"{CTL_ERR}|{transfer_id}|{reason}"

def parse_control(text: str) -> dict[str, str | int] | None:
    text = text.strip()
    if "\n" in text:
        return None
    parts = text.split("|")
    if not parts:
        return None
    kind = parts[0]
    if kind == CTL_READY and len(parts) >= 2:
        last_received = int(parts[2]) if len(parts) >= 3 else 0
        return {"type": CTL_READY, "transfer_id": parts[1], "last_received": last_received}
    if kind == CTL_ACK and len(parts) == 3:
        return {"type": CTL_ACK, "transfer_id": parts[1], "index": int(parts[2])}
    if kind == CTL_DONE and len(parts) == 2:
        return {"type": CTL_DONE, "transfer_id": parts[1]}
    if kind == CTL_ERR and len(parts) >= 3:
        return {"type": CTL_ERR, "transfer_id": parts[1], "reason": "|".join(parts[2:])}
    return None


# ── Shared clipboard poller ───────────────────────────────────────────────────

def poll_clipboard(
    predicate,
    timeout: float,
    ignore_own: str | None = None,
    label: str = "…",
) -> object:
    deadline = time.monotonic() + timeout
    last_seen = None
    while time.monotonic() < deadline:
        try:
            clip = pyperclip.paste()
        except Exception:
            clip = ""
        if clip != ignore_own and clip != last_seen:
            last_seen = clip
            result = predicate(clip)
            if result:
                return result
        time.sleep(POLL_INTERVAL)
    raise TimeoutError(f"Timed out waiting for {label}")


# ── Password ──────────────────────────────────────────────────────────────────

def get_password() -> str:
    pw = os.environ.get(ENV_PASSWORD_VAR, "")
    if not pw:
        pw = getpass(f"Password (or set ${ENV_PASSWORD_VAR}): ")
    if not pw:
        print("ERROR: No password provided.")
        sys.exit(1)
    return pw


# ── Finalise transfer (receiver) ──────────────────────────────────────────────

def finalise_transfer(payloads: dict[int, str], total: int, full_hash_expected: str,
                      password: str, dest_dir: Path) -> None:
    print("\n🔧 Reassembling …")
    b64_full = "".join(payloads[i] for i in range(1, total + 1))
    computed = hashlib.sha256(b64_full.encode()).hexdigest()[:32]
    if computed != full_hash_expected:
        print("❌ Full payload hash mismatch — data corrupted or tampered with.")
        sys.exit(1)
    print("   Hash verified ✓")

    print("🔓 Decrypting …")
    try:
        compressed = decrypt(base64.b64decode(b64_full), password)
    except Exception as e:
        print(f"❌ Decryption failed: {e}")
        sys.exit(1)

    print("📂 Extracting …")
    dest_dir.mkdir(parents=True, exist_ok=True)

    with tempfile.TemporaryDirectory() as tmpdir:
        extracted = decompress_blob(compressed, Path(tmpdir))

        for src_path in extracted:
            dest_path = dest_dir / src_path.name
            # For directories: collect archived children before moving
            archived_children = (
                {p.name for p in src_path.iterdir()} if src_path.is_dir() else None
            )
            if dest_path.exists():
                try:
                    ans = input(f"   '{dest_path}' already exists. Overwrite? [Y/n] ").strip().lower()
                except KeyboardInterrupt:
                    print("\nAborted.")
                    sys.exit(0)
                if ans not in ("", "y", "yes"):
                    print(f"   Skipping '{src_path.name}'.")
                    continue
                shutil.rmtree(dest_path) if dest_path.is_dir() else dest_path.unlink()
            shutil.move(str(src_path), str(dest_dir))
            print(f"   ✅ Extracted → {dest_path}")

            # Stale check: only inside transferred folders, not for single files
            if archived_children is not None:
                stale = [p for p in dest_path.iterdir() if p.name not in archived_children]
                if stale:
                    print(f"\n🔍 Items in '{dest_path.name}/' NOT in this transfer:")
                    for p in stale:
                        try:
                            ans = input(f"   '{p.name}' was not transferred. Delete it? [y/N] ").strip().lower()
                        except KeyboardInterrupt:
                            print("\nAborted.")
                            sys.exit(0)
                        if ans in ("y", "yes"):
                            shutil.rmtree(p) if p.is_dir() else p.unlink()
                            print(f"   🗑️  Deleted '{p.name}'.")

    print("\n🎉 Transfer complete!\n")


# ── MANUAL sender ─────────────────────────────────────────────────────────────

def sender_manual(target: Path, chunk_kb: int, skip: list[str]) -> None:
    password = get_password()
    print(f"📦 Compressing '{target}' …")
    compressed, archive_name = compress_path(target, skip)
    print("🔒 Encrypting …")
    encrypted  = encrypt(compressed, password)
    b64_full   = base64.b64encode(encrypted).decode()
    full_hash  = hashlib.sha256(b64_full.encode()).hexdigest()[:32]
    chunk_size = chunk_kb * 1024
    raws       = [b64_full[i:i+chunk_size] for i in range(0, len(b64_full), chunk_size)]
    total      = len(raws)
    transfer_id = new_transfer_id()

    print(f"\n✅ Ready")
    print(f"   Transfer ID : {transfer_id}")
    print(f"   Archive     : {archive_name}")
    print(f"   Total size  : {len(b64_full)/1024:.1f} KB  →  {total} chunk(s) of ≤{chunk_kb} KB\n")

    for i, raw in enumerate(raws, start=1):
        chunk_text = build_chunk(raw, transfer_id, i, total, full_hash, archive_name)
        pyperclip.copy(chunk_text)
        print(f"📋 Part {i}/{total}  [ID:{transfer_id}]  copied to clipboard.")
        if i < total:
            try:
                input(f"   Press ENTER after receiver confirms part {i}, to load part {i+1}/{total} … ")
            except KeyboardInterrupt:
                print("\nAborted.")
                sys.exit(0)
    print("\n   All parts sent! ✓")


# ── MANUAL receiver ───────────────────────────────────────────────────────────

def receiver_manual(dest_dir: Path) -> None:
    password = get_password()
    print("\n📥 Receiver mode — paste each part when prompted.\n")
    payloads:    dict[int, str] = {}
    transfer_id: str | None = None
    full_hash_expected: str | None = None
    total: int | None = None

    while True:
        if total is not None:
            missing = [i for i in range(1, total+1) if i not in payloads]
            if not missing:
                break
            next_expected = missing[0]
            print(f"\n   Transfer ID : {transfer_id}")
            print(f"   Received    : [{', '.join(str(k) for k in sorted(payloads)) or '—'}]")
            print(f"   Still need  : [{', '.join(str(m) for m in missing)}]")
            prompt = f"   Copy part {next_expected}/{total} to clipboard, then press ENTER … "
        else:
            next_expected = None
            prompt = "   Copy part 1 to clipboard, then press ENTER … "

        try:
            input(prompt)
        except KeyboardInterrupt:
            print("\nAborted.")
            sys.exit(0)

        parsed, reason = parse_chunk_verbose(pyperclip.paste())
        if parsed is None:
            print(f"\n   ❌ Not a valid clipcp chunk ({reason}).")
            continue
        if transfer_id is None:
            transfer_id, total = parsed["transfer_id"], parsed["total"]
            full_hash_expected = parsed["full_hash"]
            print(f"\n   🔑 Locked onto transfer [{transfer_id}]  ({total} part(s) total)")
        elif parsed["transfer_id"] != transfer_id:
            print(f"\n   ❌ Wrong transfer ID (got [{parsed['transfer_id']}], expected [{transfer_id}]).")
            continue

        idx = parsed["index"]
        if next_expected is not None and idx != next_expected:
            print(f"\n   ❌ Wrong part: got {idx}, expected {next_expected}. Try again.")
            continue
        if idx in payloads:
            print(f"\n   ℹ️  Part {idx} already received.")
        else:
            payloads[idx] = parsed["payload"]
            print(f"\n   ✅ Part {idx}/{total}  [ID:{transfer_id}]  verified and queued.")

    assert total is not None and full_hash_expected is not None
    finalise_transfer(payloads, total, full_hash_expected, password, dest_dir)


# ── AUTO sender ───────────────────────────────────────────────────────────────

def sender_auto(target: Path | None, chunk_kb: int, timeout: float,
                skip: list[str], resume_id: str | None, yes: bool = False) -> None:
    chunk_data: dict[int, str] = {}

    if resume_id:
        # ── Resume: load remaining (unACKed) chunks from disk ─────────────────
        try:
            archive_name, total, full_hash, _, order, chunk_data = load_sender_state(resume_id)
        except (FileNotFoundError, KeyError) as e:
            print(f"❌ Cannot resume sender state for [{resume_id}]: {e}")
            sys.exit(1)
        transfer_id   = resume_id
        remaining_idx = sorted(chunk_data.keys())
        print(f"\n🔄 Resuming sender [{transfer_id}]  (job #{order})")
        print(f"   Archive        : {archive_name}  ({total} chunk(s) total)")
        print(f"   Chunks on disk : {remaining_idx}\n")
    else:
        # ── Fresh transfer ─────────────────────────────────────────────────────
        if target is None:
            print("ERROR: No target specified.")
            sys.exit(1)
        password_for_encrypt = get_password()
        print(f"📦 Compressing '{target}' …")
        compressed, archive_name = compress_path(target, skip)
        print("🔒 Encrypting …")
        encrypted  = encrypt(compressed, password_for_encrypt)
        b64_full   = base64.b64encode(encrypted).decode()
        full_hash  = hashlib.sha256(b64_full.encode()).hexdigest()[:32]
        chunk_size = chunk_kb * 1024
        raws       = [b64_full[i:i+chunk_size] for i in range(0, len(b64_full), chunk_size)]
        total      = len(raws)
        transfer_id = new_transfer_id()
        order       = next_order_number()
        chunk_data  = {i: raw for i, raw in enumerate(raws, 1)}
        job_type    = "folder" if target.is_dir() else "file"

        print(f"\n✅ Ready")
        print(f"   Job         : #{order}")
        print(f"   Transfer ID : {transfer_id}")
        print(f"   Type        : {job_type}")
        print(f"   Archive     : {archive_name}")
        print(f"   Total size  : {len(b64_full)/1024:.1f} KB  →  {total} chunk(s) of ≤{chunk_kb} KB")

        if not yes:
            try:
                input(f"\n   Press ENTER to announce job to receiver (or Ctrl+C to abort) … ")
            except KeyboardInterrupt:
                print("\nAborted.")
                sys.exit(0)

        save_sender_state(transfer_id, archive_name, total, full_hash, chunk_kb, raws, order)

        job_msg = make_job(transfer_id, order, job_type, archive_name, total, len(b64_full) / 1024)
        pyperclip.copy(job_msg)
        print(f"\n📋 Job #{order} [{transfer_id}] → clipboard. Waiting for receiver …\n")

    print(f"   Waiting for receiver READY signal …\n")

    # ── Wait for READY (receiver reports its last successfully received index) ─
    def is_ready(clip: str) -> dict[str, str | int] | None:
        ctl = parse_control(clip)
        return ctl if (ctl is not None and ctl["type"] == CTL_READY) else None

    try:
        ready_ctl = poll_clipboard(is_ready, timeout, label="READY signal from receiver")
    except TimeoutError:
        print("❌ Timed out waiting for receiver READY signal.")
        sys.exit(1)

    assert isinstance(ready_ctl, dict)
    last_received = int(ready_ctl.get("last_received", 0))
    start_from    = last_received + 1

    if last_received > 0:
        print(f"🔄 Receiver already has chunks 1‥{last_received}. Resuming from chunk {start_from} …\n")
    else:
        print("🔔 Receiver is ready. Starting transfer …\n")

    # ── Send chunks from start_from; delete each from disk after ACK ──────────
    for i in range(start_from, total + 1):
        if i not in chunk_data:
            print(f"❌ Chunk {i} not found on disk — state inconsistent with receiver.")
            sys.exit(1)
        raw        = chunk_data[i]
        chunk_text = build_chunk(raw, transfer_id, i, total, full_hash, archive_name)
        pyperclip.copy(chunk_text)
        print(f"📤 Part {i}/{total}  [ID:{transfer_id}]  → clipboard")

        if i < total:
            def is_ack_for(clip: str, _i: int = i) -> bool:
                ctl = parse_control(clip)
                return (ctl is not None
                        and ctl["type"] == CTL_ACK
                        and ctl["transfer_id"] == transfer_id
                        and ctl["index"] == _i)

            try:
                poll_clipboard(is_ack_for, timeout, ignore_own=chunk_text,
                               label=f"ACK for part {i}")
            except TimeoutError:
                print(f"❌ Timed out waiting for ACK of part {i}.")
                sys.exit(1)
            delete_sender_chunk(transfer_id, i)
            print(f"   ✅ ACK received for part {i}/{total}  (chunk {i} deleted from disk)")
        else:
            def is_done_or_err(clip: str) -> bool:
                msg = parse_control(clip)
                return (msg is not None
                        and msg["transfer_id"] == transfer_id
                        and msg["type"] in (CTL_DONE, CTL_ERR))

            print(f"   ⏳ Waiting for receiver DONE signal …")
            try:
                poll_clipboard(is_done_or_err, timeout, ignore_own=chunk_text,
                               label="DONE or ERR signal")
            except TimeoutError:
                print("❌ Timed out waiting for DONE signal.")
                sys.exit(1)

            ctl = parse_control(pyperclip.paste())
            if ctl and ctl["type"] == CTL_ERR:
                print(f"❌ Receiver reported error: {ctl.get('reason', '?')}")
                sys.exit(1)

    # ── Success: clear saved state ────────────────────────────────────────────
    clear_transfer_state(transfer_id)
    print("\n🎉 All parts acknowledged — transfer complete!\n")


# ── AUTO receiver ─────────────────────────────────────────────────────────────

def receiver_auto(dest_dir: Path, timeout: float, resume_id: str | None,
                  yes: bool = False, chunk_kb: int = DEFAULT_CHUNK_KB) -> None:
    password = get_password()

    payloads:           dict[int, str] = {}
    transfer_id:        str | None     = None
    total:              int | None     = None
    full_hash_expected: str | None     = None
    last_written:       str | None     = None

    if resume_id:
        # ── Explicit resume: load already-received chunks from disk ───────────
        try:
            archive_name, total, full_hash_expected, payloads = load_receiver_state(resume_id)
        except (FileNotFoundError, KeyError) as e:
            print(f"❌ Cannot resume receiver state for [{resume_id}]: {e}")
            sys.exit(1)
        transfer_id = resume_id
        last_received = max(payloads.keys()) if payloads else 0
        print(f"\n🔄 Resuming receiver [{transfer_id}]")
        print(f"   Already have: {sorted(payloads.keys())}  (last={last_received})\n")
    else:
        last_received = 0
        print("\n📥 Receiver mode — waiting for job announcement …\n")

        # ── Poll for CTL_JOB (file/folder/git) or CTL_GIT_SRV (git handshake) ─
        def is_job_or_git_srv(clip: str) -> dict | None:
            job = parse_job_msg(clip)
            if job:
                return {"_kind": "job", **job}
            if clip.startswith(f"{CTL_GIT_SRV}|"):
                parts = clip.split("|")
                if len(parts) == 3:
                    return {"_kind": "git_srv", "transfer_id": parts[1], "server_hash": parts[2]}
            return None

        try:
            first = poll_clipboard(is_job_or_git_srv, timeout, label="job announcement")
        except TimeoutError:
            print("❌ Timed out waiting for job announcement.")
            sys.exit(1)

        assert isinstance(first, dict)

        if first["_kind"] == "git_srv":
            # ── Git transfer: hand off to git receiver ─────────────────────────
            dest_dir.mkdir(parents=True, exist_ok=True)
            _receiver_git_after_srv(dest_dir, timeout, chunk_kb, yes,
                                    str(first["transfer_id"]), str(first["server_hash"]))
            return

        # ── Regular file/folder transfer ──────────────────────────────────────
        job = first
        transfer_id = str(job["transfer_id"])

        # Auto-resume if we already have chunks for this transfer (skip confirmation)
        if _receiver_dir(transfer_id).exists():
            try:
                _, _tot2, _fhash2, payloads = load_receiver_state(transfer_id)
                total = int(_tot2)
                full_hash_expected = str(_fhash2)
                last_received = max(payloads.keys()) if payloads else 0
                print(f"🔄 Auto-resuming job #{job['order']} [{transfer_id}]")
                print(f"   Already have chunks: {sorted(payloads.keys())}\n")
            except (FileNotFoundError, KeyError):
                pass  # corrupted state — treat as fresh

        if not payloads:
            # Fresh transfer — show info and ask confirmation
            print(f"📋 Job #{job['order']} [{transfer_id}]:")
            print(f"   Type    : {job['type']}")
            print(f"   Name    : {job['name']}")
            print(f"   Chunks  : {job['total']}  ({job['size_kb']:.1f} KB total)")

            if not yes:
                try:
                    ans = input(f"\n   Accept transfer? [Y/n] ").strip().lower()
                except KeyboardInterrupt:
                    print("\nAborted.")
                    sys.exit(0)
                if ans not in ("", "y", "yes"):
                    print("   Declined.")
                    sys.exit(0)

    # ── Signal readiness with last successfully received index ────────────────
    ready_tid = transfer_id or "WAITING"
    ready_msg = make_ready(ready_tid, last_received)
    pyperclip.copy(ready_msg)
    last_written = ready_msg
    print(f"\n📣 READY → clipboard. Waiting for sender …\n")

    # ── Collect missing chunks ────────────────────────────────────────────────
    while True:
        if total is not None:
            missing = [i for i in range(1, total + 1) if i not in payloads]
            if not missing:
                break
            next_expected = missing[0]
        else:
            next_expected = None

        def is_valid_chunk(clip: str) -> ChunkDict | None:
            if clip == last_written:
                return None
            parsed, _ = parse_chunk_verbose(clip)
            if parsed is None:
                return None
            if transfer_id is not None and parsed["transfer_id"] != transfer_id:
                return None
            if next_expected is not None and parsed["index"] != next_expected:
                return None
            return parsed

        label = f"part {next_expected}" if next_expected else "part 1"
        try:
            parsed = poll_clipboard(is_valid_chunk, timeout, label=label)
        except TimeoutError:
            print(f"❌ Timed out waiting for {label} from sender.")
            if transfer_id:
                pyperclip.copy(make_err(transfer_id, "timeout"))
            sys.exit(1)

        assert isinstance(parsed, dict)

        if transfer_id is None:
            transfer_id        = parsed["transfer_id"]
            print(f"🔑 Locked onto transfer [{transfer_id}]  ({parsed['total']} part(s) total)")
        if total is None:
            total              = parsed["total"]
        if full_hash_expected is None:
            full_hash_expected = parsed["full_hash"]

        idx = parsed["index"]
        if idx not in payloads:
            payloads[idx] = parsed["payload"]
            save_receiver_chunk(transfer_id, parsed["archive_name"], parsed["total"],
                                parsed["full_hash"], idx, parsed["payload"])
            print(f"   ✅ Part {idx}/{total}  [ID:{transfer_id}]  received and saved")
        else:
            print(f"   ℹ️  Part {idx} duplicate — ignored")

        missing_after = [i for i in range(1, total + 1) if i not in payloads]
        if missing_after:
            ack = make_ack(transfer_id, idx)
            pyperclip.copy(ack)
            last_written = ack
            print(f"   📣 ACK|{idx} → clipboard  (still need: {missing_after})")

    # ── Signal completion ─────────────────────────────────────────────────────
    assert transfer_id is not None and total is not None and full_hash_expected is not None
    done_msg = make_done(transfer_id)
    pyperclip.copy(done_msg)
    print(f"\n📣 DONE → clipboard")

    finalise_transfer(payloads, total, full_hash_expected, password, dest_dir)

    # ── Success: clear saved state ────────────────────────────────────────────
    clear_transfer_state(transfer_id)


# ── GIT sender ────────────────────────────────────────────────────────────────

def sender_git(repo_path: Path, chunk_kb: int, timeout: float, yes: bool = False) -> None:
    """
    Git sync sender.
    1. Announce server HEAD → clipboard (GIT_SRV).
    2. Wait for client HEAD from receiver (GIT_CLI = implicit READY).
    3. If already in sync, exit.
    4. Create encrypted git bundle, split into chunks, send with ACK protocol.
    """
    server_hash = git_get_head(repo_path)
    if server_hash == NULL_HASH:
        print("❌ No git commits found in the repository.")
        sys.exit(1)

    transfer_id = new_transfer_id()
    print(f"\n🌿 Git mode — sender")
    print(f"   Transfer ID  : {transfer_id}")
    print(f"   Server HEAD  : {server_hash[:12]}…")
    print(f"   Announcing to client …\n")

    srv_msg = f"{CTL_GIT_SRV}|{transfer_id}|{server_hash}"
    pyperclip.copy(srv_msg)

    def is_git_cli(clip: str) -> dict[str, str] | None:
        if not clip.startswith(f"{CTL_GIT_CLI}|"):
            return None
        parts = clip.split("|")
        if len(parts) != 3 or parts[1] != transfer_id:
            return None
        return {"client_hash": parts[2]}

    try:
        cli_ctl = poll_clipboard(is_git_cli, timeout, ignore_own=srv_msg,
                                 label="client git hash")
    except TimeoutError:
        print("❌ Timed out waiting for client git hash.")
        sys.exit(1)

    assert isinstance(cli_ctl, dict)
    client_hash = str(cli_ctl["client_hash"])
    print(f"   Client HEAD  : {client_hash[:12]}…")

    if client_hash == server_hash:
        print("\n✅ Already in sync — nothing to transfer.\n")
        return

    # Determine sync direction
    client_is_ancestor = git_is_ancestor(repo_path, client_hash, server_hash)

    if not client_is_ancestor:
        # Client is ahead or diverged — negotiate common base, then request reverse sync
        print(f"\n⚠️  Client is not behind server. Negotiating common base …")
        candidates = git_get_recent_commits(repo_path)
        cands_msg  = f"{CTL_GIT_CANDIDATES}|{transfer_id}|{','.join(candidates)}"
        pyperclip.copy(cands_msg)
        print(f"   Sent {len(candidates)} candidate commit(s) → clipboard")

        def is_git_base(clip: str) -> dict[str, str] | None:
            if not clip.startswith(f"{CTL_GIT_BASE}|"):
                return None
            parts = clip.split("|", 2)
            if len(parts) != 3 or parts[1] != transfer_id:
                return None
            return {"base_hash": parts[2]}

        try:
            base_ctl = poll_clipboard(is_git_base, timeout, ignore_own=cands_msg,
                                      label="common base from client")
        except TimeoutError:
            print("❌ Timed out waiting for common base from client.")
            sys.exit(1)

        assert isinstance(base_ctl, dict)
        base_hash = str(base_ctl["base_hash"])
        if base_hash == NULL_HASH:
            print("   No common base — client will send full history")
        else:
            print(f"   Common base  : {base_hash[:12]}…")

        rev_msg = f"{CTL_GIT_REVERSE}|{transfer_id}|{base_hash}"
        pyperclip.copy(rev_msg)

        def is_git_confirm(clip: str) -> bool:
            return clip.strip() == f"{CTL_GIT_CONFIRM}|{transfer_id}"

        try:
            poll_clipboard(is_git_confirm, timeout, ignore_own=rev_msg, label="client CONFIRM")
        except TimeoutError:
            print("❌ Timed out waiting for client confirmation.")
            sys.exit(1)

        print("   Client confirmed. Waiting for bundle chunks …\n")

        rev_payloads:  dict[int, str] = {}
        rev_total:     int | None     = None
        rev_full_hash: str | None     = None
        rev_last_seen: str | None     = f"{CTL_GIT_CONFIRM}|{transfer_id}"

        while True:
            if rev_total is not None:
                rev_missing = [i for i in range(1, rev_total + 1) if i not in rev_payloads]
                if not rev_missing:
                    break
                rev_next = rev_missing[0]
            else:
                rev_next = None

            def is_rev_chunk(clip: str, _next: int | None = rev_next) -> ChunkDict | None:
                if clip == rev_last_seen:
                    return None
                parsed, _ = parse_chunk_verbose(clip)
                if parsed is None or parsed["transfer_id"] != transfer_id:
                    return None
                if _next is not None and parsed["index"] != _next:
                    return None
                return parsed

            rev_label = f"part {rev_next}" if rev_next else "part 1"
            try:
                rev_parsed = poll_clipboard(is_rev_chunk, timeout, label=rev_label)
            except TimeoutError:
                print(f"❌ Timed out waiting for {rev_label}.")
                pyperclip.copy(make_err(transfer_id, "timeout"))
                sys.exit(1)

            assert isinstance(rev_parsed, dict)
            if rev_total is None:
                rev_total     = rev_parsed["total"]
                rev_full_hash = rev_parsed["full_hash"]
                print(f"🔑 Locked onto transfer [{transfer_id}]  ({rev_total} part(s) total)")

            rev_idx = rev_parsed["index"]
            if rev_idx not in rev_payloads:
                rev_payloads[rev_idx] = rev_parsed["payload"]
                save_receiver_chunk(transfer_id, "git.bundle", rev_parsed["total"],
                                    rev_parsed["full_hash"], rev_idx, rev_parsed["payload"])
                print(f"   ✅ Part {rev_idx}/{rev_total}  received and saved")
            else:
                print(f"   ℹ️  Part {rev_idx} duplicate — ignored")

            rev_missing_after = [i for i in range(1, rev_total + 1) if i not in rev_payloads]
            if rev_missing_after:
                rev_ack = make_ack(transfer_id, rev_idx)
                pyperclip.copy(rev_ack)
                rev_last_seen = rev_ack
                print(f"   📣 ACK|{rev_idx} → clipboard  (still need: {rev_missing_after})")

        assert rev_total is not None and rev_full_hash is not None
        pyperclip.copy(make_done(transfer_id))
        print(f"\n📣 DONE → clipboard")

        print("\n🔧 Reassembling …")
        rev_b64 = "".join(rev_payloads[i] for i in range(1, rev_total + 1))
        if hashlib.sha256(rev_b64.encode()).hexdigest()[:32] != rev_full_hash:
            print("❌ Payload hash mismatch.")
            sys.exit(1)
        print("   Hash verified ✓")

        print("🔓 Decrypting …")
        password = get_password()
        try:
            rev_bundle_bytes = decrypt(base64.b64decode(rev_b64), password)
        except Exception as e:
            print(f"❌ Decryption failed: {e}")
            sys.exit(1)

        with tempfile.NamedTemporaryFile(suffix=".bundle", delete=False) as frev:
            rev_bundle_tmp = Path(frev.name)
            frev.write(rev_bundle_bytes)
        try:
            print("🌿 Applying git bundle …")
            git_apply_bundle(repo_path, rev_bundle_tmp)
            print("\n🎉 Reverse git sync complete!\n")
        except RuntimeError as e:
            print(f"❌ Failed to apply git bundle: {e}")
            sys.exit(1)
        finally:
            rev_bundle_tmp.unlink(missing_ok=True)
            clear_transfer_state(transfer_id)
        return

    # Normal sync: server is ahead → create bundle, encrypt, split
    with tempfile.TemporaryDirectory() as tmpdir:
        bundle_path = Path(tmpdir) / "sync.bundle"
        print("📦 Creating git bundle …")
        try:
            git_create_bundle(repo_path, bundle_path, client_hash)
        except RuntimeError as e:
            print(f"❌ Failed to create git bundle: {e}")
            sys.exit(1)

        bundle_bytes = bundle_path.read_bytes()
        print(f"   Bundle size  : {len(bundle_bytes) / 1024:.1f} KB")

        password = get_password()
        encrypted  = encrypt(bundle_bytes, password)
        b64_full   = base64.b64encode(encrypted).decode()
        full_hash  = hashlib.sha256(b64_full.encode()).hexdigest()[:32]
        chunk_size = chunk_kb * 1024
        raws       = [b64_full[i:i + chunk_size] for i in range(0, len(b64_full), chunk_size)]
        total      = len(raws)
        chunk_data = {i: raw for i, raw in enumerate(raws, 1)}
        archive_name = "git.bundle"

        order = next_order_number()
        print(f"   Chunks       : {total} × ≤{chunk_kb} KB")

        if not yes:
            try:
                input(f"\n   Press ENTER to announce git job #{order} to receiver (or Ctrl+C to abort) … ")
            except KeyboardInterrupt:
                print("\nAborted.")
                sys.exit(0)

        save_sender_state(transfer_id, archive_name, total, full_hash, chunk_kb, raws, order)

        job_msg = make_job(transfer_id, order, "git", repo_path.name, total, len(b64_full) / 1024)
        pyperclip.copy(job_msg)
        print(f"\n📋 Git job #{order} [{transfer_id}] → clipboard. Waiting for receiver …\n")

        def is_ready_git(clip: str) -> dict | None:
            ctl = parse_control(clip)
            return ctl if (ctl and ctl["type"] == CTL_READY
                           and ctl["transfer_id"] == transfer_id) else None

        try:
            poll_clipboard(is_ready_git, timeout, ignore_own=job_msg,
                           label="READY from receiver")
        except TimeoutError:
            print("❌ Timed out waiting for receiver READY.")
            sys.exit(1)

        print("🔔 Receiver is ready. Starting transfer …\n")

        for i in range(1, total + 1):
            raw        = chunk_data[i]
            chunk_text = build_chunk(raw, transfer_id, i, total, full_hash, archive_name)
            pyperclip.copy(chunk_text)
            print(f"📤 Part {i}/{total}  [ID:{transfer_id}]  → clipboard")

            if i < total:
                def is_ack_for(clip: str, _i: int = i) -> bool:
                    ctl = parse_control(clip)
                    return (ctl is not None and ctl["type"] == CTL_ACK
                            and ctl["transfer_id"] == transfer_id
                            and ctl["index"] == _i)

                try:
                    poll_clipboard(is_ack_for, timeout, ignore_own=chunk_text,
                                   label=f"ACK for part {i}")
                except TimeoutError:
                    print(f"❌ Timed out waiting for ACK of part {i}.")
                    sys.exit(1)
                delete_sender_chunk(transfer_id, i)
                print(f"   ✅ ACK received for part {i}/{total}  (chunk {i} deleted from disk)")
            else:
                def is_done_or_err(clip: str) -> bool:
                    msg = parse_control(clip)
                    return (msg is not None and msg["transfer_id"] == transfer_id
                            and msg["type"] in (CTL_DONE, CTL_ERR))

                print(f"   ⏳ Waiting for receiver DONE signal …")
                try:
                    poll_clipboard(is_done_or_err, timeout, ignore_own=chunk_text,
                                   label="DONE or ERR signal")
                except TimeoutError:
                    print("❌ Timed out waiting for DONE signal.")
                    sys.exit(1)
                ctl = parse_control(pyperclip.paste())
                if ctl and ctl["type"] == CTL_ERR:
                    print(f"❌ Receiver reported error: {ctl.get('reason', '?')}")
                    sys.exit(1)

    clear_transfer_state(transfer_id)
    print("\n🎉 Git sync bundle delivered — transfer complete!\n")


# ── GIT receiver ──────────────────────────────────────────────────────────────

def _receiver_git_after_srv(dest_dir: Path, timeout: float, chunk_kb: int, yes: bool,
                             transfer_id: str, server_hash: str) -> None:
    """Git receiver logic after the initial GIT_SRV/GIT_CLI handshake."""
    print(f"   Transfer ID  : {transfer_id}")
    print(f"   Server HEAD  : {server_hash[:12]}…")

    client_hash = git_get_head(dest_dir)
    print(f"   Client HEAD  : {client_hash[:12]}…")

    if client_hash == server_hash:
        print("\n✅ Already in sync — nothing to receive.\n")
        return

    # Reply with client hash (serves as implicit READY)
    cli_msg = f"{CTL_GIT_CLI}|{transfer_id}|{client_hash}"
    pyperclip.copy(cli_msg)
    last_written = cli_msg
    print(f"\n   Client hash → clipboard. Waiting for bundle chunks …\n")

    # First message: JOB (forward sync) or CANDIDATES (reverse/diverge negotiation)
    payloads:           dict[int, str] = {}
    total:              int | None     = None
    full_hash_expected: str | None     = None

    def is_job_or_candidates(clip: str) -> dict[str, object] | None:
        if clip == last_written:
            return None
        job = parse_job_msg(clip)
        if job and job["transfer_id"] == transfer_id:
            return {"_kind": "job", "job": job}
        if clip.startswith(f"{CTL_GIT_CANDIDATES}|"):
            parts = clip.split("|", 2)
            if len(parts) == 3 and parts[1] == transfer_id:
                hashes = [h for h in parts[2].split(",") if h]
                return {"_kind": "candidates", "hashes": hashes}
        return None

    try:
        first_msg = poll_clipboard(is_job_or_candidates, timeout, label="server response")
    except TimeoutError:
        print("❌ Timed out waiting for server.")
        pyperclip.copy(make_err(transfer_id, "timeout"))
        sys.exit(1)

    assert isinstance(first_msg, dict)

    if first_msg["_kind"] == "job":
        # ── Forward sync: server is ahead — confirm and start receiving ───────
        job: dict = first_msg["job"]  # type: ignore[assignment]
        # Auto-resume if we already have chunks for this transfer
        last_rx = 0
        if _receiver_dir(transfer_id).exists():
            try:
                _, _tot, _fhash, existing = load_receiver_state(transfer_id)
                if existing:
                    last_rx = max(existing.keys())
                    payloads.update(existing)
                    total = int(_tot)
                    full_hash_expected = str(_fhash)
                    print(f"\n🔄 Auto-resuming git job #{job['order']} [{transfer_id}]")
                    print(f"   Already have chunks: {sorted(existing.keys())}\n")
            except (FileNotFoundError, KeyError):
                pass

        if not payloads:
            print(f"\n📋 Git job #{job['order']} [{transfer_id}]:")
            print(f"   Repo    : {job['name']}")
            print(f"   Chunks  : {job['total']}  ({job['size_kb']:.1f} KB total)")
            if not yes:
                try:
                    ans = input("\n   Accept and start receiving? [Y/n] ").strip().lower()
                except KeyboardInterrupt:
                    print("\nAborted.")
                    sys.exit(0)
                if ans not in ("", "y", "yes"):
                    print("   Declined.")
                    sys.exit(0)
        ready_msg = make_ready(transfer_id, last_rx)
        pyperclip.copy(ready_msg)
        last_written = ready_msg
        print(f"📣 READY → clipboard. Waiting for chunks …\n")
        # Fall through to while loop below

    elif first_msg["_kind"] == "candidates":
        # ── Base negotiation: find common ancestor, then send bundle ──────────
        candidate_hashes: list[str] = [str(h) for h in first_msg["hashes"]]  # type: ignore[arg-type]
        base_hash = NULL_HASH
        for h in candidate_hashes:
            if git_hash_exists(dest_dir, h):
                base_hash = h
                break

        if base_hash == NULL_HASH:
            print(f"\n⚠️  No common base found — will send full history.")
        else:
            print(f"\n   Common base  : {base_hash[:12]}…")

        base_msg = f"{CTL_GIT_BASE}|{transfer_id}|{base_hash}"
        pyperclip.copy(base_msg)
        last_written = base_msg
        print(f"   Base hash → clipboard. Waiting for REVERSE signal …")

        def is_git_reverse(clip: str) -> dict[str, str] | None:
            if not clip.startswith(f"{CTL_GIT_REVERSE}|"):
                return None
            parts = clip.split("|", 2)
            if len(parts) == 3 and parts[1] == transfer_id:
                return {"base_hash": parts[2]}
            return None

        try:
            reverse_ctl = poll_clipboard(is_git_reverse, timeout, ignore_own=base_msg,
                                         label="REVERSE signal")
        except TimeoutError:
            print("❌ Timed out waiting for REVERSE signal.")
            sys.exit(1)

        assert isinstance(reverse_ctl, dict)
        srv_hash_for_bundle: str = str(reverse_ctl.get("base_hash", NULL_HASH))
        bundle_from: str = srv_hash_for_bundle[:12] + "…" if srv_hash_for_bundle != NULL_HASH else "(full history)"

        print(f"\n⚠️  Server requests reverse sync (client → server).")
        print(f"   Client HEAD  : {client_hash[:12]}…")
        print(f"   Bundle from  : {bundle_from}")
        if not yes:
            try:
                ans = input("\n   Send your commits to the server? [Y/n] ").strip().lower()
            except KeyboardInterrupt:
                print("\nAborted.")
                sys.exit(0)
            if ans not in ("", "y", "yes"):
                print("   Declined.")
                sys.exit(0)

        confirm_msg = f"{CTL_GIT_CONFIRM}|{transfer_id}"
        pyperclip.copy(confirm_msg)
        last_written = confirm_msg
        print("\n   Confirmed → clipboard. Preparing bundle …\n")

        with tempfile.TemporaryDirectory() as tmpdir:
            bundle_path = Path(tmpdir) / "sync.bundle"
            print("📦 Creating git bundle …")
            try:
                git_create_bundle(dest_dir, bundle_path, srv_hash_for_bundle)
            except RuntimeError as e:
                print(f"❌ Failed to create git bundle: {e}")
                sys.exit(1)

            bundle_bytes = bundle_path.read_bytes()
            print(f"   Bundle size  : {len(bundle_bytes) / 1024:.1f} KB")
            password = get_password()
            encrypted  = encrypt(bundle_bytes, password)
            b64_full   = base64.b64encode(encrypted).decode()
            full_hash  = hashlib.sha256(b64_full.encode()).hexdigest()[:32]
            chunk_size = chunk_kb * 1024
            raws       = [b64_full[i:i + chunk_size] for i in range(0, len(b64_full), chunk_size)]
            rev_total  = len(raws)
            chunk_data = {i: raw for i, raw in enumerate(raws, 1)}

            print(f"   Chunks       : {rev_total} × ≤{chunk_kb} KB")
            save_sender_state(transfer_id, "git.bundle", rev_total, full_hash, chunk_kb, raws)

            for i in range(1, rev_total + 1):
                raw        = chunk_data[i]
                chunk_text = build_chunk(raw, transfer_id, i, rev_total, full_hash, "git.bundle")
                pyperclip.copy(chunk_text)
                last_written = chunk_text
                print(f"📤 Part {i}/{rev_total}  [ID:{transfer_id}]  → clipboard")

                if i < rev_total:
                    def is_ack_rev(clip: str, _i: int = i) -> bool:
                        ctl = parse_control(clip)
                        return (ctl is not None and ctl["type"] == CTL_ACK
                                and ctl["transfer_id"] == transfer_id
                                and ctl["index"] == _i)

                    try:
                        poll_clipboard(is_ack_rev, timeout, ignore_own=chunk_text,
                                       label=f"ACK for part {i}")
                    except TimeoutError:
                        print(f"❌ Timed out waiting for ACK of part {i}.")
                        sys.exit(1)
                    delete_sender_chunk(transfer_id, i)
                    print(f"   ✅ ACK received for part {i}/{rev_total}")
                else:
                    def is_done_or_err_rev(clip: str) -> bool:
                        msg = parse_control(clip)
                        return (msg is not None and msg["transfer_id"] == transfer_id
                                and msg["type"] in (CTL_DONE, CTL_ERR))

                    print(f"   ⏳ Waiting for server DONE signal …")
                    try:
                        poll_clipboard(is_done_or_err_rev, timeout, ignore_own=chunk_text,
                                       label="DONE or ERR signal")
                    except TimeoutError:
                        print("❌ Timed out waiting for DONE signal.")
                        sys.exit(1)
                    ctl_msg = parse_control(pyperclip.paste())
                    if ctl_msg and ctl_msg["type"] == CTL_ERR:
                        print(f"❌ Server reported error: {ctl_msg.get('reason', '?')}")
                        sys.exit(1)

        clear_transfer_state(transfer_id)
        print("\n🎉 Reverse git sync complete — commits delivered to server!\n")
        return

    # ── Receive all chunks ────────────────────────────────────────────────────
    while True:
        if total is not None:
            missing = [i for i in range(1, total + 1) if i not in payloads]
            if not missing:
                break
            next_expected = missing[0]
        else:
            next_expected = None

        def is_valid_chunk(clip: str) -> ChunkDict | None:
            if clip == last_written:
                return None
            parsed, _ = parse_chunk_verbose(clip)
            if parsed is None:
                return None
            if parsed["transfer_id"] != transfer_id:
                return None
            if next_expected is not None and parsed["index"] != next_expected:
                return None
            return parsed

        label = f"part {next_expected}" if next_expected else "part 1"
        try:
            parsed = poll_clipboard(is_valid_chunk, timeout, label=label)
        except TimeoutError:
            print(f"❌ Timed out waiting for {label}.")
            pyperclip.copy(make_err(transfer_id, "timeout"))
            sys.exit(1)

        assert isinstance(parsed, dict)

        if total is None:
            total              = parsed["total"]
            full_hash_expected = parsed["full_hash"]
            print(f"🔑 Locked onto transfer [{transfer_id}]  ({total} part(s) total)")

        idx = parsed["index"]
        if idx not in payloads:
            payloads[idx] = parsed["payload"]
            save_receiver_chunk(transfer_id, "git.bundle", parsed["total"],
                                parsed["full_hash"], idx, parsed["payload"])
            print(f"   ✅ Part {idx}/{total}  received and saved")
        else:
            print(f"   ℹ️  Part {idx} duplicate — ignored")

        missing_after = [i for i in range(1, total + 1) if i not in payloads]
        if missing_after:
            ack = make_ack(transfer_id, idx)
            pyperclip.copy(ack)
            last_written = ack
            print(f"   📣 ACK|{idx} → clipboard  (still need: {missing_after})")

    # Signal done
    assert total is not None and full_hash_expected is not None
    done_msg = make_done(transfer_id)
    pyperclip.copy(done_msg)
    print(f"\n📣 DONE → clipboard")

    # Reassemble + decrypt
    print("\n🔧 Reassembling …")
    b64_full = "".join(payloads[i] for i in range(1, total + 1))
    if hashlib.sha256(b64_full.encode()).hexdigest()[:32] != full_hash_expected:
        print("❌ Payload hash mismatch — data corrupted or tampered with.")
        sys.exit(1)
    print("   Hash verified ✓")

    print("🔓 Decrypting …")
    password = get_password()
    try:
        bundle_bytes = decrypt(base64.b64decode(b64_full), password)
    except Exception as e:
        print(f"❌ Decryption failed: {e}")
        sys.exit(1)

    # Write bundle to a temp file and apply
    with tempfile.NamedTemporaryFile(suffix=".bundle", delete=False) as f:
        bundle_tmp = Path(f.name)
        f.write(bundle_bytes)
    try:
        print("🌿 Applying git bundle …")
        git_apply_bundle(dest_dir, bundle_tmp)
        print("\n🎉 Git sync complete!\n")
    except RuntimeError as e:
        print(f"❌ Failed to apply git bundle: {e}")
        sys.exit(1)
    finally:
        bundle_tmp.unlink(missing_ok=True)
        clear_transfer_state(transfer_id)


def receiver_git(dest_dir: Path, timeout: float, chunk_kb: int, yes: bool = False) -> None:
    """Git sync receiver — polls for server git hash then delegates."""
    dest_dir.mkdir(parents=True, exist_ok=True)
    print("\n🌿 Git mode — receiver. Waiting for server …\n")

    def is_git_srv(clip: str) -> dict[str, str] | None:
        if not clip.startswith(f"{CTL_GIT_SRV}|"):
            return None
        parts = clip.split("|")
        if len(parts) != 3:
            return None
        return {"transfer_id": parts[1], "server_hash": parts[2]}

    try:
        srv_ctl = poll_clipboard(is_git_srv, timeout, label="server git hash")
    except TimeoutError:
        print("❌ Timed out waiting for server git hash.")
        sys.exit(1)

    assert isinstance(srv_ctl, dict)
    srv: dict[str, str] = srv_ctl  # type: ignore[assignment]
    _receiver_git_after_srv(dest_dir, timeout, chunk_kb, yes,
                            srv["transfer_id"], srv["server_hash"])


# ── Entry point ───────────────────────────────────────────────────────────────

def main() -> None:
    parser = argparse.ArgumentParser(
        description=(
            "clipcp — transfer files/folders via clipboard.\n\n"
            "  Sender:   clipcp <path> [--chunk-size KB] [--skip PAT …]\n"
            "  Receiver: clipcp [--dest DIR]\n"
            "  Resume:   clipcp --resume <transfer_id>  (on either machine)\n"
            "  Clear:    clipcp --clear\n\n"
            f"Password via ${ENV_PASSWORD_VAR} or interactive prompt."
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    parser.add_argument("target", nargs="?",
                        help="File or directory to send (omit for receiver mode)")
    parser.add_argument("--auto", action=argparse.BooleanOptionalAction, default=True,
                        help="Auto handshake (default: on). --no-auto for manual ENTER-driven mode.")
    parser.add_argument("--chunk-size", type=int, default=DEFAULT_CHUNK_KB, metavar="KB",
                        help=f"Max chunk size in KB (default: {DEFAULT_CHUNK_KB})")
    parser.add_argument("--dest", type=Path, default=Path("."), metavar="DIR",
                        help="Destination directory in receiver mode (default: .)")
    parser.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT, metavar="S",
                        help=f"Seconds to wait for each step (default: {DEFAULT_TIMEOUT})")
    parser.add_argument("--skip", nargs="+", default=[], metavar="PATTERN",
                        help="Skip files/dirs matching pattern (e.g. --skip .git *.pyc)")
    parser.add_argument("--git", action="store_true",
                        help="Git sync mode (sender only — receiver auto-detects from job announcement)")
    parser.add_argument("--yes", "-y", action="store_true",
                        help="Skip confirmation prompts on both sender and receiver")
    # Transfer-management commands — transfer_id is optional (auto-resolved if only one job exists)
    parser.add_argument("--resume", nargs="?", const=None, default=argparse.SUPPRESS,
                        metavar="ID_OR_ORDER",
                        help="Resume an interrupted transfer (hex transfer ID or numeric order #)")
    parser.add_argument("--extract", nargs="?", const=None, default=argparse.SUPPRESS,
                        metavar="TRANSFER_ID",
                        help="Extract a fully-received transfer from saved state")
    parser.add_argument("--clear", nargs="?", const=None, default=argparse.SUPPRESS,
                        metavar="TRANSFER_ID",
                        help="Clear saved state for a transfer (omit ID to auto-resolve single job)")
    parser.add_argument("--list", action="store_true",
                        help="List all saved transfers and exit")
    args = parser.parse_args()

    # ── --list ────────────────────────────────────────────────────────────────
    if args.list:
        list_transfers()
        sys.exit(0)

    # ── --extract ─────────────────────────────────────────────────────────────
    if hasattr(args, "extract"):
        tid = resolve_transfer_id(args.extract, "--extract", role="receiver")
        try:
            archive_name, total, full_hash, payloads = load_receiver_state(tid)
        except (FileNotFoundError, KeyError) as e:
            print(f"❌ Cannot load receiver state for [{tid}]: {e}")
            sys.exit(1)
        missing = [i for i in range(1, total + 1) if i not in payloads]
        if missing:
            print(f"❌ Cannot extract [{tid}]: missing chunks {missing}")
            print(f"   Use --resume {tid} to receive the remaining chunks first.")
            sys.exit(1)
        if archive_name == "git.bundle":
            print(f"🌿 Applying git bundle for [{tid}] …")
            password = get_password()
            b64_full = "".join(payloads[i] for i in range(1, total + 1))
            if hashlib.sha256(b64_full.encode()).hexdigest()[:32] != full_hash:
                print("❌ Payload hash mismatch.")
                sys.exit(1)
            try:
                bundle_bytes = decrypt(base64.b64decode(b64_full), password)
            except Exception as e:
                print(f"❌ Decryption failed: {e}")
                sys.exit(1)
            with tempfile.NamedTemporaryFile(suffix=".bundle", delete=False) as f:
                bundle_tmp = Path(f.name)
                f.write(bundle_bytes)
            try:
                git_apply_bundle(args.dest, bundle_tmp)
                print("\n🎉 Git sync complete!\n")
            except RuntimeError as e:
                print(f"❌ {e}")
                sys.exit(1)
            finally:
                bundle_tmp.unlink(missing_ok=True)
        else:
            print(f"📦 All {total} chunk(s) present for [{tid}]. Extracting …")
            password = get_password()
            finalise_transfer(payloads, total, full_hash, password, args.dest)
        clear_transfer_state(tid)
        sys.exit(0)

    # ── --clear ───────────────────────────────────────────────────────────────
    if hasattr(args, "clear"):
        if args.clear is None:
            # No ID given — auto-resolve single job or clear all if confirmed
            if not CLIPCP_DIR.exists() or not any(CLIPCP_DIR.iterdir()):
                print("   Nothing to clear.")
                sys.exit(0)
            all_jobs = [d.name for d in sorted(CLIPCP_DIR.iterdir()) if d.is_dir()]
            if len(all_jobs) == 1:
                tid = all_jobs[0]
                clear_transfer_state(tid)
                print(f"   🗑️  Cleared transfer [{tid}]")
            else:
                print(f"❌ Multiple transfers found — specify which to clear:")
                for t in all_jobs:
                    print(f"   {t}")
                print(f"\n   Or clear all: clipcp --clear all")
                sys.exit(1)
        elif args.clear == "all":
            clear_all_state()
        else:
            clear_transfer_state(args.clear)
            print(f"   🗑️  Cleared transfer [{args.clear}]")
        sys.exit(0)

    # ── --resume (auto-detect sender vs receiver from saved state) ────────────
    if hasattr(args, "resume"):
        tid = resolve_transfer_id(args.resume, "--resume")
        is_sender   = _sender_dir(tid).exists()
        is_receiver = _receiver_dir(tid).exists()
        if is_sender and not is_receiver:
            sender_auto(None, args.chunk_size, args.timeout, args.skip, tid, args.yes)
        elif is_receiver and not is_sender:
            receiver_auto(args.dest, args.timeout, tid, args.yes, args.chunk_size)
        elif is_sender and is_receiver:
            print(f"Both sender and receiver state found for [{tid}].")
            try:
                role = input("Resume as [s]ender or [r]eceiver? ").strip().lower()
            except KeyboardInterrupt:
                sys.exit(0)
            if role.startswith("s"):
                sender_auto(None, args.chunk_size, args.timeout, args.skip, tid, args.yes)
            else:
                receiver_auto(args.dest, args.timeout, tid, args.yes, args.chunk_size)
        else:
            print(f"❌ No saved state found for transfer [{tid}].")
            sys.exit(1)
        return

    # ── Normal operation ──────────────────────────────────────────────────────
    if args.target:
        target = Path(args.target)
        if not target.exists():
            print(f"ERROR: '{target}' does not exist.")
            sys.exit(1)
        if args.git:
            sender_git(target, args.chunk_size, args.timeout, args.yes)
        elif args.auto:
            sender_auto(target, args.chunk_size, args.timeout, args.skip, None, args.yes)
        else:
            sender_manual(target, args.chunk_size, args.skip)
    else:
        if args.auto:
            receiver_auto(args.dest, args.timeout, None, args.yes, args.chunk_size)
        else:
            receiver_manual(args.dest)


if __name__ == "__main__":
    main()
