#!/usr/bin/env bash
# Regenerate the wire-format bindings for `rustledger-wasm`.
#
# Three phases (ADR-0004):
#
# 1. **TypeScript** (`bindings/index.d.ts`) -- ts-rs Phase 1 (#1218,
#    #1223). `cargo test --features ts-export` triggers per-type
#    `.d.ts` emission; we concatenate, dedupe, and prettier-format.
#
# 2. **JSON Schema** (`bindings/index.schema.json`) -- Phase 3
#    (#1232). `cargo test --features json-schema` writes a single
#    draft-2020-12 schema rooted at `ParseResult`; all other DTOs
#    appear under `$defs`. Prettier-formatted for stable diffs.
#
# 3. **Python types** (`bindings/types.py`) -- Phase 3 (#1232).
#    `datamodel-code-generator` converts the JSON Schema into Pydantic
#    v2 models. No separate Rust derive needed -- Python piggybacks on
#    the schemars output.
#
# Hand-tuned overrides in phase 1:
#
# - **Concatenate + dedupe**. Each per-type ts-rs file has a
#   `// This file was generated by [ts-rs]...` header and per-file
#   `import type` statements. The concatenated bundle needs neither --
#   types reference each other within the same file.
#
# - **Override `TypedValueJson` with the narrowed discriminated
#   union.** ts-rs emits the wide struct shape
#   `{ type: ...union, value: MetaValueJson }`; the hand-tuned
#   discriminated union from #1215 narrows `value`'s shape per
#   `type` variant. The Rust DTO can't express this structurally
#   (it would need a tagged enum with a custom Deserialize for the
#   `null` variant), so the override lives here.
#
# The JSON Schema phase intentionally does NOT apply the same
# `TypedValueJson` narrowing. Pydantic v2 has its own discriminated-
# union ergonomics (`Annotated[Union[...], Field(discriminator=...)]`)
# that don't map cleanly from a TS literal-union override; rather than
# maintain two narrowings, we ship the wide form to Python users.
# See ADR-0004 Phase 3 notes for the trade-off.
#
# CI runs this script via `--check` mode (set `RUSTLEDGER_TS_CHECK=1`)
# which fails if any of the three regenerated artifacts differ from
# the checked-in versions. Local contributors run it without the env
# var to regenerate and commit.

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
WASM_DIR="$REPO_ROOT/crates/rustledger-wasm"
RAW_DIR="$WASM_DIR/bindings/bindings"
TS_OUTPUT="$WASM_DIR/bindings/index.d.ts"
SCHEMA_OUTPUT="$WASM_DIR/bindings/index.schema.json"
PY_OUTPUT="$WASM_DIR/bindings/types.py"

# Pin datamodel-code-generator at the same level as `prettier@3` in
# Phase 1 -- a minor release that changes output formatting would
# otherwise turn `bindings-fresh` red on every PR for unrelated reasons.
# Bump deliberately + commit the regenerated `types.py`.
DATAMODEL_CODEGEN_VERSION="0.32.0"

# autoflake removes the import orphaned by the placeholder strip in
# Phase 3 (see that step for the why). Pinned for the same determinism
# reason as the other generators. Pure-Python -- chosen over ruff
# because ruff ships a prebuilt dynamically-linked binary that NixOS
# can't execute via uvx/pipx, which would break local regen.
AUTOFLAKE_VERSION="2.3.1"

# === Phase 1: TypeScript ===

# Clear the intermediate per-type directory before regen. ts-rs only
# writes files for types that still derive `TS`, so a removed or
# renamed DTO leaves a stale per-type file behind; without this rm,
# `find $RAW_DIR -name '*.ts'` picks both old + new up and the
# concatenated `index.d.ts` exports the removed type. The dir is
# .gitignored (see `.gitignore`) so this is safe -- it always rebuilds.
rm -rf "$RAW_DIR"

