#!/usr/bin/env bash
# stack-detect.sh  -  answer "what is this repo built with" from the repo itself.
#
# Why this exists: `/multi-agent:stack` writes `enabledPlugins` and a human picks
# the stack. When that conversation never happens the repo inherits the global
# setting, and on the machine this was written against only 3 of 25 checkouts had
# ever had it - so a Next.js site and several Node CLIs were all routed to the iOS
# toolkit. An unsupervised run has nobody to notice.
#
# So: infer, deterministically, from file markers - never a model call - so the
# same repo always answers the same way and the answer can be shown with its
# reason.
#
# This file answers ONE question: which of `ios android web backend` describes
# this repo. It deliberately does not know plugin names. "Which plugin carries a
# stack" is a different question with a different owner
# (`pipeline/scripts/_stack-routing.mjs`), and the two were one table here until
# that put a tenth copy of the stack-to-plugin mapping in the tree - the exact
# duplication `multi-agent-refs/stack-skill-routing.md` exists to prevent.
#
# Usage:
#   . stack-detect.sh; ma_stack_detect <repo-root>   # sets MA_STACKS, MA_STACK_WHY
#   bash stack-detect.sh <repo-root>                 # prints KEY=VALUE
#   bash stack-detect.sh --json <repo-root>
#
# Output: MA_STACKS is a space-separated subset of `ios android web backend`, in
# that fixed order so two runs on the same repo produce the same string. Empty is
# a real answer and means "no marker matched", NOT "not looked" - MA_STACK_WHY
# distinguishes them, because a caller that cannot tell those apart will treat an
# unreadable directory as a language-free repo.

# Markers whose mere presence decides a stack, as data rather than a `case`
# cascade: `stack:glob:maxdepth:extensions`, where the extensions are the
# stack's source language (used to discount a marker below the root, see
# _ma_share_ok). Adding a language is one line here, which is
# the point - a contributor who has to edit control flow to add Rust will
# instead widen somebody else's regex.
#
# Not every rule fits this shape, and forcing the two that do not would be worse
# than keeping them in code: Android is decided by FILE CONTENT (Gradle alone is
# a JVM service), and one package.json can be web, backend or both. Those two
# run after the table, with their reasoning at the point of decision.
MA_STACK_MARKERS="\
ios:Package.swift:3:swift|m|mm
ios:*.xcodeproj:3:swift|m|mm
ios:*.xcworkspace:3:swift|m|mm
ios:Podfile:3:swift|m|mm
web:vite.config.*:3:js|jsx|ts|tsx|mjs|cjs|vue|svelte
web:nuxt.config.*:3:js|jsx|ts|tsx|mjs|cjs|vue|svelte
web:angular.json:3:js|jsx|ts|tsx|mjs|cjs|vue|svelte
web:svelte.config.*:3:js|jsx|ts|tsx|mjs|cjs|vue|svelte
backend:requirements.txt:3:py
backend:pyproject.toml:3:py
backend:go.mod:3:go
backend:Cargo.toml:3:rs
backend:pom.xml:3:java|kt|scala"

# Every source extension a language share is measured against: the table's
# own plus common languages no marker names.
MA_STACK_SOURCE_EXTS='swift|m|mm|kt|java|scala|js|jsx|ts|tsx|mjs|cjs|vue|svelte|py|go|rs|dart|cs|rb|php|c|cc|cpp|h|hpp'
# Below this share (percent of tracked source files) a marker under the root
# is ignored, once the repo tracks at least MA_STACK_MIN_FILES source files; a
# smaller tree is too small to judge by share.
MA_STACK_MIN_SHARE=1
MA_STACK_MIN_FILES=50

# Dependency names that decide which side of one package.json a repo is on. A
# Next app with API routes is honestly both, so both may be recorded.
MA_STACK_WEB_DEPS='"(react|next|vue|svelte|@angular/core|solid-js|astro|preact|remix)"[[:space:]]*:'
MA_STACK_BACKEND_DEPS='"(express|fastify|@nestjs/core|koa|hapi|@trpc/server|apollo-server)"[[:space:]]*:'

