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

APP_NAME="${0##*/}"
VERSION="1.0.0"

usage() {
  cat <<'EOF'
civipatch - generate a CiviDesk-standard patch from git diff

Usage:
  civipatch [OUTPUT.patch] [options] [-- <path-or-git-diff-args>...]

Examples:
  civipatch small-change.patch \
    -r "https://my.help.cividesk.com/..." \
    -m "6.12" \
    -d "Implements a small change."

  civipatch activity-fix.patch \
    -r "https://my.help.cividesk.com/..." \
    --ported-to "CiviCRM 6.5.1" \
    -d "Adds the activity Modified by selector column." \
    -- CRM/Activity/BAO/Activity.php templates/CRM/Activity/Selector/Selector.tpl

  civipatch staged.patch --cached -d "Implements staged changes only."

Options:
  -o, --output FILE          Patch file to write. Use "-" for stdout.
      --stdout              Write patch to stdout.
  -F, --force               Overwrite an existing output file.
  -d, --desc TEXT           Patch description/body text.
  -f, --desc-file FILE      Read patch description/body from FILE. Use "-" for stdin.
  -r, --ref VALUE           Ref metadata value. Default: TBD.
  -s, --source VALUE        Source metadata value. Default: inferred from git.
  -m, --merged-in VALUE     MergedIn metadata value. Default: TBD.
      --original-target VAL Optional OriginalTarget metadata value.
      --ported-to VAL       Optional PortedTo metadata value.
      --title VALUE         First line of the patch preamble. Default: output basename.
      --cached, --staged    Generate diff from staged changes.
      --binary              Include binary diff data.
  -U, --unified N           Number of context lines for git diff.
      --verify              Verify generated patch with git apply --check in a temp worktree.
      --allow-empty         Allow writing a header-only patch when git diff is empty.
      --self-test           Run the built-in test suite.
      --version             Print version.
  -h, --help                Show this help.

Pass path filters and advanced git-diff arguments after "--". This avoids
ambiguity between wrapper options, output filename, and git diff options.
EOF
}

die() {
  printf '%s: %s\n' "$APP_NAME" "$*" >&2
  exit 1
}

note() {
  printf '%s\n' "$*" >&2
}

cleanup_paths=()
cleanup_worktrees=()
git_root_for_cleanup=""

cleanup() {
  local path
  for path in "${cleanup_worktrees[@]:-}"; do
    if [ -n "$git_root_for_cleanup" ]; then
      git -C "$git_root_for_cleanup" worktree remove --force "$path" >/dev/null 2>&1 || true
    fi
    rm -rf "$path" >/dev/null 2>&1 || true
  done
  for path in "${cleanup_paths[@]:-}"; do
    rm -rf "$path" >/dev/null 2>&1 || true
  done
}
trap cleanup EXIT

mktemp_file() {
  local file
  file="$(mktemp "${TMPDIR:-/tmp}/civipatch.XXXXXX")"
  cleanup_paths+=("$file")
  printf '%s\n' "$file"
}

mktemp_dir() {
  local dir
  dir="$(mktemp -d "${TMPDIR:-/tmp}/civipatch.XXXXXX")"
  cleanup_paths+=("$dir")
  printf '%s\n' "$dir"
}

require_git_repo() {
  git rev-parse --show-toplevel 2>/dev/null || die "not inside a git repository"
}

normalize_url() {
  local url="${1%.git}"
  case "$url" in
    git@*:*)
      local host="${url%%:*}"
      local path="${url#*:}"
      host="${host#git@}"
      printf 'https://%s/%s\n' "$host" "$path"
      ;;
    ssh://git@*/*)
      url="${url#ssh://git@}"
      local host="${url%%/*}"
      local path="${url#*/}"
      printf 'https://%s/%s\n' "$host" "$path"
      ;;
    *)
      printf '%s\n' "$url"
      ;;
  esac
}

infer_source() {
  local sha ref remote url
  sha="$(git rev-parse HEAD 2>/dev/null)" || die "cannot infer source before the first commit"
  ref="$(git describe --tags --exact-match HEAD 2>/dev/null || true)"
  if [ -z "$ref" ]; then
    ref="$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true)"
  fi
  if [ -z "$ref" ]; then
    ref="$(git rev-parse --short HEAD 2>/dev/null)"
  fi

  remote="$(git config --get remote.origin.url 2>/dev/null || true)"
  if [ -n "$remote" ]; then
    url="$(normalize_url "$remote")"
    printf '%s/tree/%s (baseline commit %s)\n' "$url" "$ref" "$sha"
  else
    printf 'baseline commit %s\n' "$sha"
  fi
}