# Run the export. `cargo test --features ts-export` triggers ts-rs's
# auto-generated `export_bindings_<type>` test functions, which write
# the per-type .d.ts files. `--quiet` keeps the test summary terse;
# failures still surface via the exit code (set -e at the top).
echo "Phase 1/3: generating TypeScript bindings..." >&2
(cd "$REPO_ROOT" && cargo test -p rustledger-wasm --features ts-export --lib --quiet)

if [ ! -d "$RAW_DIR" ]; then
    echo "error: expected ts-rs output at $RAW_DIR" >&2
    exit 1
fi

# Sort the per-type files for a deterministic output order so the
# generated file's diff stays stable across regen runs. Force the C
# locale so `Ledger.ts` and `LedgerOptions.ts` (and similar pairs
# where punctuation interacts with letters) sort identically on every
# machine. en_US.UTF-8 and C disagree on the relative order of `.`
# (0x2E) vs uppercase letters in some glibc versions -- which broke
# CI's freshness gate before this was pinned.
mapfile -t TS_FILES < <(find "$RAW_DIR" -name '*.ts' | LC_ALL=C sort)

if [ "${#TS_FILES[@]}" -eq 0 ]; then
    echo "error: no .ts files found under $RAW_DIR" >&2
    exit 1
fi

TMP_OUT="$(mktemp)"
# Catch every sidecar the script writes inside `bindings/`. Without
# this, a Ctrl-C or mid-script failure leaves `index.d.ts.new`,
# `index.schema.json.fmt`, etc. inside the directory that ends up in
# `git add bindings/`. Only `bindings/bindings/` is .gitignored;
# sidecars at the parent level are visible to `git add`.
trap 'rm -f \
    "$TMP_OUT" \
    "$TS_OUTPUT.new" "$TS_OUTPUT.fmt" "$TS_OUTPUT.err" \
    "$SCHEMA_OUTPUT.fmt" "$SCHEMA_OUTPUT.err"' EXIT

# Header.
cat > "$TMP_OUT" << 'EOF'
// AUTO-GENERATED -- DO NOT EDIT.
//
// This file is regenerated by `scripts/regen-bindings.sh` from the
// Rust DTOs in `crates/rustledger-wasm/src/types.rs`. Run the script
// after changing any DTO; CI fails if this file drifts from the
// generator output. See ADR-0004 (#1218) for the design.

EOF

# Concatenate every per-type file, stripping the per-file generated-by
# header line and any `import type { ... } from "..."` statements (all
# types resolve within this single file). Skip TypedValueJson -- it
# gets replaced with the hand-tuned discriminated union below.
#
# The import strip is awk block-mode rather than `sed '/^import type/d'`
# because ts-rs can emit multi-line imports when the import list grows:
#     import type {
#         Foo,
#         Bar,
#     } from "./Foo";
# A line-mode delete would strip only the first line and leak the
# continuation lines as orphan TS syntax. Block-mode skips from the
# opening `^import type` to the first line ending with `;`.
for f in "${TS_FILES[@]}"; do
    name="$(basename "$f" .ts)"
    if [ "$name" = "TypedValueJson" ]; then
        continue
    fi
    awk '
        /^\/\/ This file was generated by \[ts-rs\]/ { next }
        /^import type/ { in_import = 1 }
        in_import {
            if ($0 ~ /;[[:space:]]*$/) in_import = 0
            next
        }
        { print }
    ' "$f" >> "$TMP_OUT"
    echo "" >> "$TMP_OUT"
done

# Hand-tuned override for `TypedValueJson` -- ADR-0004 #4.
#
# ts-rs emits `{ type: "string" | "amount" | ..., value: MetaValueJson }`
# which doesn't narrow `value` when discriminating on `type`. The
# narrowed shape below pairs each `type` literal with the right
# payload type so `switch (v.type) { case "amount": v.value.number ... }`
# narrows correctly.
#
# When a new MetaValue variant is added on the Rust side, both the
# `meta_value_to_typed_json` converter AND this override need updating.
# The narrowing is hand-tuned because the Rust DTO is a struct
# (`{value_type, value}`), not a tagged enum -- restructuring as an
# enum is awkward because the `null` variant's payload doesn't have
# a clean serde representation. See ADR-0004 for the design rationale.
cat >> "$TMP_OUT" << 'EOF'

