#!/usr/bin/env bash
# Headless-Chrome screenshot capture for WordPress admin screens.
#
# Capture is ALWAYS "MITM-fulfill": the browser is made to believe it is
# browsing https://<project-domain>/ while every request for that host is
# actually served in-process from the project's host-only container
# (172.17.0.1:<port> — never internet-exposed). No site is ever ungated, no
# public request is ever made, no listening socket is opened. See
# tools/bw-guide-shot/README.md "Security model" for the full write-up.
#
# Auth is a WP session cookie minted on demand via the gateway (no stored
# password, 30 min expiry) and destroyed the moment this script exits, success
# or failure (trap). Nothing sensitive is ever printed to stdout/logs.
#
# Usage:
#   tools/bw-guide-shot.sh --project <name> --path "<wp-admin path>" --out <file.png>
#            [--selector "<css selector>" | --region <name>]
#            [--actions "<verb:arg; verb:arg; ...>"]
#            [--trim] [--width <px>] [--height <px>] [--full-page]
#            [--anonymous] [--reset-ui]
#
# See --help for full option docs, tools/bw-guide-shot/selectors.json for the
# named --region library, and tools/bw-guide-shot/README.md "Interactions"
# for the --actions DSL (click/hover/focus/type/key/waitfor/scrollto/wait).

source "$(dirname "$0")/lib/common.sh"

TOOL_DIR="${BW_REPO_ROOT}/tools/bw-guide-shot"
SELECTORS_JSON="${TOOL_DIR}/selectors.json"
CAPTURE_JS="${TOOL_DIR}/capture.js"

usage() {
	cat <<'EOF'
Usage: tools/bw-guide-shot.sh --project <name> --path "<wp-admin path>" --out <file.png>
         [--selector "<css selector>" | --region <name>]
         [--actions "<verb:arg; verb:arg; ...>"]
         [--trim] [--width <px>] [--height <px>] [--full-page]

Required:
  --project <name>   srv-gw project name. Must be a running container.
  --path <path>      URL path (+ query string), e.g. /wp-admin/edit.php?post_type=staff
  --out <file>       Output PNG path.

Cropping (pick at most one):
  --selector <css>   CSS selector to screenshot (element-only capture).
  --region <name>    Named selector from tools/bw-guide-shot/selectors.json:
                        settings-sidebar, editor-canvas, admin-menu, list-table, full
                      ("full" = no selector, same as omitting both.)
  If the selector/region doesn't appear within 15s, falls back to a viewport
  (or --full-page) screenshot, prints SELECTOR_MISSING, and still exits 0.
  Resolution checks the main frame AND every child frame (e.g. Gutenberg's
  iframed post-content canvas), so this also works for elements inside it.

Interactions (see tools/bw-guide-shot/README.md "Interactions" for the full
reference):
  --actions "<script>"  Steps separated by ";", each "verb:arg". Runs after
                         load (and the welcome-modal dismissal), before the
                         screenshot. Verbs: click/hover/focus/waitfor/scrollto
                         (:<selector>), type:<selector>|<text>, key:<KeyName>,
                         wait:<ms, max 10000>. Max 15 steps, 45s total budget.
                         Read-only: a main-frame navigation aborts the shot.
  On any step failure: a full-viewport debug PNG is written to
  "<out>.failed.png", a "FAILED_STEP <n> <verb> <detail>" line is printed,
  and this script exits 2 (the normal --out file is NOT written).

Session:
  --anonymous        Capture as a logged-OUT visitor: no cookie is minted, so
                      /wp-login.php shows the real login screen instead of
                      redirecting to the Dashboard. Use for sign-in docs.
  --reset-ui         Before capturing, pin the editor's persisted UI state to
                      a known-good baseline (settings sidebar OPEN, welcome
                      modal dismissed). Gutenberg stores these in USER META, so
                      a previous run that toggled the sidebar shut will silently
                      break every later sidebar/inspector capture until reset.
                      Try this FIRST when a sidebar region isn't found.

Deterministic rectangle (race-free — see README "Race: element captures vs
late layout shifts"):
  --clip WxH+X+Y     Capture an explicit viewport rectangle instead of resolving
                      an element. Cannot race a layout shift, because nothing is
                      measured. Use for panels on screens that lay out late
                      (ACF metaboxes): e.g. the settings sidebar on a 1600px
                      viewport is always --clip 280x640+1320+97.
                      Mutually exclusive with --selector/--region.

Sizing:
  --width <px>       Viewport width. Default: 1600.
  --height <px>      Viewport height. Default: 1200.
  --full-page        Full-page (scrolling) screenshot instead of viewport,
                      when no selector/region matched or was requested.

Post-processing:
  --trim             ImageMagick: trim to content + 8px white border.

Security model (see tools/bw-guide-shot/README.md for detail):
  - Capture is host-only MITM-fulfill. No id-auth gate is ever touched, no
    request for the target site ever leaves this host.
  - Auth cookies are minted on demand for the "support" user and the specific
    session token is destroyed on exit (success or failure) — never
    WP_Session_Tokens::destroy_all() (that would log out real interactive
    sessions of the same user).
  - Nothing secret is ever printed to stdout, stderr, or any log.
  - --actions scripts must be read-only (see "Interactions" above); this is
    enforced by a navigation tripwire, not just documentation.
EOF
}

PROJECT=""
URL_PATH=""
OUT=""
SELECTOR=""
REGION=""
ACTIONS=""
TRIM=0
WIDTH=1600
HEIGHT=1200
FULL_PAGE=0
ANONYMOUS=0
RESET_UI=0
CLIP=""

while [[ $# -gt 0 ]]; do
	case "$1" in
		--project) PROJECT="${2:-}"; shift 2 ;;
		--path) URL_PATH="${2:-}"; shift 2 ;;
		--out) OUT="${2:-}"; shift 2 ;;
		--selector) SELECTOR="${2:-}"; shift 2 ;;
		--region) REGION="${2:-}"; shift 2 ;;
		--actions) ACTIONS="${2:-}"; shift 2 ;;
		--trim) TRIM=1; shift ;;
		--width) WIDTH="${2:-}"; shift 2 ;;
		--height) HEIGHT="${2:-}"; shift 2 ;;
		--full-page) FULL_PAGE=1; shift ;;
		--anonymous) ANONYMOUS=1; shift ;;
		--reset-ui) RESET_UI=1; shift ;;
		--clip) CLIP="${2:-}"; shift 2 ;;
		-h|--help) usage; exit 0 ;;
		*) log_error "Unknown argument: $1"; usage; exit 1 ;;
	esac