slugify() {
  local slug
  slug="$(printf '%s' "$1" | tr '/[:space:]' '--' | tr -cd 'A-Za-z0-9._-')"
  [ -n "$slug" ] || slug="changes"
  printf '%s\n' "$slug"
}

read_description_file() {
  local file="$1"
  if [ "$file" = "-" ]; then
    cat
    return
  fi
  [ -r "$file" ] || die "cannot read description file: $file"
  cat "$file"
}

write_patch() {
  local target="$1"
  local patch_title="$2"
  local diff_file="$3"

  {
    printf '%s\n' "$patch_title"
    printf '%s\n' '----'
    printf 'Ref: %s\n' "$ref"
    printf 'Source: %s\n' "$source"
    [ -z "$original_target" ] || printf 'OriginalTarget: %s\n' "$original_target"
    [ -z "$ported_to" ] || printf 'PortedTo: %s\n' "$ported_to"
    printf 'MergedIn: %s\n' "$merged_in"
    printf '\n%s\n\n' "$description"
    printf '%s\n' '----'
    printf '%s\n'
    cat "$diff_file"
  } > "$target"
}

verify_patch() {
  local patch_file="$1"
  local repo_root="$2"
  local verify_dir

  verify_dir="$(mktemp_dir)"
  cleanup_worktrees+=("$verify_dir")
  git_root_for_cleanup="$repo_root"

  git -C "$repo_root" worktree add --detach "$verify_dir" HEAD >/dev/null 2>&1
  git -C "$verify_dir" apply --check "$patch_file"
  note "Verified with git apply --check in a temporary worktree."
}