/**
 * Tagged-union value used in `DirectiveJson::Custom`'s `values` field
 * (issue #1207). Narrowed discriminated union -- `switch (v.type)` (or
 * `if (v.type === "amount")`) narrows `v.value` to the right payload
 * shape.
 *
 * **Hand-tuned override** of the auto-generated `TypedValueJson` -- the
 * Rust DTO is a struct, not a tagged enum, so ts-rs can't emit the
 * narrowing on its own. See ADR-0004 (#1218).
 */
export type TypedValueJson =
    | { type: "string"; value: string }
    | { type: "account"; value: string }
    | { type: "currency"; value: string }
    | { type: "tag"; value: string }
    | { type: "link"; value: string }
    | { type: "date"; value: string }
    | { type: "number"; value: string }
    | { type: "bool"; value: boolean }
    | { type: "amount"; value: { number: string; currency: string } }
    | { type: "null"; value: null };
EOF

# Format hint: collapse 3+ consecutive blank lines into 2. Cosmetic
# only; keeps the bundle readable when several short types are
# concatenated.
awk 'BEGIN { blank = 0 }
     /^$/ { blank++; if (blank <= 2) print; next }
     { blank = 0; print }' "$TMP_OUT" > "$TS_OUTPUT.new"

# Run the bundle through Prettier (#1226). ts-rs emits a compact
# one-line-per-field layout that wasm-bindgen's typescript_custom_section
# formatter miscounts brackets on -- the result is escalating
# indentation in the generated `pkg/*.d.ts`. Prettier normalizes the
# layout (proper indentation, semicolons, line breaks) so wasm-bindgen
# embeds clean output. As a side effect, the checked-in
# `bindings/index.d.ts` is easier to read and review.
#
# `npx --yes` accepts the prompt to download `prettier` if not cached.
# Both `npx` and `prettier` are available in CI via the existing Node
# toolchain that wasm-pack uses.
if ! command -v npx >/dev/null 2>&1; then
    echo "error: npx not found in PATH. Install Node.js to regenerate." >&2
    exit 1
fi
# Prettier's `--semi` default is on (we want semicolons); the old
# `--no-semi=false` was silently ignored by prettier 3 ('Ignored
# unknown option --no-semi=false. Did you mean --semi?'). Just rely
# on the default rather than passing a confusing/wrong flag.
npx --yes prettier@3 --parser=typescript --print-width=100 \
    < "$TS_OUTPUT.new" > "$TS_OUTPUT.fmt" 2> "$TS_OUTPUT.err" || {
    echo "error: prettier failed to format the TS bundle:" >&2
    cat "$TS_OUTPUT.err" >&2
    rm -f "$TS_OUTPUT.new" "$TS_OUTPUT.fmt" "$TS_OUTPUT.err"
    exit 1
}
rm -f "$TS_OUTPUT.new" "$TS_OUTPUT.err"
mv "$TS_OUTPUT.fmt" "$TS_OUTPUT"

# === Phase 2: JSON Schema ===

# The schema export test has two opt-in gates: `#[ignore]` (skipped by
# default) and `RUSTLEDGER_REGEN_SCHEMA=1` (panics without it -- guards
# against `cargo test --include-ignored` silently mutating the source
# tree). We set the env var here and pass `--exact` so a typo in the
# test path doesn't silently match zero tests and exit 0.
#
# Verification that the test actually ran is via a stdout sentinel
# ("EXPORT_INDEX_SCHEMA_RAN_OK") that the test prints on success and
# this script greps for. Mtime comparison was tried first but failed
# on warm caches where the whole test runs in <1s and the file mtime
# stays equal to the snapshot taken just before (stat -c %Y is
# whole-second resolution).
echo "Phase 2/3: generating JSON Schema..." >&2