done

[[ -n "$PROJECT" ]] || { log_error "--project is required"; usage; exit 1; }
[[ -n "$URL_PATH" ]] || { log_error "--path is required"; usage; exit 1; }
[[ -n "$OUT" ]] || { log_error "--out is required"; usage; exit 1; }
[[ -z "$SELECTOR" || -z "$REGION" ]] || die "Use --selector OR --region, not both"
if [[ -n "$CLIP" ]]; then
	[[ "$CLIP" =~ ^[0-9]+x[0-9]+\+[0-9]+\+[0-9]+$ ]] || die "--clip must look like WxH+X+Y, e.g. 280x640+1320+97"
	[[ -z "$SELECTOR" && -z "$REGION" ]] || die "--clip cannot be combined with --selector/--region"
fi
[[ "$WIDTH" =~ ^[0-9]+$ && "$HEIGHT" =~ ^[0-9]+$ ]] || die "--width/--height must be integers"

if [[ -n "$REGION" ]]; then
	[[ -f "$SELECTORS_JSON" ]] || die "selectors.json not found at ${SELECTORS_JSON}"
	SELECTOR=$(jq -r --arg r "$REGION" 'if has($r) then (.[$r] // "") else "__UNKNOWN_REGION__" end' "$SELECTORS_JSON")
	[[ "$SELECTOR" != "__UNKNOWN_REGION__" ]] || die "Unknown --region '${REGION}'. See ${SELECTORS_JSON} for valid names."
fi

