#!/usr/bin/env bash
set -euo pipefail

usage() {
  cat <<'EOF'
Usage:
  migrate-unified-runtime-volume.sh [--expected-repositories N | --allow-empty] verify SOURCE_ROOT TARGET_ROOT
  migrate-unified-runtime-volume.sh [--expected-repositories N | --allow-empty] copy   SOURCE_ROOT TARGET_ROOT

Run only after stopping every writer that can mutate MeshX/AGS filesystem
state. SOURCE_ROOT is the old meshx-data mount and TARGET_ROOT is the new
agentteam-data mount. The source must contain meshx/ags-repos. By default at least
one bare repository is required; --allow-empty is the explicit zero-data gate.
The command never deletes source data or overwrites a non-empty target subtree.
EOF
}

fail() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }

expected_repositories=""
allow_empty=false
while [[ $# -gt 0 ]]; do
  case "$1" in
    --expected-repositories)
      [[ $# -ge 2 ]] || { usage >&2; exit 64; }
      expected_repositories="$2"
      shift 2
      ;;
    --allow-empty)
      allow_empty=true
      shift
      ;;
    --help|-h)
      usage
      exit 0
      ;;
    *) break ;;
  esac
done
[[ $# -eq 3 ]] || { usage >&2; exit 64; }
[[ "$allow_empty" != true || -z "$expected_repositories" ]] || fail "--allow-empty and --expected-repositories are mutually exclusive"
if [[ -n "$expected_repositories" ]]; then
  [[ "$expected_repositories" =~ ^[0-9]+$ ]] || fail "expected repository count must be a non-negative integer"
fi

mode="$1"
source_root="$2"
target_root="$3"
[[ "$mode" == "verify" || "$mode" == "copy" ]] || { usage >&2; exit 64; }

command -v git >/dev/null || fail "git is required"
command -v python3 >/dev/null || fail "python3 is required"
[[ -d "$source_root" ]] || fail "source data root does not exist: $source_root"
[[ -d "$target_root" ]] || fail "target data root does not exist: $target_root"

source_root="$(cd "$source_root" && pwd -P)"
target_root="$(cd "$target_root" && pwd -P)"
[[ "$source_root" != "$target_root" ]] || fail "source and target data roots must differ"
[[ -d "$source_root/meshx/ags-repos" && ! -L "$source_root/meshx/ags-repos" ]] || fail "source is missing required meshx/ags-repos directory"

# Source inventory is a precondition, not a post-copy check. Validate it before
# creating any target directory so a wrong expected count leaves target intact
# and can be corrected by rerunning the same command.
allow_empty_arg=0
[[ "$allow_empty" == true ]] && allow_empty_arg=1
python3 - "$source_root" "$expected_repositories" "$allow_empty_arg" <<'PY'
import os
import subprocess
import sys
from pathlib import Path

root = Path(sys.argv[1]) / "meshx/ags-repos"
expected_raw = sys.argv[2]
expected = int(expected_raw) if expected_raw else None
allow_empty = sys.argv[3] == "1"

repositories = []
for head in root.rglob("HEAD"):
    candidate = head.parent
    result = subprocess.run(["git", "--git-dir", str(candidate), "rev-parse", "--is-bare-repository"], text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
    if result.returncode == 0 and result.stdout.strip() == "true":
        repositories.append(candidate)
repositories = sorted(set(repositories))

count = len(repositories)
if expected is not None and count != expected:
    raise SystemExit(f"source AGS repository count mismatch: got {count}, expected {expected}")
if expected is None and count == 0 and not allow_empty:
    raise SystemExit("source contains zero AGS repositories; pass --allow-empty only for an explicitly verified empty environment")
for repository in repositories:
    fsck = subprocess.run(["git", "--git-dir", str(repository), "fsck", "--full", "--strict", "--no-progress"], text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    if fsck.returncode != 0:
        raise SystemExit(f"git fsck failed for {repository}: {fsck.stderr.strip()}")
    subprocess.run(["git", "--git-dir", str(repository), "for-each-ref", "--format=%(refname) %(objectname)", "--sort=refname"], check=True, stdout=subprocess.DEVNULL)
    subprocess.run(["git", "--git-dir", str(repository), "count-objects", "-v"], check=True, stdout=subprocess.DEVNULL)
PY

subtrees=(
  meshx/ags-repos
  meshx/automation-blobs
  meshx/automation-runner
  meshx/kb-source-mirrors
  meshx/source-mirrors
  meshx/public-repo-cache
  meshx/kb-code-wiki
)

verify_roots() {
python3 - "$1" "$2" "$expected_repositories" "$allow_empty_arg" <<'PY'
import hashlib
import json
import os
import stat
import subprocess
import sys
from pathlib import Path

source_root = Path(sys.argv[1])
target_root = Path(sys.argv[2])
expected_raw = sys.argv[3]
expected = int(expected_raw) if expected_raw else None
allow_empty = sys.argv[4] == "1"
subtrees = [
    Path("meshx/ags-repos"),
    Path("meshx/automation-blobs"),
    Path("meshx/automation-runner"),
    Path("meshx/kb-source-mirrors"),
    Path("meshx/source-mirrors"),
    Path("meshx/public-repo-cache"),
    Path("meshx/kb-code-wiki"),
]

def regular_tree(root: Path):
    result = {}
    if not root.exists():
        return result
    for directory, dirnames, filenames in os.walk(root, followlinks=False):
        directory_path = Path(directory)
        for name in sorted(dirnames + filenames):
            path = directory_path / name
            relative = path.relative_to(root).as_posix()
            info = path.lstat()
            if stat.S_ISLNK(info.st_mode):
                result[relative] = ("symlink", os.readlink(path))
            elif stat.S_ISREG(info.st_mode):
                digest = hashlib.sha256()
                with path.open("rb") as stream:
                    for chunk in iter(lambda: stream.read(1024 * 1024), b""):
                        digest.update(chunk)
                result[relative] = ("file", info.st_size, digest.hexdigest())
            elif stat.S_ISDIR(info.st_mode):
                result[relative] = ("dir",)
            else:
                raise SystemExit(f"unsupported filesystem entry: {path}")
    return result

for subtree in subtrees:
    source = regular_tree(source_root / subtree)
    target = regular_tree(target_root / subtree)
    if source != target:
        missing = sorted(set(source) - set(target))[:10]
        extra = sorted(set(target) - set(source))[:10]
        changed = sorted(key for key in set(source) & set(target) if source[key] != target[key])[:10]
        raise SystemExit(f"filesystem verification failed for {subtree}: missing={missing} extra={extra} changed={changed}")

def bare_repositories(root: Path):
    repositories = []
    for head in root.rglob("HEAD"):
        candidate = head.parent
        result = subprocess.run(["git", "--git-dir", str(candidate), "rev-parse", "--is-bare-repository"], text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
        if result.returncode == 0 and result.stdout.strip() == "true":
            repositories.append(candidate)
    return sorted(set(repositories))

def repo_snapshot(root: Path):
    snapshot = {}
    for repository in bare_repositories(root):
        relative = repository.relative_to(root).as_posix()
        fsck = subprocess.run(["git", "--git-dir", str(repository), "fsck", "--full", "--strict", "--no-progress"], text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
        if fsck.returncode != 0:
            raise SystemExit(f"git fsck failed for {repository}: {fsck.stderr.strip()}")
        refs = subprocess.run(["git", "--git-dir", str(repository), "for-each-ref", "--format=%(refname) %(objectname)", "--sort=refname"], check=True, text=True, stdout=subprocess.PIPE).stdout
        head = subprocess.run(["git", "--git-dir", str(repository), "symbolic-ref", "-q", "HEAD"], text=True, stdout=subprocess.PIPE).stdout.strip()
        objects = subprocess.run(["git", "--git-dir", str(repository), "count-objects", "-v"], check=True, text=True, stdout=subprocess.PIPE).stdout
        snapshot[relative] = {"head": head, "refs": refs, "objects": objects}
    return snapshot

source_repos = repo_snapshot(source_root / "meshx/ags-repos")
count = len(source_repos)
if expected is not None and count != expected:
    raise SystemExit(f"source AGS repository count mismatch: got {count}, expected {expected}")
if expected is None and count == 0 and not allow_empty:
    raise SystemExit("source contains zero AGS repositories; pass --allow-empty only for an explicitly verified empty environment")
target_repos = repo_snapshot(target_root / "meshx/ags-repos")
if source_repos != target_repos:
    raise SystemExit("AGS bare repository HEAD/ref/object verification failed")

print(json.dumps({"status": "verified", "repositories": count}, sort_keys=True))
PY
}

present_subtrees=()
for subtree in "${subtrees[@]}"; do
  source="$source_root/$subtree"
  if [[ ! -e "$source" ]]; then
    continue
  fi
  [[ -d "$source" && ! -L "$source" ]] || fail "source subtree must be a real directory: $source"
  present_subtrees+=("$subtree")
done

if [[ "$mode" == "verify" ]]; then
  verify_roots "$source_root" "$target_root"
  exit 0
fi

target_meshx="$target_root/meshx"
if [[ -e "$target_meshx" ]]; then
  [[ -d "$target_meshx" && ! -L "$target_meshx" ]] || fail "target MeshX root must be a real directory: $target_meshx"
  if [[ -n "$(find "$target_meshx" -mindepth 1 -maxdepth 1 -print -quit)" ]]; then
    # An interrupted process may have been killed immediately after the one
    # atomic publication rename. Treat the exact verified snapshot as an
    # idempotent success, but never merge into or overwrite a divergent target.
    if verify_roots "$source_root" "$target_root"; then
      exit 0
    fi
    fail "target MeshX root is non-empty and does not match source: $target_meshx"
  fi
fi

# Copy every subtree into one hidden staging root and verify the complete
# snapshot before publishing anything. A copy or verification failure can
# therefore remove staging without leaving a partially populated live target.
staging_root="$target_root/.unified-volume-stage"
[[ ! -L "$staging_root" ]] || fail "staging root must not be a symlink: $staging_root"
rm -rf "$staging_root"
mkdir "$staging_root"
cleanup_staging() { rm -rf "$staging_root"; }
trap cleanup_staging EXIT INT TERM

for subtree in "${present_subtrees[@]}"; do
  source="$source_root/$subtree"
  staging="$staging_root/$subtree"
  mkdir -p "$(dirname "$staging")"
  mkdir "$staging"
  # cp -a preserves mode, ownership (when permitted), timestamps and links.
  if ! cp -a "$source"/. "$staging"/; then
    fail "copy failed for $subtree; global staging was removed and retry is safe"
  fi
done

if ! verify_roots "$source_root" "$staging_root" >/dev/null; then
  fail "staged volume verification failed; global staging was removed and retry is safe"
fi

# Re-check the single publication boundary after staging. Publishing the whole
# MeshX tree in one rename prevents a crash from exposing only some subtrees.
if [[ -e "$target_meshx" ]]; then
  [[ -d "$target_meshx" && ! -L "$target_meshx" ]] || fail "target MeshX root changed while staging: $target_meshx"
  [[ -z "$(find "$target_meshx" -mindepth 1 -maxdepth 1 -print -quit)" ]] || fail "target MeshX root changed while staging: $target_meshx"
  rmdir "$target_meshx"
fi
if ! mv "$staging_root/meshx" "$target_meshx"; then
  fail "publish failed for MeshX data; global staging was removed and retry is safe"
fi

if ! verify_roots "$source_root" "$target_root"; then
  rm -rf "$target_meshx"
  fail "published volume verification failed; published copies were removed and retry is safe"
fi

trap - EXIT INT TERM
cleanup_staging