# Capture cargo's combined stdout+stderr so we can scan for the
# sentinel. `tee` keeps the output visible in the terminal AND lets
# us grep -- we don't want to silence the test logs in CI.
SCHEMA_TEST_LOG="$(mktemp)"
# Extend the EXIT trap to also clean this up. It's covered by the
# script-level trap above.

(
    cd "$REPO_ROOT"
    RUSTLEDGER_REGEN_SCHEMA=1 cargo test \
        -p rustledger-wasm --features json-schema --lib \
        -- --include-ignored --nocapture --exact \
        types::export_json_schema::export_index_schema
) 2>&1 | tee "$SCHEMA_TEST_LOG"

if ! grep -qF "EXPORT_INDEX_SCHEMA_RAN_OK" "$SCHEMA_TEST_LOG"; then
    echo "error: the schema export test did not print its success sentinel." >&2
    echo "  This means \`cargo test --exact types::export_json_schema::export_index_schema\`" >&2
    echo "  matched zero tests (renamed/moved fn or wrongly evaluated cfg)" >&2
    echo "  OR the test ran but the SUCCESS_SENTINEL const in types.rs" >&2
    echo "  drifted from the literal this script greps for." >&2
    exit 1
fi
rm -f "$SCHEMA_TEST_LOG"

if [ ! -f "$SCHEMA_OUTPUT" ]; then
    echo "error: expected JSON Schema at $SCHEMA_OUTPUT" >&2
    exit 1
fi

# Prettier the JSON for stable diffs. Prettier's JSON output is
# canonical (sorted keys are not enforced, but indentation and
# trailing-newline rules are). We don't ask prettier to sort keys
# because schemars's output is already deterministic and key order
# is informative (`type`/`properties`/`required` in that order).
npx --yes prettier@3 --parser=json --print-width=100 \
    < "$SCHEMA_OUTPUT" > "$SCHEMA_OUTPUT.fmt" 2> "$SCHEMA_OUTPUT.err" || {
    echo "error: prettier failed to format the JSON Schema:" >&2
    cat "$SCHEMA_OUTPUT.err" >&2
    rm -f "$SCHEMA_OUTPUT.fmt" "$SCHEMA_OUTPUT.err"
    exit 1
}
rm -f "$SCHEMA_OUTPUT.err"
mv "$SCHEMA_OUTPUT.fmt" "$SCHEMA_OUTPUT"

# === Phase 3: Python (Pydantic v2 via datamodel-code-generator) ===

# `datamodel-code-generator` (https://koxudaxi.github.io/datamodel-code-generator/)
# consumes the JSON Schema and emits Pydantic v2 models. We invoke it
# via `pipx run` (CI) or assume it's already on PATH (local). The
# `--output-model-type pydantic_v2.BaseModel` selects Pydantic v2's
# discriminator support; `--use-schema-description` propagates Rust
# rustdoc comments through schemars into Python docstrings.
#
# Note: the Python class count exceeds the schema's `$defs` count. Each
# internally/externally-tagged enum in the schema is a single `$def`
# (e.g. `DirectiveJson` with its 12 `oneOf` variants), but datamodel-
# code-generator expands every variant into its own Pydantic class plus
# a union alias. So a count mismatch between `index.schema.json` `$defs`
# and `types.py` classes is expected, not a regen bug.
#
# Pinned to `$DATAMODEL_CODEGEN_VERSION` at the same level as
# `prettier@3` in Phase 1 -- a minor release that changes output
# formatting would otherwise turn `bindings-fresh` red on every PR
# for unrelated reasons. Bump deliberately and commit the regen.
echo "Phase 3/3: generating Python types..." >&2

