#!/usr/bin/env bash
# Lists added Kotlin/Java lines in a diff that match ANR/perf leads: stage 4 of
# android-review. Every hit is a lead to judge in context, not a finding.
# From Shubham Singh Bisht's Android helpers: https://shubhamsinghbisht.com/android-roadmap/#helpers
#
#   scan_diff.sh                  changes since the nearest base (main or a release-*
#                                 branch), plus uncommitted and untracked .kt/.java files
#   scan_diff.sh --base           print that base commit
#   scan_diff.sh <git-diff-args>  e.g. A..B, <sha>^!, main...HEAD
#   scan_diff.sh --file <diff>    scan a saved unified diff
#   scan_diff.sh --self-test      check the scanner against fixtures/
#
# Tune per repo with environment variables:
#   SCAN_STARTUP   regex for startup files (PERF-3)
#                  default: (^|/)([A-Za-z0-9_]*Application|MainActivity)\.(kt|java)$
#   SCAN_BIG_LIST  regex for the name of the app's large collection (ANR-5)
#                  default: objects|items   (so page.items.forEach { } is a lead)
set -euo pipefail

here="$(cd "$(dirname "$0")" && pwd)"

# Hunk headers carry the enclosing function so PERF-1 can recognise hot paths.
fun_regex='^[[:space:]]*(([a-z]+[[:space:]]+)*fun[[:space:]].*)$'

kdiff() {
  git -c "diff.ktfun.xfuncname=$fun_regex" \
      -c "core.attributesFile=$here/funcname.gitattributes" \
      diff -U0 --no-color --no-ext-diff "$@"
}

# The nearest base: of main/master/develop and the release branches, the one this branch is
# fewest commits ahead of. Branches cut from a release branch are reviewed
# against it, not against main. A branch that already contains HEAD is skipped
# (it would make the diff empty) — unless every base contains it, as on main
# itself, and then only uncommitted work is reviewed. Ties keep the earlier ref.
default_base() {
  local ref ahead any="" best="" best_ahead=""
  for ref in $(git for-each-ref --format='%(refname:short)' \
      refs/remotes/origin/main refs/heads/main refs/remotes/origin/master refs/heads/master refs/remotes/origin/develop refs/heads/develop 'refs/remotes/origin/release-*' 'refs/heads/release-*'); do
    any=1
    ahead="$(git rev-list --count "$ref..HEAD")"
    [ "$ahead" -gt 0 ] || continue
    if [ -z "$best" ] || [ "$ahead" -lt "$best_ahead" ]; then best="$ref"; best_ahead="$ahead"; fi
  done
  if [ -z "$best" ] && [ -n "$any" ]; then git rev-parse HEAD; return; fi
  if [ -z "$best" ]; then
    echo "scan_diff: no main/master/develop or release-* base here; pass a range, e.g. scan_diff.sh HEAD~3..HEAD" >&2
    exit 2
  fi
  git merge-base HEAD "$best"
}

diff_default() {
  local base
  base="$(default_base)"
  kdiff "$base" -- '*.kt' '*.java'
  git ls-files --others --exclude-standard -- '*.kt' '*.java' | while IFS= read -r f; do
    kdiff --no-index /dev/null "$f" || true   # --no-index exits 1 when files differ
  done
}

