#!/usr/bin/env bash
# Tests the plugin-declared account-owned-dir merge/reconcile lib: a brand that
# ships a plugin declaring an owned top-level dir gets it unioned into the
# account SCHEMA.md allowed-top-level set; a brand without the plugin does not;
# the merge is idempotent; the reconcile check reports present before/after.
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PLAT_SCRIPTS="$SCRIPT_DIR/.."
REAL_PROJECT_DIR="$(cd "$PLAT_SCRIPTS/.." && pwd)"
REAL_TEMPLATE="$REAL_PROJECT_DIR/templates/account-schema/SCHEMA.md"
REAL_PREMIUM="$(cd "$REAL_PROJECT_DIR/.." && pwd)/premium-plugins"
PASS=0; FAIL=0; FAILED=()
# `--` so a pattern that starts with a hyphen (a region line: "- `x.json` — …")
# is read as the pattern, not as a grep option.
assert_grep()   { if printf '%s' "$2" | grep -qF -- "$1"; then PASS=$((PASS+1)); else FAIL=$((FAIL+1)); FAILED+=("$3: expected to find [$1]"); fi; }
assert_nogrep() { if printf '%s' "$2" | grep -qF -- "$1"; then FAIL=$((FAIL+1)); FAILED+=("$3: expected NOT to find [$1]"); else PASS=$((PASS+1)); fi; }
assert_eq()     { if [ "$1" = "$2" ]; then PASS=$((PASS+1)); else FAIL=$((FAIL+1)); FAILED+=("$3: expected [$2] got [$1]"); fi; }
assert_dir()    { if [ -d "$1" ]; then PASS=$((PASS+1)); else FAIL=$((FAIL+1)); FAILED+=("$2: expected directory [$1]"); fi; }
assert_nodir()  { if [ -d "$1" ]; then FAIL=$((FAIL+1)); FAILED+=("$2: expected NO directory [$1]"); else PASS=$((PASS+1)); fi; }

. "$PLAT_SCRIPTS/lib/account-schema-owned-dirs.sh"

# Build a fake platform tree at $T/platform whose premium sibling is a symlink to
# the real premium-plugins (so the real sitedesk-job PLUGIN.md is exercised).
make_tree() { # $1=ships (json array literal)
  local T; T="$(mktemp -d)"
  mkdir -p "$T/platform/plugins" "$T/platform/config" "$T/platform/templates/account-schema"
  ln -s "$REAL_PREMIUM" "$T/premium-plugins"
  cp "$REAL_TEMPLATE" "$T/platform/templates/account-schema/SCHEMA.md"
  printf '{"installDir":"test-brand","shipsPremiumBundles":%s}\n' "$1" > "$T/platform/config/brand.json"
  echo "$T"
}
make_account() { # $1=tree
  local A; A="$(mktemp -d)"
  cp "$1/platform/templates/account-schema/SCHEMA.md" "$A/SCHEMA.md"
  printf '{"accountId":"x","role":"house"}\n' > "$A/account.json"
  echo "$A"
}

# A vertical-aware tree: brand.json carries a `vertical`, and the real memory
# schema references are symlinked in so the generator exercises the shipped
# schema-<vertical>.md top-level table.
REAL_MEMORY_REFS="$REAL_PROJECT_DIR/plugins/memory/references"
make_vertical_tree() { # $1=ships  $2=vertical (schema-basename)
  local T; T="$(mktemp -d)"
  mkdir -p "$T/platform/plugins/memory" "$T/platform/config" "$T/platform/templates/account-schema"
  ln -s "$REAL_PREMIUM" "$T/premium-plugins"
  ln -s "$REAL_MEMORY_REFS" "$T/platform/plugins/memory/references"
  cp "$REAL_TEMPLATE" "$T/platform/templates/account-schema/SCHEMA.md"
  printf '{"installDir":"test-brand","shipsPremiumBundles":%s,"vertical":"%s"}\n' "$1" "$2" > "$T/platform/config/brand.json"
  echo "$T"
}