# --- Resolve project -> host-only port + public domain (srv-gw is the only
# source of truth for both; never guessed/derived from user input). ---
log_info "Resolving project '${PROJECT}' via srv-gw status..."
# Retry the status lookup: the gateway is single-threaded, so a batch of
# back-to-back captures (each doing a status lookup plus a cookie mint/destroy)
# can transiently make it report running:false for a container that has been up
# for days. Observed in practice — 9 of 14 captures in a batch aborted with
# "Project is not running" while the container never went down. Treat a
# not-running answer as possibly-transient and re-ask before believing it.
STATUS_ATTEMPTS=3
STATUS_JSON=""
RUNNING="false"
for ((attempt = 1; attempt <= STATUS_ATTEMPTS; attempt++)); do
	STATUS_JSON=$(srv-gw status --project "$PROJECT" 2>&1) || true
	RUNNING=$(printf '%s' "$STATUS_JSON" | jq -r '.running // false' 2>/dev/null || echo false)
	[[ "$RUNNING" == "true" ]] && break
	if [[ $attempt -lt $STATUS_ATTEMPTS ]]; then
		log_warn "srv-gw status says '${PROJECT}' is not running (attempt ${attempt}/${STATUS_ATTEMPTS}) — the gateway may just be busy; retrying in 4s..."
		sleep 4
	fi
done
[[ "$RUNNING" == "true" ]] || die "Project '${PROJECT}' is not running after ${STATUS_ATTEMPTS} attempts. Check: srv-gw status --project ${PROJECT}"

# NOTE on the trailing `|| true` below: with `pipefail` active (set by
# lib/common.sh), a `grep` that matches nothing makes the whole pipeline's
# status non-zero even though `head`/`cut` downstream succeed — and since
# this is a plain assignment with no enclosing if/&&/||, that would trigger
# `errexit` right here and skip the explicit die() below, exiting with no
# clear message. `|| true` neutralizes that so the intended check runs.
PORT=$(printf '%s' "$STATUS_JSON" | jq -r '.ports // ""' 2>/dev/null | grep -oE '172\.17\.0\.1:[0-9]+' | head -1 | cut -d: -f2) || true
[[ -n "$PORT" ]] || die "Could not resolve a 172.17.0.1:<port> binding from srv-gw status for '${PROJECT}'"

DOMAIN=$(printf '%s' "$STATUS_JSON" | jq -r '.domains[0] // empty' 2>/dev/null) || true
[[ -n "$DOMAIN" ]] || die "Project '${PROJECT}' has no registered domain (srv-gw status .domains[] is empty)"

log_info "Project '${PROJECT}' -> host-only 172.17.0.1:${PORT}, capturing as https://${DOMAIN}"

# --- Mint an on-demand WP session cookie (no stored password). Single line —
# srv-gw wp splits args on newlines. Written straight into a 600-perm temp
# file; never echoed to stdout/logs. ---
COOKIE_FILE=$(mktemp /tmp/bw-guide-shot.XXXXXXXX.cookies)
chmod 600 "$COOKIE_FILE"
TOKEN=""

cleanup() {
	local ec=$?
	if [[ -n "$TOKEN" ]]; then
		if srv-gw wp --project "$PROJECT" -- eval '$u=get_user_by("login","support"); WP_Session_Tokens::get_instance($u->ID)->destroy("'"$TOKEN"'"); echo "destroyed";' 2>/dev/null | grep -q destroyed; then
			log_info "Destroyed the minted session token (single session, not destroy_all)."
		else
			log_warn "Could not confirm the minted session token was destroyed for project '${PROJECT}' — verify manually (WP_Session_Tokens for the 'support' user)."
		fi
	fi
	rm -f "$COOKIE_FILE"
	exit $ec
}
trap cleanup EXIT

if [[ "$ANONYMOUS" -eq 1 ]]; then
	# Anonymous mode: capture as a logged-OUT visitor (login screens, public
	# pages). No cookie is minted, so there is no session token to destroy —
	# $COOKIE_FILE stays empty and cleanup still removes it.
	log_info "Anonymous mode — no session cookie will be minted (capturing as a logged-out visitor)."
else

