#!/usr/bin/env bash
# shellcheck shell=bash
# Scribe plugin — the pieces the two write-path hooks share (#2901).
#
# scribe_prior_art.sh fires BEFORE a Write/Edit tool call; scribe_after_write.sh
# fires AFTER a Bash tool call and diffs the working tree, so code written by
# sed/heredocs/scripts gets the same prior-art and ledger checks. Both need the
# same three things, kept here so they cannot drift apart:
#
#   scribe_skip_path PATH      formats that hold prose or data, not shapes
#   scribe_defs                stdin code → "kind<TAB>name" per definition
#   scribe_local_dups ROOT REL "kind<TAB>name" lines on stdin → the by-name
#                              local-duplicate lines (ARM 1, #2280)
#   scribe_unreached STATE SID SECS REL  the "Scribe didn't answer" line, once
#                              per outage (#2932) — or nothing, if said lately
#   scribe_reached STATE SID   the server answered: the next outage speaks again
#
# Sourced, not executed: `. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"`.

# Skip formats that hold prose or data rather than reusable code. Purely to
# avoid a pointless round-trip — the server would return nothing for these
# anyway. Config formats are NOT skipped: a CI workflow or a compose file is
# often exactly the thing worth reusing.
scribe_skip_path() {
  case "$1" in
    *.md|*.mdx|*.txt|*.rst|*.json|*.lock|*.log|*.csv|*.tsv|*.svg|*.png|*.jpg|*.jpeg|*.gif|*.ico|*.pdf)
      return 0 ;;
  esac
  return 1
}