ma_stack_detect() {
  local root="${1:-$PWD}"
  MA_STACKS=""
  MA_STACK_WHY=""

  if [ -z "$root" ] || [ ! -d "$root" ]; then
    MA_STACK_WHY="unreadable: $root"
    return 1
  fi
  # Strip trailing slashes before anything builds a path from $root. `find`
  # prints `<root>/x`, so a root ending in `/` makes every `-path "$root/$sub"`
  # prune carry a double slash and match nothing - the submodule prune then
  # silently does not fire, and a vendored Package.swift reports a Compose app
  # as iOS. A caller passing a directory with a trailing slash is normal.
  while [ "${root%/}" != "$root" ] && [ "$root" != "/" ]; do root="${root%/}"; done

  # Submodule paths are pruned: a vendored checkout is somebody else's repo and
  # its markers are not this repo's stack. Measured, not theorised - the Android
  # app vendors the shared configuration repo, which ships a Package.swift, and a
  # depth-first scan reported that Compose app as an iOS repo.
  local _prunes=(
    -name node_modules -o -name .build -o -name Pods -o -name build -o -name dist
    -o -name .next -o -name .gradle -o -name DerivedData -o -name .worktrees
    -o -name .git -o -name vendor -o -name Carthage
  )
  local _sub _subs=()
  if [ -f "$root/.gitmodules" ]; then
    while IFS= read -r _sub; do
      [ -n "$_sub" ] && _subs+=(-o -path "$root/$_sub")
    done <<< "$(sed -n 's/^[[:space:]]*path[[:space:]]*=[[:space:]]*//p' "$root/.gitmodules" 2>/dev/null)"
  fi

  # The repo ROOT is checked before anything deeper, and that ordering is the
  # whole correctness argument: the root manifest is the repo's own declaration,
  # while every deeper hit belongs to a submodule, a build output or a vendored
  # dependency. Without it the answer is decided by `find` traversal order, which
  # is arbitrary - it read a generated .next/package.json as a Next.js app's
  # manifest and found no framework in it.
  _ma_has() { # $1 = -name pattern; prints the first hit, empty when none
    local _g
    for _g in "$root"/$1; do
      [ -e "$_g" ] && {
        printf '%s\n' "$_g"
        return 0
      }
    done
    # No -mindepth here, deliberately. -mindepth suppresses predicate evaluation
    # for shallower entries, so with -mindepth 2 the prune never fired on a
    # depth-1 submodule directory and find walked straight into it. The root glob
    # above already returned any root-level hit, so re-visiting depth 1 is free.
    find "$root" -maxdepth "${2:-3}" \
      -type d \( "${_prunes[@]}" "${_subs[@]}" \) -prune -o \
      -name "$1" -print 2>/dev/null | head -1
  }

  _ma_add() { # $1 = stack, $2 = why-suffix
    case " $MA_STACKS " in *" $1 "*) return 0 ;; esac
    MA_STACKS="${MA_STACKS:+$MA_STACKS }$1"
    _why="${_why:+$_why }$1<-$2"
  }

  # A marker below the root counts only when the stack's own language is at
  # least MA_STACK_MIN_SHARE percent of the tracked source files. A subtree
  # copied in without being a submodule is not pruned by name: a Kotlin app
  # carrying a shared configuration repo's Package.swift has 1 .swift file
  # against 9000+ .kt, and that marker is not this repo's stack. A root marker
  # is the repo's own declaration and always counts. Outside a git work tree
  # there is no tracked-file list, so the marker counts. Package manifests
  # written in a source language (Package.swift) are not counted as source.
  local _files="" _files_read=0 _ignored=""
  _ma_share_ok() { # $1 = stack, $2 = extensions, $3 = hit path
    case "$3" in "$root"/*/*) ;; *) return 0 ;; esac
    if [ "$_files_read" -eq 0 ]; then
      _files_read=1
      _files=$(git -C "$root" ls-files 2>/dev/null | grep -iE "\.($MA_STACK_SOURCE_EXTS)\$" |
        grep -vE '(^|/)Package(@[^/]*)?\.swift$')
    fi
    [ -n "$_files" ] || return 0
    local total n
    total=$(printf '%s\n' "$_files" | grep -c .)
    [ "$total" -ge "$MA_STACK_MIN_FILES" ] || return 0
    n=$(printf '%s\n' "$_files" | grep -ciE "\.($2)\$")
    [ $((n * 100)) -ge $((total * MA_STACK_MIN_SHARE)) ] && return 0
    case " $_ignored " in *" $1 "*) return 1 ;; esac
    _ignored="${_ignored:+$_ignored }$1"
    _why="${_why:+$_why }ignored $1<-${3#"$root"/}($n of $total source files)"
    return 1
  }

  local _why="" hit row stack pat depth exts

  # --- table markers ----------------------------------------------------
  while IFS=: read -r stack pat depth exts; do
    [ -n "$stack" ] || continue
    case " $MA_STACKS " in *" $stack "*) continue ;; esac
    hit=$(_ma_has "$pat" "$depth")
    [ -n "$hit" ] && _ma_share_ok "$stack" "$exts" "$hit" && _ma_add "$stack" "${hit##*/}"
  done <<EOF
