#!/usr/bin/env bash
# The only CI this repo has. Everything a branch must pass before it can
# squash-merge, in one script so CI and what you run locally are one
# definition rather than two that drift.
set -euo pipefail

# rustup installs to ~/.cargo/bin, which is not on the PATH of a systemd
# user unit and is not on the PATH of a login shell when rustup was run
# with --no-modify-path. Source it here rather than depending on whoever
# configured the runner having got it right — a gate that only works on one
# machine is not a gate.
[ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env"

for tool in cargo rustc rustfmt go node npm; do
  command -v "$tool" >/dev/null || { echo "gate: $tool not on PATH" >&2; exit 1; }
done
rustup target list --installed 2>/dev/null | grep -qx wasm32-unknown-unknown \
  || { echo "gate: wasm32-unknown-unknown target missing — rustup target add wasm32-unknown-unknown" >&2; exit 1; }

export CGO_ENABLED=0
export GOPRIVATE="${GOPRIVATE:+$GOPRIVATE,}amadan.net/oficina/estilo.git"

# The runner clones a fresh worktree per branch, so fixtures/node_modules
# (gitignored, like any node_modules) never carries over from a previous
# run. npm ci reads fixtures/package-lock.json rather than resolving from
# fixtures/package.json, so the installed yjs is exactly the pinned 13.6.27
# — no silent drift from whatever a bare `npm install` would pick.
echo "=== fixtures: node dependencies"
(cd fixtures && npm ci)

# save-queue.mjs is the editor's retry/drop logic, factored out so it
# runs under plain Node with no DOM/browser needed (fixtures/
# save-queue.mjs's own doc comment). This is fix round 1's regression
# test for F1 — a permanently-rejected batch used to retry forever and
# absorb every later edit, losing all of it silently — and it belongs
# in the gate, not just something run by hand once, or a future
# regression here goes uncaught the same way F1 itself did.
echo "=== fixtures: the save queue's retry/drop logic (F1 regression)"
(cd fixtures && node --test save-queue.test.mjs)

# fix round 2's blocking finding: a resync that failed once used to
# give up forever, silently recreating F1's own data loss for the
# rest of the session (resync-controller.mjs's own doc comment has
# the full mechanism). Same reasoning as the F1 test above — this
# belongs in the gate, not just something run by hand once.
echo "=== fixtures: the resync retry/backoff state machine (fix round 2 regression)"
(cd fixtures && node --test resync-controller.test.mjs)

# live-updates.mjs owns the cross-tab cursor and the stream's
# connection policy, and two of its tests are regressions for defects
# caught in review before the code existed: a cursor that advances
# over a gap skips rows permanently, and an idle-close whose only
# reopen triggers are visibility/focus/edit leaves a tab that is
# merely being READ permanently stale. Both fail silently in a
# browser — the document still saves, it just stops learning — so
# they belong in the gate rather than in a manual check nobody
# repeats.
echo "=== fixtures: the live-update cursor and connection policy"
(cd fixtures && node --test live-updates.test.mjs)

# fixtures/build-bundles.mjs is byte-reproducible: no sourcemaps, no
# banners, no timestamps, no absolute paths baked into the output, and
# esbuild's actual work happens in a pinned native binary (0.28.2),
# not interpreted JS, so the Node version running it does not enter
# into the result either. Five consecutive runs on this one runner
# produce identical sha256, which is all a freshness check ever
# needs — a weaker bar than cross-machine reproducibility, and
# already met. Rebuilding here and diffing what's committed mirrors
# "the embedded wasm must match a fresh build" below, for the same
# reason that check is safe rather than randomly red.
# align.mjs is the editor's half of paragraph alignment — the schema
# attribute, the command and the active-state reader — factored out of
# editor-entry.js and toolbar.mjs so it runs under plain Node with no
# DOM, exactly like the three modules above. It belongs in the gate for
# the reason its own tests spell out: the class names and attribute
# values in it are the same vocabulary as core/src/fragment.rs's
# ALIGNMENTS and internal/export/pdf.go's alignOf, which are separate
# binaries that cannot import it, and both of the mistakes it guards
# (an alignment nobody chose written into the document, a command that
# edits when it was only being asked a question) fail silently in a
# browser.
echo "=== fixtures: paragraph alignment in the editor"
(cd fixtures && node --test align.test.mjs)

# fonts.mjs is the font vocabulary — the three faces, their marks, and
# which of them an unmarked run is in. That last one is the reason this
# is in the gate: the default face is stated in four places that cannot
# import each other (this module, views/show.html's [docs-text] rule,
# internal/export/pdf.go's styleFor and internal/fonts' Default), and a
# toolbar that disagrees with the other three fails completely silently
# — the control names a face the text is not in, and choosing that face
# does nothing because it is already what the run has no mark for.
# fonts.mjs also runs a load-time guard that only executes because this
# test imports it, the same arrangement align.test.mjs has with
# toolbar.mjs's.
echo "=== fixtures: the font vocabulary and its default"
(cd fixtures && node --test fonts.test.mjs)

# paste.mjs decides what a blank line pasted out of Google Docs means.
# It is in the gate because the mistake it is one edit away from is
# invisible until someone pastes: turning EVERY hard_break into a
# paragraph break would destroy Shift+Enter, which editor-entry.js binds
# and core/src/fragment.rs renders, and it would do so only on the paste
# path — which had no test at all before this file. Like the four above
# it is DOM-free: transformPasted takes a Slice and returns a Slice.
echo "=== fixtures: what a pasted blank line means"
(cd fixtures && node --test paste.test.mjs)

# indent.mjs is the second block attribute's vocabulary — the value is a
# NUMBER where alignment's is a string, so where align.test.mjs above
# checks that a closed table is consulted, this checks a clamp. The
# cases that matter are the ones no editor produces and
# POST /d/{sqid}/updates accepts anyway: a level past the maximum, a
# zero, a value that is not a number. core/src/fragment.rs holds the
# same three in Rust, independently, so a document cannot indent one
# distance on screen and another on paper.
echo "=== fixtures: the indent vocabulary and its clamp"
(cd fixtures && node --test indent.test.mjs)

# sizes.mjs is the point ladder, and importing it here is what makes its
# own module-load guard run in the gate — the same arrangement align and
# fonts already have, for the reason their comments give: a guard that
# only executes when a browser mounts the editor is dead code in CI, so
# a drifted vocabulary would pass everything and then throw for every
# user. The legacy four-step marks are the part most worth holding: they
# are no longer offered and must go on being understood forever, because
# documents in the wild carry them.
echo "=== fixtures: the point ladder, and the four-step scale it replaced"
(cd fixtures && node --test sizes.test.mjs)

# autoformat.mjs turns "- " into a bullet, and the Backspace that takes
# it back is the whole reason the feature is tolerable rather than
# infuriating. Both halves are in the gate because both are invisible
# until someone types: a rule that fires where it should not, and a
# revert that does not give back the characters, each look like a broken
# editor and neither shows up in any Go test. DOM-free — ProseMirror's
# state, model and inputrules packages are all headless, which matters
# because Chromium will not start in this project's sandbox, so a rule
# tested only through the browser drive is tested nowhere most of the
# time.
echo "=== fixtures: the autoformat rules, and the Backspace that undoes one"
(cd fixtures && node --test autoformat.test.mjs)

# A word count is a number people quote to each other, so being quietly
# wrong is worse here than in most places. The cases that matter are the
# ones whitespace-splitting gets wrong — punctuation, and the languages
# that put no space between words at all, where splitting on whitespace
# reports 1 for a whole sentence.
echo "=== fixtures: counting words, including where there are no spaces"
(cd fixtures && node --test wordcount.test.mjs)

# menu.mjs's enabling logic, against a forty-line fake DOM.
#
# In the gate because its absence already shipped a bug: sync read the
# module constant where it should have read the merged table, so every
# command the caller passed in — paged, infinite, word count, the
# shortcuts sheet — was disabled on every update and could never be
# clicked. Three deliverables unreachable, and nothing saw it: the Go
# tests check the model and the markup, and the gate cannot open a
# browser. The first test in this file fails against that exact edit.
echo "=== fixtures: which menu items the editor enables"
(cd fixtures && node --test menu.test.mjs)

# Both bars of the editor's chrome, against the same kind of recording
# fake DOM. One plugin drives them in the browser (the toolbar's, with
# the menu on its onSync) and this drives them the same way.
#
# In the gate because its absence shipped a visible flash and would ship
# it again. Both bars sync on every ProseMirror state update, and both
# used to rewrite every control's state unconditionally; setAttribute
# with the value already there still invalidates style and still records
# a mutation, and so does setting a reflected boolean property to the
# value it already holds. So the chrome repainted once per keystroke,
# and a tab getting its frames back paid for the whole backlog in one
# visible frame — 23 mutations measured on reveal, 21 of them redundant
# attribute writes.
#
# Held against the source as well as against the controls that exist
# today, because the failure mode is a control added later: the count
# went from 12 to 23 across the toolbar redesign, and then menu.mjs,
# written afterwards by a different session, reproduced the whole defect
# from scratch. Five of the 23 were its.
echo "=== fixtures: the editor's chrome writes only what changed"
(cd fixtures && node --test toolbar.test.mjs)

echo "=== the editor bundle must match a fresh build"
(cd fixtures && node build-bundles.mjs)
if [ -n "$(git status --porcelain -- internal/docs/static/)" ]; then
  echo "gate: internal/docs/static/ does not match a fresh build. Run 'cd fixtures && node build-bundles.mjs' and commit the result." >&2
  # The rebuild above just mutated a committed directory as a side
  # effect of checking it. Restore it to what's actually committed —
  # tracked changes via checkout, any new hashed file the rebuild
  # wrote via clean — so the only trace of this failure is the
  # message and exit code above, not a stray dirty file a later
  # command (or a re-run of this same script) trips over, or a
  # rebuilt-to-fresh tree that would silently pass a second run
  # without anyone having committed anything.
  git checkout -- internal/docs/static/
  git clean -fd -- internal/docs/static/ >/dev/null
  exit 1
fi

echo "=== rust: fmt"
cargo fmt --manifest-path core/Cargo.toml --check

# build-wasm.sh is the ONLY clippy invocation in this gate (it runs
# --all-targets -D warnings -D clippy::disallowed_methods) and it runs
# BEFORE cargo test, for two reasons that used to be two separate bugs:
#
# 1. A second, looser clippy invocation here previously let a banned
#    method planted in a test file sail through this fast check and get
#    caught only ~40s later, inside build-wasm.sh's own clippy run --
#    defeating the point of failing fast. One invocation, no drift.
# 2. core/tests/no_host_imports.rs asserts zero wasm imports by reading
#    the wasm32 artifact off disk; it only exists after this line. Running
#    cargo test first made the assertion's own presence into a race —
#    it silently reported PASS-via-SKIP unless some other test happened
#    to build the artifact as a side effect first. Building here removes
#    the race instead of hoping to win it.
echo "=== wasm: clippy, then the artifact the host embeds"
./scripts/build-wasm.sh

echo "=== rust: build, test"
# DOCS_REQUIRE_WASM=1 turns core/tests/no_host_imports.rs's "artifact
# missing" branch into a failure instead of a skip. It is always set here
# because the wasm artifact was just built above; unset, a bare local
# `cargo test` still skips gracefully on a fresh clone.
export DOCS_REQUIRE_WASM=1
cargo build --release --manifest-path core/Cargo.toml
cargo test --manifest-path core/Cargo.toml

echo "=== the embedded wasm must match a fresh build"
if ! cmp -s core/target/wasm32-unknown-unknown/release/docs_core.wasm internal/core/testdata/docs_core.wasm; then
  echo "gate: internal/core/testdata/docs_core.wasm is stale. Re-copy it." >&2
  exit 1
fi

echo "=== wire compatibility, both directions"
./scripts/wire-gate.sh

echo "=== go: vet, test, gofmt"
go vet ./...
# The browser checks (internal/docs/*_browser_test.go) need a Chromium, so
# the gate does not run them; it vets them, so a test behind a tag that
# nothing compiles cannot rot silently.
go vet -tags browser ./internal/docs/
go test ./... -count=1
test -z "$(gofmt -l .)" || { echo "gate: gofmt"; gofmt -l .; exit 1; }

echo "=== go: build ./... (the app, not just the packages tests import)"
# go vet/go test above already type-check every package they touch,
# but neither one proves cmd/docs itself links into a runnable binary
# under CGO_ENABLED=0 with no -mod flag (this repo's own constraint) —
# a plain build is the only step that actually does. CGO_ENABLED is
# already exported above; stated again here so this line reads
# correctly on its own.
CGO_ENABLED=0 go build ./...

# There used to be a further step here: build cmd/docs with -tags
# release and assert the result refuses to start with -dev-auth on
# its command line, proving a release binary had no code path that
# could ever turn the development-only stand-in identity on
# (cmd/docs/devauth_flag_release.go). Fix round 1 on Task 1 deleted
# that identity outright — internal/docs/devauth.go had zero readers
# once rastrillo/auth's RequireSession started guarding every
# document route unconditionally, with no flag anywhere capable of
# disabling it — so the `release` build tag it was the only user of
# is gone too, and there is no longer a release/non-release
# distinction for this gate to hold apart. The property that check
# guarded (an unauthenticated binary reachable at a real URL) is now
# covered a different way: go test above runs
# TestNewRefusesMissingInstanceKeyOutsideDevelopment and
# TestNewRefusesMissingMailOutsideDevelopment (internal/docs/auth_test.go),
# proving Env other than "development" still fails closed on its own
# fail-closed configuration, and TestSignInRequiredForEverything
# proves every document route — GET and POST alike — refuses a
# signed-out request. A stray "-dev-auth" on any build's command line
# today reaches rastrillo.Resolve's own flag.FlagSet undefined, and is
# refused exactly like any other unrecognized flag.

echo "gate: ok"