log_info "Minting a short-lived (30 min) session cookie for 'support'..."
# Own temp file for stderr too (mktemp: atomic create at 600, same standard
# as $COOKIE_FILE) — a PHP fatal here could echo partial internals, so it
# gets the same tight-permissions treatment, not a $$-suffixed guess.
MINT_ERR_FILE=$(mktemp /tmp/bw-guide-shot.XXXXXXXX.minterr)
chmod 600 "$MINT_ERR_FILE"
# set +e/-e bracket: this is a plain (non-assignment) command, so under
# errexit a non-zero exit here would abort the script BEFORE the next line
# reads $? — which would skip the intended error handling entirely.
set +e
srv-gw wp --project "$PROJECT" -- eval '$u=get_user_by("login","support"); $exp=time()+1800; $m=WP_Session_Tokens::get_instance($u->ID); $t=$m->create($exp); $h=md5(get_option("siteurl")); echo "wordpress_sec_".$h."\t".wp_generate_auth_cookie($u->ID,$exp,"secure_auth",$t)."\n"."wordpress_logged_in_".$h."\t".wp_generate_auth_cookie($u->ID,$exp,"logged_in",$t)."\nTOKEN\t".$t."\n";' > "$COOKIE_FILE" 2>"$MINT_ERR_FILE"
MINT_EXIT=$?
set -e
if [[ $MINT_EXIT -ne 0 ]]; then
	log_error "Cookie mint failed (exit ${MINT_EXIT}) for project '${PROJECT}'."
	if [[ -s "$MINT_ERR_FILE" ]]; then
		log_error "$(cat "$MINT_ERR_FILE")"
	fi
	rm -f "$MINT_ERR_FILE"
	die "Aborting — no capture attempted."
fi
rm -f "$MINT_ERR_FILE"

grep -q '^wordpress_sec_' "$COOKIE_FILE" || die "Mint output missing wordpress_sec_ cookie line — cannot authenticate"
grep -q '^wordpress_logged_in_' "$COOKIE_FILE" || die "Mint output missing wordpress_logged_in_ cookie line — cannot authenticate"

TOKEN=$(awk -F'\t' '$1=="TOKEN"{print $2}' "$COOKIE_FILE")
[[ "$TOKEN" =~ ^[A-Za-z0-9]{20,100}$ ]] || die "Mint output TOKEN line missing or malformed — refusing to proceed without a destroyable token"
log_info "Session minted (token not shown; will be destroyed on exit)."
fi

# --- Optionally normalise the editor's persisted UI state. ---
# Gutenberg saves editor UI preferences (settings-sidebar open/closed, welcome
# modal) to USER META, so they persist between runs and leak between callers: a
# script that toggles the sidebar shut silently breaks every later sidebar
# capture for that user, on every project, until something toggles it back.
# --reset-ui pins the two that matter to a known-good capture state. It is
# surgical (merges, doesn't wipe) so nothing else in the blob is lost.
if [[ "$RESET_UI" -eq 1 ]]; then
	if [[ "$ANONYMOUS" -eq 1 ]]; then
		log_warn "--reset-ui has no effect in --anonymous mode (no logged-in user); ignoring."
	else
		log_info "Normalising editor UI state (settings sidebar open, welcome modal dismissed)..."
		if ! srv-gw wp --project "$PROJECT" -- eval '$u=get_user_by("login","support"); if(!$u){echo "no-user"; return;} $p=get_user_meta($u->ID,"wp_persisted_preferences",true); if(!is_array($p)){$p=array();} $c=isset($p["core"])&&is_array($p["core"])?$p["core"]:array(); $c["isComplementaryAreaVisible"]=true; $p["core"]=$c; $e=isset($p["core/edit-post"])&&is_array($p["core/edit-post"])?$p["core/edit-post"]:array(); $e["welcomeGuide"]=false; $p["core/edit-post"]=$e; update_user_meta($u->ID,"wp_persisted_preferences",$p); echo "ui-normalised";' 2>/dev/null | grep -q 'ui-normalised'; then
			log_warn "Could not normalise editor UI state — continuing anyway."
		fi
	fi
fi

# --- Run the capture. ---
mkdir -p "$(dirname "$OUT")" 2>/dev/null || true