$MA_STACK_MARKERS
EOF

  # --- android, by content ----------------------------------------------
  # Gradle alone does NOT mean Android - a JVM service builds with Gradle too.
  # AndroidManifest.xml or the Android Gradle plugin is what separates them, and
  # getting this wrong loads the Compose toolkit onto a Spring repo.
  # AndroidManifest.xml lives at <module>/src/main/, which is depth 4 in every
  # multi-module app, so this one search goes deeper than the rest.
  hit=$(_ma_has "AndroidManifest.xml" 5)
  if [ -z "$hit" ]; then
    # A version catalogue is where a modern build declares the Android plugin;
    # the root build.gradle.kts of the reference app names it nowhere.
    local g
    for g in "$root/gradle/libs.versions.toml" "$(_ma_has 'build.gradle*')" "$(_ma_has 'settings.gradle*')"; do
      [ -n "$g" ] && [ -f "$g" ] || continue
      if grep -qE "com\.android\.(application|library)|androidx|\bagp\b" "$g" 2>/dev/null; then
        hit="$g"
        break
      fi
    done
  fi
  [ -n "$hit" ] && _ma_share_ok "android" "kt|java" "$hit" && _ma_add "android" "${hit##*/}"

  # --- web / backend, by dependency -------------------------------------
  local pkg
  pkg=$(_ma_has "package.json")
  [ -n "$pkg" ] && ! _ma_share_ok "node" "js|jsx|ts|tsx|mjs|cjs|vue|svelte" "$pkg" && pkg=""
  if [ -n "$pkg" ]; then
    local is_web=0
    grep -qE "$MA_STACK_WEB_DEPS" "$pkg" 2>/dev/null && is_web=1
    [ "$is_web" -eq 1 ] && _ma_add "web" "package.json"
    if grep -qE "$MA_STACK_BACKEND_DEPS" "$pkg" 2>/dev/null; then
      _ma_add "backend" "package.json"
    elif [ "$is_web" -eq 0 ]; then
      # A package.json with no web framework is still a Node project, and the
      # backend toolkit is the one that covers Node. Reporting nothing here would
      # send the caller to its no-marker fallback for a repo whose language is
      # not in doubt.
      _ma_add "backend" "package.json(node)"
    fi
  fi

  # Fixed order, so the same repo always yields the same string.
  local ordered="" s
  for s in ios android web backend; do
    case " $MA_STACKS " in *" $s "*) ordered="${ordered:+$ordered }$s" ;; esac
  done
  MA_STACKS="$ordered"
  MA_STACK_WHY="${_why:-no marker matched}"
  unset -f _ma_has _ma_add _ma_share_ok
  return 0
}

# JSON string escaping without jq, which this file does not otherwise need.
# "why" can quote a caller-supplied path, so every character JSON reserves is
# escaped: backslash and quote, then any control character as \uXXXX.
_ma_json_str() {
  local s="$1" out="" c i
  s="${s//\\/\\\\}"
  s="${s//\"/\\\"}"
  if [[ "$s" == *[[:cntrl:]]* ]]; then
    for ((i = 0; i < ${#s}; i++)); do
      c="${s:i:1}"
      [[ "$c" == [[:cntrl:]] ]] && printf -v c '\\u%04x' "'$c"
      out+="$c"
    done
    s="$out"
  fi
  printf '"%s"' "$s"
}

if [ "${BASH_SOURCE[0]:-$0}" = "$0" ]; then
  _json=0
  case "${1:-}" in --json)
    _json=1
    shift
    ;;
  esac
  ma_stack_detect "${1:-$PWD}" || true
  if [ "$_json" -eq 1 ]; then
    _arr=""
    for _s in $MA_STACKS; do _arr="${_arr:+$_arr,}\"$_s\""; done
    printf '{"stacks":[%s],"why":%s}\n' "$_arr" "$(_ma_json_str "$MA_STACK_WHY")"
  else
    printf 'MA_STACKS=%s\n' "$(printf '%q' "$MA_STACKS")"
    printf 'MA_STACK_WHY=%s\n' "$(printf '%q' "$MA_STACK_WHY")"
  fi
fi