self_test() {
  local self="$1"
  local base apply_dir patch out desc
  local self_abs

  case "$self" in
    /*) self_abs="$self" ;;
    *) self_abs="$(cd "$(dirname "$self")" && pwd -P)/$(basename "$self")" ;;
  esac

  command -v git >/dev/null || die "self-test requires git"

  note "Running civipatch self-test..."
  base="$(mktemp_dir)"
  git -C "$base" init -q
  git -C "$base" config user.name "CiviPatch Test"
  git -C "$base" config user.email "civipatch@example.invalid"
  git -C "$base" remote add origin git@gitlab.com:civicrm/civicrm-core.git
  printf 'alpha\n' > "$base/a.txt"
  printf 'one\n' > "$base/b.txt"
  git -C "$base" add a.txt b.txt
  git -C "$base" commit -q -m "Initial sample"

  printf 'alpha\nbeta\n' > "$base/a.txt"
  patch="$base/unstaged.patch"
  (cd "$base" && LC_ALL=C LANG=C "$self_abs" "$patch" --force -r "https://my.help.cividesk.com/test/1" -m "6.12" -d "Implements an unstaged sample change." -- a.txt) >/dev/null
  grep -q '^Ref: https://my.help.cividesk.com/test/1$' "$patch" || die "self-test failed: missing Ref"
  grep -q '^Source: https://gitlab.com/civicrm/civicrm-core/tree/master (baseline commit ' "$patch" || die "self-test failed: inferred Source"
  grep -q '^MergedIn: 6.12$' "$patch" || die "self-test failed: missing MergedIn"
  apply_dir="$(mktemp_dir)"
  git -C "$base" worktree add --detach "$apply_dir" HEAD >/dev/null 2>&1
  git -C "$apply_dir" apply --check "$patch"
  git -C "$apply_dir" apply "$patch"
  grep -q '^beta$' "$apply_dir/a.txt" || die "self-test failed: unstaged patch content"
  git -C "$base" worktree remove --force "$apply_dir" >/dev/null
  git -C "$base" checkout -q -- a.txt

  printf 'alpha\nstaged\n' > "$base/a.txt"
  git -C "$base" add a.txt
  patch="$base/staged.patch"
  (cd "$base" && LC_ALL=C LANG=C "$self_abs" "$patch" --force --cached -d "Implements a staged sample change." --verify) >/dev/null
  grep -q '^+staged$' "$patch" || die "self-test failed: staged diff content"
  git -C "$base" reset -q --hard

  printf 'alpha\nonly-a\n' > "$base/a.txt"
  printf 'one\nalso-b\n' > "$base/b.txt"
  patch="$base/path-filter.patch"
  (cd "$base" && LC_ALL=C LANG=C "$self_abs" "$patch" --force -d "Implements only a.txt." -- a.txt) >/dev/null
  grep -q '^diff --git a/a.txt b/a.txt$' "$patch" || die "self-test failed: path filter missing a.txt"
  if grep -q '^diff --git a/b.txt b/b.txt$' "$patch"; then
    die "self-test failed: path filter included b.txt"
  fi
  git -C "$base" checkout -q -- a.txt b.txt

  printf 'alpha\nmetadata\n' > "$base/a.txt"
  desc="$base/description.txt"
  printf 'Implements description-file metadata.\nSecond sentence.\n' > "$desc"
  patch="$base/meta.patch"
  (cd "$base" && LC_ALL=C LANG=C "$self_abs" "$base/meta" --force --desc-file "$desc" --original-target "CiviCRM 5.39" --ported-to "CiviCRM 6.5.1" --title "custom-title.patch" -- a.txt) >/dev/null
  [ -f "$patch" ] || die "self-test failed: extension was not added"
  grep -q '^custom-title.patch$' "$patch" || die "self-test failed: custom title"
  grep -q '^OriginalTarget: CiviCRM 5.39$' "$patch" || die "self-test failed: OriginalTarget"
  grep -q '^PortedTo: CiviCRM 6.5.1$' "$patch" || die "self-test failed: PortedTo"
  grep -q '^Second sentence\.$' "$patch" || die "self-test failed: desc file body"

  if (cd "$base" && LC_ALL=C LANG=C "$self_abs" "$patch" -d "Should not overwrite." -- a.txt) >/dev/null 2>&1; then
    die "self-test failed: overwrite without --force succeeded"
  fi
  (cd "$base" && LC_ALL=C LANG=C "$self_abs" "$patch" --force -d "Overwrite allowed." -- a.txt) >/dev/null

  out="$base/stdout.patch"
  (cd "$base" && LC_ALL=C LANG=C "$self_abs" --stdout -d "Writes to stdout." -- a.txt) > "$out"
  grep -q '^changes.patch$' "$out" || die "self-test failed: stdout default title"
  apply_dir="$(mktemp_dir)"
  git -C "$base" worktree add --detach "$apply_dir" HEAD >/dev/null 2>&1
  git -C "$apply_dir" apply --check "$out"
  git -C "$base" worktree remove --force "$apply_dir" >/dev/null
  git -C "$base" checkout -q -- a.txt

  if (cd "$base" && LC_ALL=C LANG=C "$self_abs" "$base/empty.patch" --force -d "No diff.") >/dev/null 2>&1; then
    die "self-test failed: empty diff succeeded without --allow-empty"
  fi
  (cd "$base" && LC_ALL=C LANG=C "$self_abs" "$base/empty.patch" --force --allow-empty -d "Header only.") >/dev/null
  grep -q '^Header only\.$' "$base/empty.patch" || die "self-test failed: allow-empty patch"

  if command -v shellcheck >/dev/null; then
    shellcheck "$self_abs"
  fi

  note "Self-test passed."
}

output=""
description=""
description_file=""
ref="TBD"
source=""
merged_in="TBD"
original_target=""
ported_to=""
title=""
force=0
stdout=0
verify=0
allow_empty=0
diff_args=()

if [ "${1:-}" = "--self-test" ]; then
  self_test "$0"
  exit 0
fi

while [ "$#" -gt 0 ]; do
  case "$1" in
    -h|--help)
      usage
      exit 0
      ;;
    --version)
      printf '%s %s\n' "$APP_NAME" "$VERSION"
      exit 0
      ;;
    --self-test)
      self_test "$0"
      exit 0
      ;;
    -o|--output)
      [ "$#" -ge 2 ] || die "$1 requires a value"
      output="$2"
      shift 2
      ;;
    --output=*)
      output="${1#*=}"
      shift
      ;;
    --stdout)
      stdout=1
      output="-"
      shift
      ;;
    -F|--force)
      force=1
      shift
      ;;
    -d|--desc|--description)
      [ "$#" -ge 2 ] || die "$1 requires a value"
      description="$2"
      shift 2
      ;;
    --desc=*|--description=*)
      description="${1#*=}"
      shift
      ;;
    -f|--desc-file|--description-file)
      [ "$#" -ge 2 ] || die "$1 requires a value"
      description_file="$2"
      shift 2
      ;;
    --desc-file=*|--description-file=*)
      description_file="${1#*=}"
      shift
      ;;
    -r|--ref)
      [ "$#" -ge 2 ] || die "$1 requires a value"
      ref="$2"
      shift 2
      ;;
    --ref=*)
      ref="${1#*=}"
      shift
      ;;
    -s|--source)
      [ "$#" -ge 2 ] || die "$1 requires a value"
      source="$2"
      shift 2
      ;;
    --source=*)
      source="${1#*=}"
      shift
      ;;
    -m|--merged-in)
      [ "$#" -ge 2 ] || die "$1 requires a value"
      merged_in="$2"
      shift 2
      ;;
    --merged-in=*)
      merged_in="${1#*=}"
      shift
      ;;
    --original-target)
      [ "$#" -ge 2 ] || die "$1 requires a value"
      original_target="$2"
      shift 2
      ;;
    --original-target=*)
      original_target="${1#*=}"
      shift
      ;;
    --ported-to)
      [ "$#" -ge 2 ] || die "$1 requires a value"
      ported_to="$2"
      shift 2
      ;;
    --ported-to=*)
      ported_to="${1#*=}"
      shift
      ;;
    --title)
      [ "$#" -ge 2 ] || die "$1 requires a value"
      title="$2"
      shift 2
      ;;
    --title=*)
      title="${1#*=}"
      shift
      ;;
    --cached|--staged)
      diff_args+=("--cached")
      shift
      ;;
    --binary)
      diff_args+=("--binary")
      shift
      ;;
    -U|--unified)
      [ "$#" -ge 2 ] || die "$1 requires a value"
      diff_args+=("--unified=$2")
      shift 2
      ;;
    -U[0-9]*|--unified=*)
      diff_args+=("$1")
      shift
      ;;
    --verify)
      verify=1
      shift
      ;;
    --allow-empty)
      allow_empty=1
      shift
      ;;
    --)
      shift
      while [ "$#" -gt 0 ]; do
        diff_args+=("$1")
        shift
      done
      ;;
    -*)
      die "unknown wrapper option '$1'. Put advanced git diff args after --."
      ;;
    *)
      if [ -z "$output" ]; then
        output="$1"
      else
        die "unexpected argument '$1'. Put path filters after --."
      fi
      shift
      ;;
  esac
done

repo_root="$(require_git_repo)"

if [ "$stdout" -eq 1 ] || [ "$output" = "-" ]; then
  output="-"
else
  if [ -z "$output" ]; then
    output="$(slugify "$(git symbolic-ref --quiet --short HEAD 2>/dev/null || printf 'changes')").patch"
  fi
  case "$output" in
    *.patch) ;;
    *) output="${output}.patch" ;;
  esac
fi

if [ -n "$description_file" ]; then
  description="$(read_description_file "$description_file")"
fi
[ -n "$description" ] || description="Implements local changes."
[ -n "$source" ] || source="$(infer_source)"

diff_file="$(mktemp_file)"
git diff --no-ext-diff --no-color "${diff_args[@]}" > "$diff_file"

if ! grep -q '^diff --git ' "$diff_file"; then
  if [ "$allow_empty" -ne 1 ]; then
    die "git diff produced no patch content"
  fi
fi

if [ "$output" = "-" ]; then
  patch_title="${title:-changes.patch}"
  final_patch="$(mktemp_file)"
  write_patch "$final_patch" "$patch_title" "$diff_file"
  if [ "$verify" -eq 1 ]; then
    verify_patch "$final_patch" "$repo_root"
  fi
  cat "$final_patch"
else
  patch_title="${title:-$(basename "$output")}"
  output_dir="$(dirname "$output")"
  [ "$output_dir" = "." ] || mkdir -p "$output_dir"
  if [ -e "$output" ] && [ "$force" -ne 1 ]; then
    die "output file exists: $output (use --force to overwrite)"
  fi

  tmp_output="$(mktemp "${output_dir%/}/.civipatch.$(basename "$output").XXXXXX")"
  cleanup_paths+=("$tmp_output")
  write_patch "$tmp_output" "$patch_title" "$diff_file"
  if [ "$verify" -eq 1 ]; then
    verify_patch "$tmp_output" "$repo_root"
  fi
  mv "$tmp_output" "$output"
  note "Wrote $output"
fi