# --- sitedesk-shipping brand: quoting added ---
T_SD="$(make_tree '["sitedesk"]')"; A_SD="$(make_account "$T_SD")"
export PROJECT_DIR="$T_SD/platform"
OUT="$(merge_owned_dirs_into_schema "$A_SD")"
assert_grep "op=seed-owned-dirs" "$OUT" "sd-seed-log"
assert_grep "added=quoting" "$OUT" "sd-added-quoting"
ALLOWED="$(awk '/^```allowed-top-level$/{f=1;next} /^```$/{f=0} f' "$A_SD/SCHEMA.md")"
assert_grep "quoting" "$ALLOWED" "sd-allowlist-has-quoting"
assert_grep "Plugin-owned top-level entries" "$(cat "$A_SD/SCHEMA.md")" "sd-descriptive-section"
assert_grep "plugin-owned-dirs:start" "$(cat "$A_SD/SCHEMA.md")" "sd-marker"

# --- idempotency: second merge is byte-identical ---
BEFORE="$(shasum "$A_SD/SCHEMA.md" | awk '{print $1}')"
OUT2="$(merge_owned_dirs_into_schema "$A_SD")"
AFTER="$(shasum "$A_SD/SCHEMA.md" | awk '{print $1}')"
assert_eq "$AFTER" "$BEFORE" "idempotent-second-merge"
assert_grep "added=none" "$OUT2" "sd-second-added-none"

# --- non-sitedesk brand: no quoting ---
T_MX="$(make_tree '[]')"; A_MX="$(make_account "$T_MX")"
export PROJECT_DIR="$T_MX/platform"
merge_owned_dirs_into_schema "$A_MX" >/dev/null
ALLOWED_MX="$(awk '/^```allowed-top-level$/{f=1;next} /^```$/{f=0} f' "$A_MX/SCHEMA.md")"
assert_nogrep "quoting" "$ALLOWED_MX" "mx-no-quoting"
assert_nogrep "Plugin-owned top-level entries" "$(cat "$A_MX/SCHEMA.md")" "mx-no-section"

# --- platform plugins declare pages (storage-broker) + archive (memory) ---
# Task 1891: these two plugin-owned root dirs must land in the allowed block so
# the account-dir reconcile stops quarantining them. Symlink the real plugin
# dirs so the case exercises the shipped PLUGIN.md declaration.
T_PA="$(make_tree '[]')"
ln -s "$REAL_PROJECT_DIR/plugins/storage-broker" "$T_PA/platform/plugins/storage-broker"
ln -s "$REAL_PROJECT_DIR/plugins/memory" "$T_PA/platform/plugins/memory"
A_PA="$(make_account "$T_PA")"
export PROJECT_DIR="$T_PA/platform"
merge_owned_dirs_into_schema "$A_PA" >/dev/null
ALLOWED_PA="$(awk '/^```allowed-top-level$/{f=1;next} /^```$/{f=0} f' "$A_PA/SCHEMA.md")"
assert_grep "pages" "$ALLOWED_PA" "pa-allowlist-has-pages"
assert_grep "archive" "$ALLOWED_PA" "pa-allowlist-has-archive"

# --- reconcile: present=false on bare schema, present=true after merge ---
A_RC="$(make_account "$T_SD")"
export PROJECT_DIR="$T_SD/platform"
RC_BEFORE="$(PROJECT_DIR="$PROJECT_DIR" python3 "$PLAT_SCRIPTS/lib/account-schema-owned-dirs.py" reconcile "$A_RC")"
assert_grep "ownedDir=quoting present=false" "$RC_BEFORE" "rc-present-false"
merge_owned_dirs_into_schema "$A_RC" >/dev/null
RC_AFTER="$(PROJECT_DIR="$PROJECT_DIR" python3 "$PLAT_SCRIPTS/lib/account-schema-owned-dirs.py" reconcile "$A_RC")"
assert_grep "ownedDir=quoting present=true" "$RC_AFTER" "rc-present-true"

