#!/usr/bin/env bash
# appctl — convenience wrapper for the irdb docker compose stack and CI checks.
#
# The canonical stack is api + ui + migrate (docker-compose.yml). The
# scheduler sidecar (compose.scheduler.yml) is opt-in via --scheduler.
#
# Per-subproject scope is accepted as an optional positional arg on the
# check commands (lint/stan/test/audit/check). With no arg, the command
# runs against both api and ui — the same coverage as scripts/ci.sh.
#
# Lint, stan, test, audit and check all run in ephemeral composer/node
# containers (mirroring scripts/ci.sh) so the host needs no PHP or Node
# toolchain installed.

set -euo pipefail

# Resolve repo root regardless of where the user invokes us from. Works
# whether called as bin/appctl or via the top-level ./appctl symlink.
SCRIPT_PATH="$(readlink -f "${BASH_SOURCE[0]}")"
SCRIPT_DIR="$(dirname "$SCRIPT_PATH")"
if [[ "$(basename "$SCRIPT_DIR")" == "bin" ]]; then
    REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
else
    REPO_ROOT="$SCRIPT_DIR"
fi
cd "$REPO_ROOT"

PHP_IMAGE="composer:2"
NODE_IMAGE="node:20-alpine"
UID_GID="$(id -u):$(id -g)"

usage() {
    cat <<'EOF'
appctl — irdb docker compose / CI wrapper

USAGE
  appctl <command> [args]

STACK (docker compose; canonical stack is api + ui + migrate)
  appctl start  [--scheduler] [-d]   bring the stack up (foreground; -d to detach)
  appctl stop   [--scheduler]        stop and remove containers
  appctl build  [api|ui|scheduler]   build images (no arg = api + ui)
  appctl shell  [api|ui]             bash into a running container (default: api)
  appctl logs   [service]            follow logs (no arg = all services)
  appctl ps                          list stack status (incl. scheduler if running)
  appctl migrate                     run the one-shot migrate container

RELEASE
  appctl upgrade [version]           stop, fetch, checkout, build, start -d --scheduler
                                     version defaults to the `latest` tag; `test` is a
                                     virtual tag resolving to origin/main HEAD; otherwise
                                     pass a release tag like v0.9.

CHECKS (ephemeral containers, no running stack required)
  appctl lint   [api|ui]             composer cs (php-cs-fixer --dry-run)
  appctl stan   [api|ui]             composer stan (phpstan)
  appctl test   [api|ui]             composer test (phpunit)
  appctl audit  [api|ui]             composer audit --no-dev
  appctl check  [api|ui]             lint + stan + audit + test for the target(s)
  appctl ci                          full CI pipeline (delegates to scripts/ci.sh)

OTHER
  appctl help                        show this message
EOF
}

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

# --- compose helpers --------------------------------------------------

# Canonical stack only (api + ui + migrate).
compose_main() {
    docker compose -f docker-compose.yml "$@"
}

# Canonical stack plus the scheduler sidecar overlay. Used for start
# --scheduler, build scheduler, and read-only commands (logs / ps) that
# should still see the sidecar when it's running.
compose_full() {
    docker compose -f docker-compose.yml -f compose.scheduler.yml "$@"
}

# Parse `--scheduler` and `-d|--detach` flags out of the args.
# Sets globals: SCHED, DETACH, REST (remaining positional args).
parse_stack_flags() {
    SCHED=0
    DETACH=0
    REST=()
    while (($#)); do
        case "$1" in
            --scheduler) SCHED=1 ;;
            -d|--detach) DETACH=1 ;;
            --) shift; REST+=("$@"); break ;;
            *) REST+=("$1") ;;
        esac
        shift
    done
}

# --- subproject helpers (echo space-separated for `for` loops) --------

resolve_targets() {
    local arg="${1:-}"
    case "$arg" in
        "")        echo "api ui" ;;
        api|ui)    echo "$arg" ;;
        *)         die "unknown subproject: $arg (try: api|ui)" ;;
    esac
}

# Run a command inside a one-off composer container, mounted at /app.
# Mirrors scripts/ci.sh::run_php so behavior stays consistent.
run_php() {
    local dir="$1"; shift
    mkdir -p "$HOME/.composer-cache"
    docker run --rm \
        -u "$UID_GID" \
        -v "$REPO_ROOT/$dir":/app \
        -v "$HOME/.composer-cache":/tmp \
        -w /app \
        -e COMPOSER_HOME=/tmp/composer \
        -e COMPOSER_CACHE_DIR=/tmp/composer-cache \
        -e XDG_CONFIG_HOME=/tmp \
        "$PHP_IMAGE" "$@"
}

# Bootstrap vendor/ on demand so tests/lint don't fail with a confusing
# "vendor/bin/phpunit not found" the first time.
ensure_vendor() {
    local dir="$1"
    [[ -d "$REPO_ROOT/$dir/vendor" ]] && return 0
    printf '[appctl] %s/vendor missing — running composer install\n' "$dir"
    run_php "$dir" composer install --no-interaction --prefer-dist
}

# --- subcommands ------------------------------------------------------