NODE_ARGS=(
	"$CAPTURE_JS"
	--target-host "$DOMAIN"
	--target-port "$PORT"
	--path "$URL_PATH"
	--out "$OUT"
	--width "$WIDTH"
	--height "$HEIGHT"
)
if [[ "$ANONYMOUS" -eq 1 ]]; then
	NODE_ARGS+=(--anonymous)
else
	NODE_ARGS+=(--cookies-file "$COOKIE_FILE")
fi
[[ -n "$SELECTOR" ]] && NODE_ARGS+=(--selector "$SELECTOR")
[[ "$FULL_PAGE" -eq 1 ]] && NODE_ARGS+=(--full-page)
[[ -n "$ACTIONS" ]] && NODE_ARGS+=(--actions "$ACTIONS")
[[ -n "$CLIP" ]] && NODE_ARGS+=(--clip "$CLIP")

log_info "Launching headless Chrome (MITM-fulfill, path: ${URL_PATH})..."
# set +e/-e bracket (see note above the mint call) — we need the real exit
# code, not one swallowed by errexit before we can branch on it. Stderr is
# merged in so CAPTURE_ERROR / progress lines are visible in $CAPTURE_OUT.
set +e
CAPTURE_OUT=$(cd "$TOOL_DIR" && node "${NODE_ARGS[@]}" 2>&1)
CAPTURE_EXIT=$?
set -e

if [[ $CAPTURE_EXIT -eq 2 ]]; then
	# Action-step failure contract (tools/bw-guide-shot/README.md
	# "Interactions"): capture.js already wrote "<OUT>.failed.png" and printed
	# FAILED_STEP on stderr. Relay it clearly and preserve exit code 2 (rather
	# than routing through die(), which always exits 1) so callers can tell
	# "an interaction step failed" apart from "the capture itself errored".
	# `exit 2` still runs through `trap cleanup EXIT` below — cookie/token
	# cleanup happens exactly as it does for any other exit path.
	FAILED_LINE=$(printf '%s\n' "$CAPTURE_OUT" | grep '^FAILED_STEP' | head -1) || true
	log_error "Action step failed: ${FAILED_LINE:-node exited 2 with no FAILED_STEP line}"
	if [[ -f "${OUT}.failed.png" ]]; then
		chmod 664 "${OUT}.failed.png" 2>/dev/null || true
		log_error "Debug screenshot: ${OUT}.failed.png"
	fi
	exit 2
elif [[ $CAPTURE_EXIT -ne 0 ]]; then
	REASON=$(printf '%s\n' "$CAPTURE_OUT" | grep '^CAPTURE_ERROR' | head -1) || true
	die "Capture failed: ${REASON:-node exited ${CAPTURE_EXIT} with no CAPTURE_ERROR line}"
fi

# Relay node's progress/diagnostic lines; the JSON result line is surfaced
# separately below rather than dumped raw.
printf '%s\n' "$CAPTURE_OUT" | grep -v '^{' >&2 || true

RESULT_JSON=$(printf '%s\n' "$CAPTURE_OUT" | grep '^{' | head -1) || true
[[ -n "$RESULT_JSON" ]] && log_info "Result: ${RESULT_JSON}"

if printf '%s\n' "$CAPTURE_OUT" | grep -q '^SELECTOR_MISSING$'; then
	log_warn "SELECTOR_MISSING — requested selector/region did not appear; captured full viewport instead."
fi

[[ -f "$OUT" ]] || die "Capture reported success but ${OUT} does not exist"

if [[ "$TRIM" -eq 1 ]]; then
	log_info "Trimming with ImageMagick..."
	convert "$OUT" -bordercolor white -border 8 -trim +repage "$OUT" || die "ImageMagick trim failed"
fi

chmod 664 "$OUT" 2>/dev/null || true

DIMS=$(identify -format '%wx%h' "$OUT" 2>/dev/null || echo "unknown")
SIZE=$(stat -c%s "$OUT" 2>/dev/null || echo "unknown")
log_ok "Captured ${OUT} (${DIMS}, ${SIZE} bytes)"