# --- sweep: backfills every account with an account.json ---
ACCTS="$(mktemp -d)"; mkdir -p "$ACCTS/a1"
cp "$T_SD/platform/templates/account-schema/SCHEMA.md" "$ACCTS/a1/SCHEMA.md"
printf '{"accountId":"a1","role":"client"}\n' > "$ACCTS/a1/account.json"
SWEEP="$(reconcile_all_accounts_owned_dirs "$ACCTS" 2>&1)"
assert_grep "op=reconcile" "$SWEEP" "sweep-reconcile-log"
ALLOWED_A1="$(awk '/^```allowed-top-level$/{f=1;next} /^```$/{f=0} f' "$ACCTS/a1/SCHEMA.md")"
assert_grep "quoting" "$ALLOWED_A1" "sweep-backfilled-a1"

# --- shape-b: a bundle-root PLUGIN.md declaration is resolved too ---
# A premium bundle may declare an owned dir in its root PLUGIN.md (depth 1),
# not only in a sub-plugin manifest (depth 2). Build a fully synthetic tree.
TB="$(mktemp -d)"
mkdir -p "$TB/platform/plugins" "$TB/platform/config" "$TB/platform/templates/account-schema"
mkdir -p "$TB/premium-plugins/acme/plugins/acme-sub"
cp "$REAL_TEMPLATE" "$TB/platform/templates/account-schema/SCHEMA.md"
printf '{"installDir":"acme-brand","shipsPremiumBundles":["acme"]}\n' > "$TB/platform/config/brand.json"
printf -- '---\nname: acme\naccount-owned-dirs: [{"dir": "ledger", "description": "Acme ledger."}]\n---\n' > "$TB/premium-plugins/acme/PLUGIN.md"
printf -- '---\nname: acme-sub\n---\n' > "$TB/premium-plugins/acme/plugins/acme-sub/PLUGIN.md"
A_TB="$(mktemp -d)"; cp "$TB/platform/templates/account-schema/SCHEMA.md" "$A_TB/SCHEMA.md"
export PROJECT_DIR="$TB/platform"
RES_TB="$(PROJECT_DIR="$PROJECT_DIR" python3 "$PLAT_SCRIPTS/lib/account-schema-owned-dirs.py" resolve)"
assert_grep "ledger" "$RES_TB" "shapeb-resolve-ledger"
assert_grep "\"plugin\": \"acme\"" "$RES_TB" "shapeb-owner-is-bundle"
merge_owned_dirs_into_schema "$A_TB" >/dev/null
ALLOWED_TB="$(awk '/^```allowed-top-level$/{f=1;next} /^```$/{f=0} f' "$A_TB/SCHEMA.md")"
assert_grep "ledger" "$ALLOWED_TB" "shapeb-allowlist-has-ledger"

# --- construction vertical: root buckets derived, job-owned entities nested ---
T_C="$(make_vertical_tree '["sitedesk"]' 'schema-construction')"; A_C="$(make_account "$T_C")"
export PROJECT_DIR="$T_C/platform"
OUT_C="$(merge_owned_dirs_into_schema "$A_C")"
# Match a non-empty derived list, not the bare key (which prints even for zero).
assert_grep "domainBuckets=jobs" "$OUT_C" "con-domain-log"
# Summary reports the full root set and the job-owned entities with their parent.
assert_grep "roots=jobs,customers,customer-sites,assets,parts,suppliers,ppm-contracts" "$OUT_C" "con-roots-log"
assert_grep "nested=Visit<-Job,Quote<-Job,PurchaseOrder<-Job" "$OUT_C" "con-nested-log"
ALLOWED_C="$(awk '/^```allowed-top-level$/{f=1;next} /^```$/{f=0} f' "$A_C/SCHEMA.md")"
# Root entities (blank Owner) get a top-level bucket.
for d in jobs customers customer-sites assets parts ppm-contracts suppliers; do
  assert_grep "$d" "$ALLOWED_C" "con-allow-$d"
done
# Job-owned entities (Owner=Job) do NOT get a root bucket.
for d in visits quotes purchase-orders; do
  assert_nogrep "$d" "$ALLOWED_C" "con-noroot-$d"