scan() {
  awk '
  function add(rule, re, label) { n++; R[n] = rule; P[n] = re; L[n] = label }
  function hit(rule, label, text) {
    printf "%s:%d  %s %s  | %s\n", file, line, rule, label, substr(text, 1, 100)
  }
  BEGIN {
    big = ENVIRON["SCAN_BIG_LIST"]
    if (big == "") big = "objects|items"
    startup = ENVIRON["SCAN_STARTUP"]
    if (startup == "") startup = "(^|/)([A-Za-z0-9_]*Application|MainActivity)\\.(kt|java)$"
    add("ANR-1", "runBlocking", "runBlocking")
    add("ANR-1", "\\.get\\(\\)", ".get()")
    add("ANR-1", "Thread\\.sleep", "Thread.sleep")
    add("ANR-1", "\\.commit\\(\\)", ".commit()")
    add("ANR-1", "\\.execute\\(\\)", ".execute()")
    add("ANR-2", "GlobalScope", "GlobalScope")
    add("ANR-2", "(^|[^A-Za-z0-9_.])Thread[ ]*[({]", "Thread(")
    add("ANR-2", "(^|[^A-Za-z0-9_.])thread[ ]*[({]", "thread {")
    add("ANR-3", "synchronized[ ]*\\(", "synchronized")
    add("ANR-3", "withLock", "withLock")
    add("ANR-3", "\\.lock\\(\\)", ".lock()")
    add("ANR-5", "for[ ]*\\(.*\\.(" big ")([^A-Za-z0-9_]|$)", "loop over large collection")
    add("ANR-5", "\\.(" big ")[?]?\\.(forEach|map|filter|sumOf|count|any|all|first|find|onEach)", "loop over large collection")
    add("PERF-4", "var[ ]+[A-Za-z0-9_]+[ ]*:[ ]*(Context|Activity|AppCompatActivity|FragmentActivity|View|WebView|Fragment)([?]|[ =]|$)", "var holding Context/View")
    add("PERF-5", "Handler\\([ ]*Looper", "Handler(Looper")
    add("PERF-5", "postDelayed", "postDelayed")
    hot = "fun[ ]+(onDraw|dispatchDraw|draw|onTouchEvent|dispatchTouchEvent|onInterceptTouchEvent|onMeasure|onLayout)[ ]*\\("
    alloc = "(^|[^A-Za-z0-9_.])(Paint|Path|RectF|Rect|Matrix|PointF|ArrayList|HashMap)\\(|(mutableListOf|arrayListOf|listOf|mutableMapOf)[<(]"
  }
  # Inside a hunk: count lines down using the header counts, so removed lines
  # that look like "--- x" are never mistaken for file headers.
  (old_left > 0 || new_left > 0) {
    c = substr($0, 1, 1)
    if (c == "-") { old_left--; next }
    if (c == "+") {
      new_left--
      text = substr($0, 2)
      if (file != "") check(text)
      line++
      next
    }
    next
  }
  /^\+\+\+ / {
    path = substr($0, 5)
    sub(/\t.*$/, "", path)
    if (path == "/dev/null") { file = ""; next }
    sub(/^b\//, "", path)
    file = path
    if (file !~ /\.(kt|java)$/ || file ~ /\/src\/(test|androidTest)\//) file = ""
    startup_done = 0
    next
  }
  /^@@ / {
    split($0, h, " ")
    o = substr(h[2], 2); nn = substr(h[3], 2)
    old_left = (index(o, ",") ? substr(o, index(o, ",") + 1) : 1) + 0
    new_left = (index(nn, ",") ? substr(nn, index(nn, ",") + 1) : 1) + 0
    line = (index(nn, ",") ? substr(nn, 1, index(nn, ",") - 1) : nn) + 0
    fn = $0; sub(/^@@ [^@]*@@ ?/, "", fn)
    next
  }
  function check(text,   t, i, name) {
    t = text; sub(/^[ \t]+/, "", t)
    if (t ~ /^fun[ ]/ || t ~ /^[a-z]+[ ]+.*fun[ ]/) fn = t
    if (t ~ /^(\/\/|\/\*|\*)/) return
    if (file ~ startup && !startup_done) { hit("PERF-3", "startup file touched", t); startup_done = 1 }
    for (i = 1; i <= n; i++) if (t ~ P[i]) hit(R[i], L[i], t)
    if (fn ~ hot) {
      name = fn; sub(/^.*fun[ ]+/, "", name); sub(/[ ]*\(.*$/, "", name)
      if (t ~ /Log\.[a-z]+\(/) hit("PERF-1", "log in " name, t)
      if (t ~ alloc) hit("PERF-1", "allocation in " name, t)
    }
  }
  '
}

case "${1:-}" in --self-test|--file) ;; *)
  if ! git rev-parse --git-dir >/dev/null 2>&1; then
    echo "scan_diff: not inside a git repository" >&2
    exit 2
  fi
esac

case "${1:-}" in
  --self-test)
    actual="$(SCAN_STARTUP='' SCAN_BIG_LIST='' scan < "$here/fixtures/sample.diff")"
    if ! diff -u "$here/fixtures/expected.txt" <(printf '%s\n' "$actual"); then
      echo "self-test: FAILED (fixture)" >&2; exit 1
    fi
    tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT
    (
      cd "$tmp"
      git init -q -b main
      printf 'class A\n' > A.kt
      git add A.kt && git -c user.name=t -c user.email=t@t -c commit.gpgsign=false commit -q --no-verify -m init
      printf 'class A\nval s = GlobalScope\n' > A.kt     # uncommitted edit
      printf 'val t = runBlocking { }\n' > New.kt         # untracked file
      printf 'notes\n' > notes.md                          # non-code: ignored
      out="$("$here/scan_diff.sh")"
      echo "$out" | grep -q '^A.kt:2  ANR-2 GlobalScope' || { echo "self-test: FAILED (uncommitted edit missed)" >&2; exit 1; }
      echo "$out" | grep -q '^New.kt:1  ANR-1 runBlocking' || { echo "self-test: FAILED (untracked file missed)" >&2; exit 1; }
      # A branch cut from a release branch is reviewed against that release, not main.
      commit() { git -c user.name=t -c user.email=t@t -c commit.gpgsign=false commit -q --no-verify -m "$1"; }
      git add -A && commit wip
      git checkout -q -b release-x
      printf 'val r = Thread.sleep(1)\n' > Rel.kt && git add Rel.kt && commit rel
      git checkout -q -b feature-r
      printf 'val f = GlobalScope\n' > Feat.kt && git add Feat.kt && commit feat
      [ "$("$here/scan_diff.sh" --base)" = "$(git rev-parse release-x)" ] || { echo "self-test: FAILED (nearest base is not release-x)" >&2; exit 1; }
      out="$("$here/scan_diff.sh")"
      echo "$out" | grep -q '^Feat.kt:1  ANR-2 GlobalScope' || { echo "self-test: FAILED (feature change missed)" >&2; exit 1; }
      if echo "$out" | grep -q 'Rel.kt'; then echo "self-test: FAILED (release commit leaked into the review)" >&2; exit 1; fi
      # A branch that already contains HEAD is not a base (it would make the diff empty).
      git branch -q release-y   # release-y == HEAD: already contains it
      [ "$("$here/scan_diff.sh" --base)" = "$(git rev-parse release-x)" ] || { echo "self-test: FAILED (picked a branch already containing HEAD)" >&2; exit 1; }
      # Repos whose default branch is master (or develop) work the same way.
      git branch -q -m main master
      [ "$("$here/scan_diff.sh" --base)" = "$(git rev-parse release-x)" ] || { echo "self-test: FAILED (base changed after main -> master)" >&2; exit 1; }
      git branch -q -D release-x release-y
      [ "$("$here/scan_diff.sh" --base)" = "$(git merge-base HEAD master)" ] || { echo "self-test: FAILED (master not used as base)" >&2; exit 1; }
      git checkout -q -b feature && git branch -q -D master feature-r
      if "$here/scan_diff.sh" >/dev/null 2>err.txt; then echo "self-test: FAILED (no main should exit 2)" >&2; exit 1; fi
      grep -q 'pass a range' err.txt || { echo "self-test: FAILED (no-main message)" >&2; exit 1; }
      mkdir "$tmp/plain" && cd "$tmp/plain"
      if GIT_CEILING_DIRECTORIES="$tmp" "$here/scan_diff.sh" >/dev/null 2>err.txt; then echo "self-test: FAILED (outside git should fail)" >&2; exit 1; fi
      [ "$(cat err.txt)" = "scan_diff: not inside a git repository" ] || { echo "self-test: FAILED (outside-git message: $(cat err.txt))" >&2; exit 1; }
    )
    echo "self-test: ok"
    ;;
  --base)
    default_base
    ;;
  --file)
    scan < "${2:?usage: scan_diff.sh --file <diff>}"
    ;;
  "")
    diff_default | scan
    ;;
  *)
    kdiff "$@" -- '*.kt' '*.java' | scan
    ;;
esac