# ---------------------------------------------------------------------------
# kind<TAB>name for each thing a piece of code DEFINES, in source order. One
# program, two consumers: the local duplicate arm (every definition in the
# payload) and the ledger feed (#2791, below: the definitions being written,
# or the one enclosing an Edit). Rule-for-rule mirrored by the server's
# services/coverage.py extract_shapes — ledger rows are keyed by what THAT
# sees, so the two must agree on what counts as a definition.
scribe_defs() {
  awk '
    {
      # CSS class definition: .name { or .name,
      if (match($0, /^[[:space:]]*\.[A-Za-z][A-Za-z0-9_-]*[[:space:]]*[,{]/)) {
        t = $0; sub(/^[[:space:]]*\./, "", t); sub(/[[:space:]]*[,{].*$/, "", t)
        if (t != "") print "css\t" t; next
      }
      line = $0; sub(/^[[:space:]]+/, "", line)
      # Strip leading declaration modifiers so the definition keyword is the
      # first word regardless of language (export/pub/private/suspend/...).
      sub(/^((pub(\([a-z]+\))?|export|default|private|internal|protected|public|static|suspend|async|open|sealed|data|abstract|final|inline|unsafe|extern|override)[[:space:]]+)*/, "", line)
      # Go method with receiver: func (r *T) Name(
      if (match(line, /^func[[:space:]]*\([^)]*\)[[:space:]]*[A-Za-z_]/)) {
        t = line; sub(/^func[[:space:]]*\([^)]*\)[[:space:]]*/, "", t)
        sub(/[^A-Za-z0-9_].*$/, "", t)
        if (t != "") print "sym\t" t; next
      }
      # Keyword-announced definitions, functions and named types alike.
      # Dunders are skipped: every class defines __init__, so "already defined
      # in N other files" is guaranteed noise for them — and noise is what
      # teaches sessions to skip the hint.
      if (match(line, /^(function|def|class|func|fun|fn|sub|struct|trait|interface|enum|object|protocol|type)[[:space:]]+[A-Za-z_$]/)) {
        t = line; sub(/^[a-z]+[[:space:]]+/, "", t)
        sub(/[^A-Za-z0-9_$].*$/, "", t)
        # `type` defines only when something follows the name (= or {); an
        # import specifier `type Foo,` is the same two words and defines
        # nothing (mirror of coverage.py, #2904).
        if (line ~ /^type[[:space:]]/) {
          rest = line; sub(/^type[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*/, "", rest)
          if (rest !~ /[={]/) next
        }
        if (t != "" && t !~ /^__.*__$/) print "sym\t" t; next
      }
      # Arrow/expression assignment: const name = (…) / let name = async (
      if (match(line, /^(const|let)[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*[[:space:]]*=[[:space:]]*(async[[:space:]]*)?[(<]/)) {
        t = line; sub(/^(const|let)[[:space:]]+/, "", t)
        sub(/[^A-Za-z0-9_$].*$/, "", t)
        if (t != "") print "sym\t" t; next
      }
    }
  ' 2>/dev/null
}


# ---------------------------------------------------------------------------
# ARM 1 — BY NAME, LOCALLY (#2280). Does a definition of this already exist?
#
# The recorded arms ask Scribe what was RECORDED; the ledger arm (#2900) asks
# what a BOUND repo's ledger knows. A helper nobody recorded, in a repo nobody
# bound, is invisible to both — which is how `.btn-primary` came to be defined
# four times, already diverged. This arm asks the one question only the
# developer's machine can answer, inside the repo, holding the code about to
# be written: no index, no storage, no server — it runs even on an install
# that has never configured Scribe.
#
# Definition-shaped patterns only. Grepping for bare occurrences would match
# every CALL site and drown the real finding — and a hint that is mostly noise
# is one people learn to skip, which is worse than none. ALL code, not a
# language shortlist (#2682): the same keyword family scribe_defs announces.
#
# $1 repo root, $2 repo-relative path of the file being written (excluded from
# the grep — it would always match itself on an Edit). Definitions on stdin.
# Prints one "> - `name` is already defined in N other file(s): …" per hit.
scribe_local_dups() {
  local root="$1" rel="$2" kind name pat hits count label files
  while IFS=$'\t' read -r kind name; do
    [ -n "${name:-}" ] || continue
    case "$kind" in
      css) pat="^[[:space:]]*\.${name}[[:space:]]*[,{]" ;;
      *)   pat="(function|def|class|func|fun|fn|sub|struct|trait|interface|enum|object|protocol|type)[[:space:]]+${name}[^A-Za-z0-9_]|func[[:space:]]*\([^)]*\)[[:space:]]*${name}[[:space:]]*\(|(const|let)[[:space:]]+${name}[[:space:]]*=" ;;
    esac
    # -I skips binaries; :(exclude) drops the file being written.
    hits=$(git -C "$root" grep -I -l -E -e "$pat" -- . ":(exclude)${rel}" 2>/dev/null | head -4) || hits=""
    [ -n "$hits" ] || continue
    count=$(printf '%s\n' "$hits" | grep -c . 2>/dev/null || echo 0)
    label=$([ "$kind" = css ] && printf '.%s' "$name" || printf '%s' "$name")
    files=$(printf '%s' "$hits" | tr '\n' ' ' | sed 's/ $//')
    printf '> - `%s` is already defined in %s other file(s): %s\n' "$label" "$count" "$files"
  done
}

# ---------------------------------------------------------------------------
# The blind spot made visible (#2932). Both write-path hooks fail OPEN when the
# instance is slow or down — right for noise, wrong for silence: a session
# cannot tell "the ledger checked and found nothing" from "the ledger never
# answered", and a self-surfacing system cannot afford an invisible miss (the
# first write after a redeploy lost its derive line to a 4s cold start and
# nobody knew). So a failed call says so — ONCE per outage: the marker holds
# the time it last spoke; within ten minutes of that it stays quiet, and a
# successful call clears it so the next outage announces itself afresh.
# Unconfigured installs never reach this: no URL/token means no call was owed.
_SCRIBE_UNREACHED_QUIET=600

scribe_unreached() {
  local marker="$1/$2.unreached" now last
  now=$(date +%s 2>/dev/null) || now=0
  if [ -f "$marker" ]; then
    last=$(cat "$marker" 2>/dev/null) || last=0
    case "$last" in ''|*[!0-9]*) last=0 ;; esac
    [ $((now - last)) -lt "$_SCRIBE_UNREACHED_QUIET" ] && return 0
  fi
  printf '%s' "$now" > "$marker" 2>/dev/null || true
  printf '> Scribe did not answer the prior-art check for `%s` within %ss — this write went UNCHECKED against the record and the shape ledger (the local by-name arm, if it spoke above, needed no server). If the name matters, check it yourself: `search` for the concept, `list_shapes(project_id, path=…)` for the ledger. Said once per outage; if it keeps happening the instance is slow or down.' "$4" "$3"
}

scribe_reached() {
  rm -f "$1/$2.unreached" 2>/dev/null || true
}