done
assert_nogrep "whatsapp-groups" "$ALLOWED_C" "con-no-transport-bucket"
assert_grep "customer-sites" "$ALLOWED_C" "con-site-renamed"
SCHEMA_C="$(cat "$A_C/SCHEMA.md")"
assert_grep "Domain entity buckets" "$SCHEMA_C" "con-region"
# The descriptive region tells the agent where a job-owned entity's records live.
assert_grep '`Quote` records live under `jobs/` (owned by `Job`), not a root bucket.' "$SCHEMA_C" "con-nested-line"
B_C="$(shasum "$A_C/SCHEMA.md" | awk '{print $1}')"
merge_owned_dirs_into_schema "$A_C" >/dev/null
assert_eq "$(shasum "$A_C/SCHEMA.md" | awk '{print $1}')" "$B_C" "con-idempotent"
# Supplier is a root bucket (Task 1892): received invoices file under
# suppliers/<supplier>/Invoices/, not a stray top-level inbound-invoices/.
RCS="$(PROJECT_DIR="$PROJECT_DIR" python3 "$PLAT_SCRIPTS/lib/account-schema-owned-dirs.py" reconcile "$A_C")"
assert_grep "ontologyEntity=Supplier bucket=suppliers present=true" "$RCS" "con-supplier-root-present"

# --- estate-agent vertical: Property is the sole root; Listing (and its own
# children Viewing/Offer) nest, mirroring Quote<-Job in construction ---
T_E="$(make_vertical_tree '["real-agent"]' 'schema-estate-agent')"; A_E="$(make_account "$T_E")"
export PROJECT_DIR="$T_E/platform"
OUT_E="$(merge_owned_dirs_into_schema "$A_E")"
assert_grep "domainBuckets=properties" "$OUT_E" "ea-domain-log"
assert_grep "roots=properties" "$OUT_E" "ea-roots-log"
assert_grep "nested=Listing<-Property,Viewing<-Listing,Offer<-Listing" "$OUT_E" "ea-nested-log"
ALLOWED_E="$(awk '/^```allowed-top-level$/{f=1;next} /^```$/{f=0} f' "$A_E/SCHEMA.md")"
# Property (blank Owner) is the only root bucket.
assert_grep "properties" "$ALLOWED_E" "ea-allow-properties"
# Listing and its children nest; none is a root bucket.
for d in listings viewings offers; do
  assert_nogrep "$d" "$ALLOWED_E" "ea-noroot-$d"
done
assert_nogrep "jobs" "$ALLOWED_E" "ea-no-sitedesk-jobs"
# The suppliers/ bucket is construction-only; it must not leak into another vertical.
assert_nogrep "suppliers" "$ALLOWED_E" "ea-no-suppliers"
# The descriptive region tells the agent Listing records live under properties/.
assert_grep '`Listing` records live under `properties/` (owned by `Property`), not a root bucket.' "$(cat "$A_E/SCHEMA.md")" "ea-nested-line"

# --- roots subcommand + seed_domain_root_dirs: buckets materialise on disk ---
# estate-agent (PROJECT_DIR still $T_E/platform): properties/ is created.
ROOTS_E="$(PROJECT_DIR="$T_E/platform" python3 "$PLAT_SCRIPTS/lib/account-schema-owned-dirs.py" roots)"
assert_grep "properties" "$ROOTS_E" "ea-roots-cmd"
assert_nogrep "listings" "$ROOTS_E" "ea-roots-cmd-no-listings"
SEED_E="$(seed_domain_root_dirs "$A_E")"
assert_grep "seeded-roots=properties" "$SEED_E" "ea-seed-log"
assert_dir "$A_E/properties" "ea-seed-dir"
# Idempotent: a second run creates nothing.
SEED_E2="$(seed_domain_root_dirs "$A_E")"
assert_grep "seeded-roots=none" "$SEED_E2" "ea-seed-idempotent"

# construction: every root bucket is created; nested entities are not.
export PROJECT_DIR="$T_C/platform"
A_SEED_C="$(make_account "$T_C")"
ROOTS_C="$(PROJECT_DIR="$T_C/platform" python3 "$PLAT_SCRIPTS/lib/account-schema-owned-dirs.py" roots)"
for d in jobs customers customer-sites assets parts suppliers ppm-contracts; do
  assert_grep "$d" "$ROOTS_C" "con-roots-cmd-$d"