cmd_start() {
    parse_stack_flags "$@"
    local up_args=(up)
    [[ "$DETACH" == "1" ]] && up_args+=(-d)
    if [[ "$SCHED" == "1" ]]; then
        compose_full "${up_args[@]}" "${REST[@]}"
    else
        compose_main "${up_args[@]}" "${REST[@]}"
    fi
}

cmd_stop() {
    parse_stack_flags "$@"
    # `down` operates on the project name and tears down everything
    # currently running, regardless of which overlays were used to
    # start it — but layering the scheduler overlay keeps things tidy
    # when --scheduler was passed.
    if [[ "$SCHED" == "1" ]]; then
        compose_full down "${REST[@]}"
    else
        compose_main down "${REST[@]}"
    fi
}

cmd_build() {
    local target="${1:-}"
    case "$target" in
        "")              compose_main build ;;
        api|ui|migrate)  compose_main build "$target" ;;
        scheduler)       compose_full build scheduler ;;
        *) die "unknown build target: $target (try: api|ui|scheduler|migrate)" ;;
    esac
}

cmd_shell() {
    local service="${1:-api}"
    case "$service" in
        api|ui) ;;
        *) die "unknown shell target: $service (try: api|ui)" ;;
    esac
    # Both images are alpine-based today; fall back to sh if bash isn't
    # present so this keeps working if the base image changes.
    compose_main exec "$service" \
        sh -c 'command -v bash >/dev/null && exec bash || exec sh'
}

cmd_logs() {
    local service="${1:-}"
    if [[ -z "$service" ]]; then
        compose_full logs -f
    else
        compose_full logs -f "$service"
    fi
}

cmd_ps() {
    compose_full ps
}

cmd_migrate() {
    compose_main run --rm migrate
}

cmd_lint() {
    local target
    for target in $(resolve_targets "${1:-}"); do
        ensure_vendor "$target"
        printf '\n[appctl] %s: composer cs\n' "$target"
        run_php "$target" composer cs
    done
}

cmd_stan() {
    local target
    for target in $(resolve_targets "${1:-}"); do
        ensure_vendor "$target"
        printf '\n[appctl] %s: composer stan\n' "$target"
        run_php "$target" composer stan
    done
}

cmd_test() {
    local target
    for target in $(resolve_targets "${1:-}"); do
        ensure_vendor "$target"
        printf '\n[appctl] %s: composer test\n' "$target"
        run_php "$target" composer test
    done
}

cmd_audit() {
    local target
    for target in $(resolve_targets "${1:-}"); do
        ensure_vendor "$target"
        printf '\n[appctl] %s: composer audit\n' "$target"
        run_php "$target" composer audit --no-dev
    done
}

cmd_check() {
    local target="${1:-}"
    cmd_lint  "$target"
    cmd_stan  "$target"
    cmd_audit "$target"
    cmd_test  "$target"
}

cmd_ci() {
    [[ -x scripts/ci.sh ]] || die "scripts/ci.sh missing or not executable"
    exec scripts/ci.sh
}

# Upgrade flow: fetch, resolve the requested version to a ref, take the
# stack down, check out the ref (detached — tags are not branches), rebuild
# all images, and bring everything back up with the scheduler sidecar in
# detached mode.
#
# `latest` is the moving release tag (set by maintainers); `test` is a
# virtual alias for origin/main HEAD so operators can stage unreleased work
# without us having to push a throw-away tag for every preview.
cmd_upgrade() {
    local version="${1:-latest}"
    local ref

    if [[ -n "$(git status --porcelain)" ]]; then
        die "working tree not clean — commit or stash changes before upgrading"
    fi

    printf '[appctl] fetching refs from origin\n'
    git fetch --all --tags --prune

    if [[ "$version" == "test" ]]; then
        ref="origin/main"
    elif git rev-parse --verify --quiet "refs/tags/$version" >/dev/null; then
        ref="refs/tags/$version"
    else
        die "unknown version: $version (try: latest, test, or a release tag like v0.9)"
    fi

    local target_sha
    target_sha="$(git rev-parse --short "$ref")"
    printf '[appctl] upgrading to %s (%s)\n' "$version" "$target_sha"

    printf '[appctl] stopping stack\n'
    compose_full down

    printf '[appctl] checking out %s\n' "$ref"
    git checkout --detach "$ref"

    printf '[appctl] building images\n'
    compose_full build

    printf '[appctl] starting stack (--scheduler, detached)\n'
    compose_full up -d
}

# --- dispatch ---------------------------------------------------------

cmd="${1:-help}"
shift || true

case "$cmd" in
    start)              cmd_start "$@" ;;
    stop)               cmd_stop "$@" ;;
    build)              cmd_build "$@" ;;
    shell)              cmd_shell "$@" ;;
    logs)               cmd_logs "$@" ;;
    ps)                 cmd_ps ;;
    migrate)            cmd_migrate ;;
    lint)               cmd_lint "$@" ;;
    stan)               cmd_stan "$@" ;;
    test)               cmd_test "$@" ;;
    audit)              cmd_audit "$@" ;;
    check)              cmd_check "$@" ;;
    ci)                 cmd_ci ;;
    upgrade)            cmd_upgrade "$@" ;;
    help|-h|--help|"")  usage ;;
    *) die "unknown command: $cmd (try: appctl help)" ;;
esac