DATAMODEL_CODEGEN_CMD=""
if command -v datamodel-codegen >/dev/null 2>&1; then
    # Local override: assume the maintainer pinned the right version.
    DATAMODEL_CODEGEN_CMD="datamodel-codegen"
elif command -v pipx >/dev/null 2>&1; then
    DATAMODEL_CODEGEN_CMD="pipx run --spec datamodel-code-generator==${DATAMODEL_CODEGEN_VERSION} datamodel-codegen"
elif command -v uvx >/dev/null 2>&1; then
    DATAMODEL_CODEGEN_CMD="uvx --from datamodel-code-generator==${DATAMODEL_CODEGEN_VERSION} datamodel-codegen"
else
    echo "error: need one of datamodel-codegen, pipx, or uvx on PATH." >&2
    echo "  Install with: pipx install 'datamodel-code-generator==${DATAMODEL_CODEGEN_VERSION}'" >&2
    exit 1
fi

# Run the codegen. NO `sed '/^WARNING/d'` filter and NO `2>&1` merge
# -- datamodel-code-generator emits WARNING lines for legitimate
# concerns (unresolved $refs, partial discriminator support, etc.)
# and silencing them was exactly the failure-swallowing pattern this
# project's CONTRIBUTING memory was written to prevent. Let stderr
# flow to the terminal; the user sees real diagnostics.
#
# Capture the result via PIPESTATUS so a non-zero exit from the
# generator (vs. a successful empty pipeline) still propagates.
# shellcheck disable=SC2086
$DATAMODEL_CODEGEN_CMD \
    --input "$SCHEMA_OUTPUT" \
    --input-file-type jsonschema \
    --output "$PY_OUTPUT" \
    --output-model-type pydantic_v2.BaseModel \
    --target-python-version 3.12 \
    --use-schema-description \
    --use-double-quotes \
    --field-constraints \
    --enum-field-as-literal all \
    --use-standard-collections \
    --use-union-operator \
    --disable-timestamp \
    --custom-file-header "$(cat <<'EOF'
# AUTO-GENERATED -- DO NOT EDIT.
#
# This file is regenerated by `scripts/regen-bindings.sh` from the
# JSON Schema at `crates/rustledger-wasm/bindings/index.schema.json`,
# which in turn comes from the Rust DTOs in
# `crates/rustledger-wasm/src/types.rs`. CI fails if this file drifts.
# See ADR-0004 (#1218 + #1232 for Phase 3) for the design.
EOF
)" || {
    echo "error: datamodel-code-generator failed (see output above)" >&2
    exit 1
}

# Sanity: a 0-exit codegen run that produced no output is still a
# failure. Catches a class of partial-success cases that exit 0 with
# an empty or missing output file.
if [ ! -s "$PY_OUTPUT" ]; then
    echo "error: $PY_OUTPUT is missing or empty after datamodel-code-generator ran" >&2
    exit 1
fi

# Strip datamodel-code-generator's auto-emitted root placeholder.
#
# Our JSON Schema is a definitions-only document (the export test in
# `crates/rustledger-wasm/src/types.rs` removes the wrapper's root-level
# keys before writing). datamodel-code-generator still emits a
# placeholder for the empty root:
#
#     class Model(RootModel[Any]):
#         root: Any
#
# That class would be a misleading public export (`from types import
# Model` would resolve to a literally-accepts-anything model). awk
# block-strips it -- the two-line shape is stable across recent
# datamodel-code-generator versions.
awk '
    /^class Model\(RootModel\[Any\]\):$/ { skip = 2; next }
    skip > 0 { skip--; next }
    { print }
' "$PY_OUTPUT" > "$PY_OUTPUT.stripped"
mv "$PY_OUTPUT.stripped" "$PY_OUTPUT"