done
seed_domain_root_dirs "$A_SEED_C" >/dev/null
for d in jobs customers customer-sites assets parts suppliers ppm-contracts; do
  assert_dir "$A_SEED_C/$d" "con-seed-dir-$d"
done
for d in quotes visits purchase-orders; do
  assert_nodir "$A_SEED_C/$d" "con-seed-nonested-$d"
done

# The all-accounts sweep seeds an existing account's root buckets too, so a
# sub-account (provisioned once at creation) gains properties/ on upgrade.
export PROJECT_DIR="$T_E/platform"
SWEEP_ACCTS="$(mktemp -d)"; mkdir -p "$SWEEP_ACCTS/ea1"
cp "$T_E/platform/templates/account-schema/SCHEMA.md" "$SWEEP_ACCTS/ea1/SCHEMA.md"
printf '{"accountId":"ea1","role":"client"}\n' > "$SWEEP_ACCTS/ea1/account.json"
SWEEP_E="$(reconcile_all_accounts_owned_dirs "$SWEEP_ACCTS" 2>&1)"
assert_grep "seeded-roots=properties" "$SWEEP_E" "sweep-seed-log"
assert_dir "$SWEEP_ACCTS/ea1/properties" "sweep-seed-properties-dir"

# --- no vertical declared: generic base only ---
T_N="$(make_tree '[]')"; A_N="$(make_account "$T_N")"
export PROJECT_DIR="$T_N/platform"
merge_owned_dirs_into_schema "$A_N" >/dev/null
ALLOWED_N="$(awk '/^```allowed-top-level$/{f=1;next} /^```$/{f=0} f' "$A_N/SCHEMA.md")"
assert_nogrep "jobs" "$ALLOWED_N" "novert-no-domain"
assert_nogrep "Domain entity buckets" "$(cat "$A_N/SCHEMA.md")" "novert-no-region"

# --- reconcile: root entity present/absent, job-owned entity never at root ---
A_RCD="$(make_account "$T_C")"
export PROJECT_DIR="$T_C/platform"
RCD_BEFORE="$(PROJECT_DIR="$PROJECT_DIR" python3 "$PLAT_SCRIPTS/lib/account-schema-owned-dirs.py" reconcile "$A_RCD")"
assert_grep "ontologyEntity=Job bucket=jobs present=false" "$RCD_BEFORE" "rcd-job-absent"
# A job-owned entity is reported as not-expected-at-root, not as a plain bucket.
assert_grep "ontologyEntity=Quote owner=Job bucket=quotes expectedAtRoot=false stray=false" "$RCD_BEFORE" "rcd-quote-nested"
merge_owned_dirs_into_schema "$A_RCD" >/dev/null
RCD_AFTER="$(PROJECT_DIR="$PROJECT_DIR" python3 "$PLAT_SCRIPTS/lib/account-schema-owned-dirs.py" reconcile "$A_RCD")"
assert_grep "ontologyEntity=Job bucket=jobs present=true" "$RCD_AFTER" "rcd-job-present"
# After a clean merge the job-owned bucket is still absent from root (no stray).
assert_grep "ontologyEntity=Quote owner=Job bucket=quotes expectedAtRoot=false stray=false" "$RCD_AFTER" "rcd-quote-no-stray"

# --- reconcile: a pre-fix account still listing a job-owned bucket at root is a stray ---
A_STRAY="$(make_account "$T_C")"
export PROJECT_DIR="$T_C/platform"
# Seed the old flat-rule layout: inject `quotes` into the allowed-top-level fence.
awk '/^projects$/{print; print "quotes"; next} {print}' "$A_STRAY/SCHEMA.md" > "$A_STRAY/SCHEMA.md.tmp" && mv "$A_STRAY/SCHEMA.md.tmp" "$A_STRAY/SCHEMA.md"
RC_STRAY="$(PROJECT_DIR="$PROJECT_DIR" python3 "$PLAT_SCRIPTS/lib/account-schema-owned-dirs.py" reconcile "$A_STRAY")"
assert_grep "ontologyEntity=Quote owner=Job bucket=quotes expectedAtRoot=false stray=true" "$RC_STRAY" "rcd-quote-stray"

# insert_stray <schema_path> <name>: add <name> as the first entry inside the
# allowed-top-level fence (simulates an account whose list carries a bucket no
# longer in the ontology).
insert_stray() {
  awk -v n="$2" '{print} /^```allowed-top-level$/{print n}' "$1" > "$1.tmp" && mv "$1.tmp" "$1"
}

# --- prune: stale bucket, absent on disk -> removed from list + logged ---
T_PRUNE="$(make_vertical_tree '["sitedesk"]' 'schema-construction')"
A_P="$(make_account "$T_PRUNE")"; export PROJECT_DIR="$T_PRUNE/platform"
insert_stray "$A_P/SCHEMA.md" "inbound-invoices"
OUT_P="$(merge_owned_dirs_into_schema "$A_P")"
ALLOWED_P="$(awk '/^```allowed-top-level$/{f=1;next} /^```$/{f=0} f' "$A_P/SCHEMA.md")"
assert_nogrep "inbound-invoices" "$ALLOWED_P" "prune-empty-removed-from-list"
assert_grep "removed=inbound-invoices" "$OUT_P" "prune-empty-logged"

# --- keep: stale bucket that holds files -> retained + keptPopulated ---
T_KEEP="$(make_vertical_tree '["sitedesk"]' 'schema-construction')"
A_K="$(make_account "$T_KEEP")"; export PROJECT_DIR="$T_KEEP/platform"
insert_stray "$A_K/SCHEMA.md" "inbound-invoices"
mkdir -p "$A_K/inbound-invoices"; echo "stamp" > "$A_K/inbound-invoices/stamp_invoice.py"
OUT_K="$(merge_owned_dirs_into_schema "$A_K")"
ALLOWED_K="$(awk '/^```allowed-top-level$/{f=1;next} /^```$/{f=0} f' "$A_K/SCHEMA.md")"
assert_grep "inbound-invoices" "$ALLOWED_K" "keep-populated-retained"
assert_grep "keptPopulated=inbound-invoices" "$OUT_K" "keep-populated-logged"
assert_nogrep "removed=inbound-invoices" "$OUT_K" "keep-populated-not-in-removed"

# --- fixed-seed survival: empty base dir is never pruned ---
T_SEED="$(make_tree '[]')"; A_S="$(make_account "$T_SEED")"; export PROJECT_DIR="$T_SEED/platform"
OUT_S="$(merge_owned_dirs_into_schema "$A_S")"
ALLOWED_S="$(awk '/^```allowed-top-level$/{f=1;next} /^```$/{f=0} f' "$A_S/SCHEMA.md")"
for d in documents url-get .git output; do
  assert_grep "$d" "$ALLOWED_S" "seed-survives-$d"
done
assert_grep "removed=none" "$OUT_S" "seed-nothing-removed"

# --- idempotency after a prune: second run byte-identical (same brand) ---
export PROJECT_DIR="$T_PRUNE/platform"
B_P="$(shasum "$A_P/SCHEMA.md" | awk '{print $1}')"
merge_owned_dirs_into_schema "$A_P" >/dev/null
assert_eq "$(shasum "$A_P/SCHEMA.md" | awk '{print $1}')" "$B_P" "prune-idempotent"

# --- account-owned-files: a declared file joins the same allowed block ---
# The block is a flat list of names and already carries SCHEMA.md and
# account.json, so a file needs no second surface. A second plugin declares a
# malformed value: its key is skipped and the valid declaration still merges.
TF="$(mktemp -d)"
mkdir -p "$TF/platform/plugins/filer" "$TF/platform/plugins/broken" \
         "$TF/platform/config" "$TF/platform/templates/account-schema"
cp "$REAL_TEMPLATE" "$TF/platform/templates/account-schema/SCHEMA.md"
printf '{"installDir":"files-brand","shipsPremiumBundles":[]}\n' > "$TF/platform/config/brand.json"
printf -- '---\nname: filer\naccount-owned-files: [{"file": "x.json", "description": "Filer index."}]\n---\n' \
  > "$TF/platform/plugins/filer/PLUGIN.md"