# Belt-and-suspenders: assert the strip removed the class. If
# datamodel-code-generator's output shape ever drifts (the placeholder
# class gains a new line, or the name changes), the script should
# fail loudly rather than silently leak the placeholder.
if grep -q "^class Model(RootModel\[Any\]):$" "$PY_OUTPUT"; then
    echo "error: Model(RootModel[Any]) placeholder still present after strip." >&2
    echo "  datamodel-code-generator's output format may have drifted." >&2
    exit 1
fi

# Remove the import orphaned by the placeholder strip above.
#
# The `Model(RootModel[Any])` placeholder was the only consumer of
# `Any`, so once it's gone `from typing import Any, ...` is an unused
# import. The file is checked in and CodeQL-scanned, so a dangling
# import turns the code-scanning gate red.
#
# We run autoflake (a Python-aware unused-import remover) rather than a
# hardcoded `sed` for `Any`: autoflake understands the AST, so it
# generically removes *whatever* the strip orphaned (not just `Any`),
# won't wrongly edit the import line, and never touches `from __future__`
# or imports referenced only in annotations. This mirrors the prettier
# pass we run over the TS/JSON outputs.
DATAMODEL_AUTOFLAKE_CMD=""
if command -v autoflake >/dev/null 2>&1; then
    DATAMODEL_AUTOFLAKE_CMD="autoflake"
elif command -v pipx >/dev/null 2>&1; then
    DATAMODEL_AUTOFLAKE_CMD="pipx run --spec autoflake==${AUTOFLAKE_VERSION} autoflake"
elif command -v uvx >/dev/null 2>&1; then
    DATAMODEL_AUTOFLAKE_CMD="uvx --from autoflake==${AUTOFLAKE_VERSION} autoflake"
else
    echo "error: need one of autoflake, pipx, or uvx on PATH." >&2
    echo "  Install with: pipx install 'autoflake==${AUTOFLAKE_VERSION}'" >&2
    exit 1
fi

# shellcheck disable=SC2086
$DATAMODEL_AUTOFLAKE_CMD --remove-all-unused-imports --in-place "$PY_OUTPUT" || {
    echo "error: autoflake failed to prune unused imports from $PY_OUTPUT" >&2
    exit 1
}

# === CI-only freshness check ===

# Fail if any of the three regenerated artifacts differ from the
# committed versions, OR if any is untracked (a future binding added
# by the regen but not `git add`-ed wouldn't fail `git diff` -- it
# would just be invisible to the tracked-file comparison, silently
# escaping the freshness gate).
#
# `cd` into REPO_ROOT so `git diff -- <absolute-path>` resolves
# correctly even if the script is invoked from outside the worktree.
# Stderr is intentionally NOT redirected to /dev/null -- a `fatal: not
# a git repository` here is a genuine error and should surface, not
# be misdiagnosed as drift.
if [ "${RUSTLEDGER_TS_CHECK:-}" = "1" ]; then
    cd "$REPO_ROOT"
    drifted=0
    for path in "$TS_OUTPUT" "$SCHEMA_OUTPUT" "$PY_OUTPUT"; do
        if ! git ls-files --error-unmatch -- "$path" >/dev/null 2>&1; then
            echo "error: $path is untracked. Did you forget \`git add\`?" >&2
            drifted=1
            continue
        fi
        if ! git diff --exit-code -- "$path" >/dev/null; then
            echo "error: $path differs from the committed version." >&2
            drifted=1
        fi
    done
    if [ "$drifted" -ne 0 ]; then
        echo "Did you modify a wire-format DTO without running" >&2
        echo "scripts/regen-bindings.sh? Diff:" >&2
        git --no-pager diff -- "$TS_OUTPUT" "$SCHEMA_OUTPUT" "$PY_OUTPUT" >&2
        exit 1
    fi
    echo "OK: TS / JSON Schema / Python bindings match the committed state." >&2
else
    echo "Regenerated:" >&2
    echo "  $TS_OUTPUT" >&2
    echo "  $SCHEMA_OUTPUT" >&2
    echo "  $PY_OUTPUT" >&2
fi