printf -- '---\nname: broken\naccount-owned-files: [{"file": "y.json"\n---\n' \
  > "$TF/platform/plugins/broken/PLUGIN.md"
A_TF="$(mktemp -d)"; cp "$TF/platform/templates/account-schema/SCHEMA.md" "$A_TF/SCHEMA.md"
export PROJECT_DIR="$TF/platform"
OUT_TF="$(merge_owned_dirs_into_schema "$A_TF")"
ALLOWED_TF="$(awk '/^```allowed-top-level$/{f=1;next} /^```$/{f=0} f' "$A_TF/SCHEMA.md")"
assert_grep "x.json" "$ALLOWED_TF" "files-allowlist-has-x"
assert_nogrep "y.json" "$ALLOWED_TF" "files-malformed-skipped"
assert_grep "added=x.json" "$OUT_TF" "files-added-logged"
# A file renders without the trailing slash a dir gets; three parsers read that
# slash to tell a bucket from anything else.
assert_grep '- `x.json` — Filer index.' "$(cat "$A_TF/SCHEMA.md")" "files-region-no-slash"
assert_nogrep '- `x.json/`' "$(cat "$A_TF/SCHEMA.md")" "files-region-not-a-dir"
RC_TF="$(PROJECT_DIR="$PROJECT_DIR" python3 "$PLAT_SCRIPTS/lib/account-schema-owned-dirs.py" reconcile "$A_TF")"
assert_grep "ownedFile=x.json present=true" "$RC_TF" "files-reconcile-line"
B_TF="$(shasum "$A_TF/SCHEMA.md" | awk '{print $1}')"
merge_owned_dirs_into_schema "$A_TF" >/dev/null
assert_eq "$(shasum "$A_TF/SCHEMA.md" | awk '{print $1}')" "$B_TF" "files-idempotent"

# A dir declaration keeps its slash and its ownedDir= reconcile line.
TD="$(mktemp -d)"
mkdir -p "$TD/platform/plugins/dirred" "$TD/platform/config" "$TD/platform/templates/account-schema"
cp "$REAL_TEMPLATE" "$TD/platform/templates/account-schema/SCHEMA.md"
printf '{"installDir":"dirs-brand","shipsPremiumBundles":[]}\n' > "$TD/platform/config/brand.json"
printf -- '---\nname: dirred\naccount-owned-dirs: [{"dir": "widgets", "description": "Widget workspace."}]\n---\n' \
  > "$TD/platform/plugins/dirred/PLUGIN.md"
A_TD="$(mktemp -d)"; cp "$TD/platform/templates/account-schema/SCHEMA.md" "$A_TD/SCHEMA.md"
export PROJECT_DIR="$TD/platform"
merge_owned_dirs_into_schema "$A_TD" >/dev/null
assert_grep '- `widgets/` — Widget workspace.' "$(cat "$A_TD/SCHEMA.md")" "dirs-region-keeps-slash"
RC_TD="$(PROJECT_DIR="$PROJECT_DIR" python3 "$PLAT_SCRIPTS/lib/account-schema-owned-dirs.py" reconcile "$A_TD")"
assert_grep "ownedDir=widgets present=true" "$RC_TD" "dirs-reconcile-line"

# --- a malformed ELEMENT skips its whole key, not just that element ---
# json.loads succeeds and the first entry is well-formed, so an append-as-you-go
# parse would merge a.json and silently drop c.json. A half-merged declaration is
# worse than none: the dropped name reports as a stray forever with no line
# anywhere saying its declaration failed.
TP="$(mktemp -d)"
mkdir -p "$TP/platform/plugins/partial" "$TP/platform/plugins/good" \
         "$TP/platform/config" "$TP/platform/templates/account-schema"
cp "$REAL_TEMPLATE" "$TP/platform/templates/account-schema/SCHEMA.md"
printf '{"installDir":"partial-brand","shipsPremiumBundles":[]}\n' > "$TP/platform/config/brand.json"
printf -- '---\nname: partial\naccount-owned-files: [{"file": "a.json"}, "b.json", {"file": "c.json"}]\n---\n' \
  > "$TP/platform/plugins/partial/PLUGIN.md"
printf -- '---\nname: good\naccount-owned-files: [{"file": "d.json", "description": "Good."}]\n---\n' \
  > "$TP/platform/plugins/good/PLUGIN.md"
A_TP="$(mktemp -d)"; cp "$TP/platform/templates/account-schema/SCHEMA.md" "$A_TP/SCHEMA.md"
export PROJECT_DIR="$TP/platform"
merge_owned_dirs_into_schema "$A_TP" >/dev/null
ALLOWED_TP="$(awk '/^```allowed-top-level$/{f=1;next} /^```$/{f=0} f' "$A_TP/SCHEMA.md")"
assert_nogrep "a.json" "$ALLOWED_TP" "partial-first-entry-skipped"
assert_nogrep "c.json" "$ALLOWED_TP" "partial-later-entry-skipped"
assert_grep "d.json" "$ALLOWED_TP" "partial-sibling-plugin-still-merges"

# --- a name carrying a path separator is refused ---
# The trailing slash inside the backticks is what tells a bucket from a file in
# the descriptive region, and the fence entry must match a readdir name. A
# declared "foo/" would render as a bucket line and never match anything.
TS_="$(mktemp -d)"
mkdir -p "$TS_/platform/plugins/slashed" "$TS_/platform/config" "$TS_/platform/templates/account-schema"
cp "$REAL_TEMPLATE" "$TS_/platform/templates/account-schema/SCHEMA.md"
printf '{"installDir":"slash-brand","shipsPremiumBundles":[]}\n' > "$TS_/platform/config/brand.json"
printf -- '---\nname: slashed\naccount-owned-files: [{"file": "foo/", "description": "Bad."}]\naccount-owned-dirs: [{"dir": "bar/baz", "description": "Bad."}]\n---\n' \
  > "$TS_/platform/plugins/slashed/PLUGIN.md"
A_TS="$(mktemp -d)"; cp "$TS_/platform/templates/account-schema/SCHEMA.md" "$A_TS/SCHEMA.md"
export PROJECT_DIR="$TS_/platform"
merge_owned_dirs_into_schema "$A_TS" >/dev/null
assert_nogrep "foo/" "$(cat "$A_TS/SCHEMA.md")" "slash-file-refused"
assert_nogrep "bar/baz" "$(cat "$A_TS/SCHEMA.md")" "slash-dir-refused"

# --- shipped declarations reach an account's allowed block ---
# Symlink the real plugin dirs so the case exercises the shipped PLUGIN.md
# frontmatter, the same way the Task 1891 pages/archive case does.
T_SHIP="$(make_tree '[]')"
for _p in business-assistant cloudflare scheduling admin; do
  ln -s "$REAL_PROJECT_DIR/plugins/$_p" "$T_SHIP/platform/plugins/$_p"
done
A_SHIP="$(make_account "$T_SHIP")"
export PROJECT_DIR="$T_SHIP/platform"
merge_owned_dirs_into_schema "$A_SHIP" >/dev/null
ALLOWED_SHIP="$(awk '/^```allowed-top-level$/{f=1;next} /^```$/{f=0} f' "$A_SHIP/SCHEMA.md")"
# The four channel/session files are written by the session manager and the UI
# server on every brand, so they are declared on admin, which no brand excludes.
# realagent-code and property-administrators exclude the telegram plugin from
# their payload (brands/*/brand.json) while still constructing the telegram store
# (services/claude-session-manager/src/index.ts), so a declaration on the telegram
# plugin would leave those two brands reporting a permanent false stray. This
# tree deliberately omits whatsapp and telegram to prove the reach.
for _n in e-sign data-portal.json calendar-availability.json wa-channel-bindings.json \
          telegram-channel-bindings.json webchat-channel-bindings.json \
          canonical-webchat-session.json session-titles.json agents-disabled.json; do
  assert_grep "$_n" "$ALLOWED_SHIP" "ship-allow-$_n"
done

echo "PASS=$PASS FAIL=$FAIL"
if [ "$FAIL" -ne 0 ]; then printf '%s\n' "${FAILED[@]}"; exit 1; fi
