#!/usr/bin/env nu
# domains/rustelo/commands.nu — CLI dispatch for RusteloApp projects.
# Delegates to ontoref --project-root <rustelo> for domain-level operations.
# publish-contract / probe deliver the content-publisher consumption contract
# (ADR-046 view discipline, ADR-056 verification) to any onboarded RusteloApp.

# Locate the site workspace (dir containing site/content) under a project root.
def site-workspace [root: string]: nothing -> string {
  if ([$root, "site", "content"] | path join | path exists) { $root } else if ([$root, "code", "site", "content"] | path join | path exists) { $"($root)/code" } else { "" }
}

def main [cmd?: string, ...rest: string] {
  let domain_root = (^ontoref project root rustelo | str trim)
  match ($cmd | default "") {
    "layers"  => { with-env { ONTOREF_PROJECT_ROOT: $domain_root } { ^ontoref describe features } },
    "plugins" => { print "  (rustelo plugin registry — not yet implemented)" },
    # Crosses the levels: routes come from the FRAMEWORK/BUILD level, the FTL and the content
    # tree from THIS instance. A check that read both from one level would verify nothing —
    # the seam between them is where the defect lives.
    "routes"  => { routes $domain_root ($env.ONTOREF_PROJECT_ROOT? | default (pwd | path expand)) ("--check" in $rest) },
    "wasm"    => { print "  (rustelo WASM targets — not yet implemented)" },
    "publish-contract" => { publish-contract $domain_root },
    "probe"            => { probe },
    _ => {
      print "  rustelo: layers | plugins | routes | wasm | publish-contract | probe"
      print "    publish-contract  the 6-answer content-publisher contract (+ oracle), from the producer"
      print "    probe             run the end-to-end publish->activate->index oracle in THIS project"
    },
  }
}

# Print the producer-declared content-publisher contract. Resolved from the
# rustelo project root — works from any RusteloApp, no copy.
def publish-contract [domain_root: string] {
  let ncl = $"($domain_root)/domains/rustelo/ontology/publishing-contract.ncl"
  if not ($ncl | path exists) {
    print $"  ✗ contract not found at ($ncl)"
    return
  }
  let res = (do { ^nickel export --import-path ($ncl | path dirname) $ncl } | complete)
  if $res.exit_code != 0 {
    print "  ✗ contract export failed:"
    print $res.stderr
    return
  }
  let c = ($res.stdout | from json)
  print ""
  print $"  (ansi cyan_bold)Rustelo content-publisher contract(ansi reset)  [profile: ($c.profile)]"
  print $"  (ansi green)activate(ansi reset)  ($c.activate_cmd)"
  print $"  (ansi green)serve(ansi reset)     ($c.serve_cmd)"
  print $"  (ansi green)index(ansi reset)     ($c.content_root)"
  for q in $c.questions {
    let tag = if $q.side == "ContentOnly" { $"(ansi green)content-only(ansi reset)" } else { $"(ansi yellow)recompile(ansi reset)" }
    print ""
    print $"  (ansi white_bold)◆ ($q.id)(ansi reset)  [($tag)]"
    print $"    (ansi default_dimmed)Q(ansi reset) ($q.question)"
    print $"    (ansi default_dimmed)A(ansi reset) ($q.answer)"
    print $"    (ansi magenta)oracle(ansi reset) ($q.oracle)"
  }
  let gaps = ($c.known_gaps? | default [])
  if ($gaps | length) > 0 {
    print ""
    print $"  (ansi yellow_bold)Known gaps(ansi reset)  — declared producer-side limitations, plan around them"
    for g in $gaps {
      print $"  (ansi yellow)▲ ($g.id)(ansi reset)  [($g.side)/($g.status)]"
      print $"    (ansi default_dimmed)area(ansi reset)    ($g.area)"
      print $"    (ansi default_dimmed)symptom(ansi reset) ($g.symptom)"
      print $"    (ansi magenta)oracle(ansi reset)  ($g.oracle)"
    }
  }
  print ""
}

# Run the end-to-end binding oracle in the CURRENT RusteloApp: publish a trivial
# item, activate (content_processor), assert it entered the index, clean up.
# Witnessed verification (ADR-056) — proves the contract before generating pages.
def probe [] {
  let root = ($env.ONTOREF_PROJECT_ROOT? | default (pwd | path expand))
  let ws = (site-workspace $root)
  if ($ws | is-empty) {
    print $"  ✗ no site/content found under ($root) (looked at site/ and code/site/) — not a content-only RusteloApp"
    return
  }
  if (which content_processor | is-empty) {
    print "  ✗ content_processor not on PATH — install the rustelo content tooling"
    return
  }
  let probe_md  = $"($ws)/site/content/blog/en/getting-started/_ontoref-probe.md"
  let probe_dir = ($probe_md | path dirname)
  mkdir $probe_dir
  "---\nid: \"_ontoref-probe\"\ntitle: \"Ontoref Probe\"\nslug: \"_ontoref-probe\"\nexcerpt: \"Auto-generated contract probe.\"\nauthor: \"ontoref\"\ndate: \"2026-01-01\"\npublished: true\ncategory: \"getting-started\"\ntags: [\"probe\"]\n---\n\n# Ontoref Probe\n\nWitnessed publish->activate->index oracle.\n" | save -f $probe_md

  cd $ws
  let act = (do { ^content_processor --content-type blog --language en } | complete)
  let html = $"($ws)/site/r/blog/en/getting-started/_ontoref-probe.html"
  let idx  = $"($ws)/site/r/blog/en/getting-started/index.json"
  let html_ok = ($html | path exists)
  let idx_ok = if ($idx | path exists) { (open --raw $idx | str contains "_ontoref-probe") } else { false }

  # Clean up the probe artifacts and re-index to drop it from the index.
  rm -f $probe_md $html $"($ws)/site/r/blog/en/getting-started/_ontoref-probe.json"
  do { ^content_processor --content-type blog --language en } | complete | ignore

  print ""
  if ($act.exit_code == 0) and $html_ok and $idx_ok {
    print $"  (ansi green_bold)GREEN(ansi reset)  publish->activate->index oracle passed"
    print $"    item indexed at site/r/blog/en/getting-started/ and present in index.json"
    print $"    next: serve \(rustelo-htmx-server\) and GET /blog/getting-started/<slug> -> expect HTTP 200"
  } else {
    print $"  (ansi red_bold)RED(ansi reset)  oracle failed — activate exit=($act.exit_code), html=($html_ok), in_index=($idx_ok)"
    if $act.exit_code != 0 { print $act.stderr }
  }
  print ""
}

# ── routes — the route manifest, and the witness across the three levels ──────
#
# This command was DECLARED in domain.ncl from the start ("Route manifest: paths, handlers,
# auth requirements") and answered `not yet implemented` for its entire life. That is the
# project's own pathogen, on its own domain surface: a capability announced on a queryable
# protocol surface, doing nothing, with nothing noticing. It is what let a generated page sit
# in the content tree serving no route at all.
#
# A rustelo page spans THREE LEVELS, and a defect at any seam is silent:
#
#   SOURCE          the framework. `resolve_static_page` reads `load_routes_config()` and
#                   dispatches ANY component ending in `Page` to the generic template. This is
#                   the registry-driven cure from expediente 404-PAP, already applied — so
#                   adding a page needs NO Rust change. Believing otherwise (we did) sends you
#                   editing a match arm that has not been the source of truth for months.
#   IMPLEMENTATION  `site/config/routes.ncl` — the declared routes. BAKED at build time.
#   CONTENT         the instance: `site/i18n/locales/<lang>/**.ftl` carries the page's TEXT.
#
# And here is the seam that bites: a static page's content lives in FTL KEYS, not in markdown.
# `static.j2` renders `texts[page_id ~ "-page-title"] | default(page_id)`. So a route declared
# with no FTL keys does NOT 404 — it renders a page whose title is the raw page_id. A broken
# page that returns 200. Nothing in the stack complains, which is why six of them are live.
#
#   ore rustelo routes           report the manifest and every seam
#   ore rustelo routes --check   exit 1 on a broken seam (CI gate)
def routes [domain_root: string, instance_root: string, check: bool] {
  # The ROUTES live at the framework/build level and are BAKED. The FTL and the content tree
  # live in THIS instance. The seam between the two levels is exactly where a page dies, so the
  # check must span both — an instance verifying only itself would report clean over a route it
  # does not own, and a framework verifying only itself would never see the orphan markdown.
  # domain_root is the framework/build tree (routes baked there) in the MANAGED model. A BONDED
  # consumer (ADR-073) is self-contained: it carries its own site/config/routes.ncl and content,
  # with no separate framework workspace on disk — build and instance coincide. Fall the build
  # workspace back to the instance when the domain root has none; only a project with NEITHER is
  # genuinely unbuildable.
  let inst_ws  = (site-workspace $instance_root)
  let dom_ws   = (site-workspace $domain_root)
  let build_ws = (if ($dom_ws | is-not-empty) { $dom_ws } else { $inst_ws })
  if ($build_ws | is-empty) {
    error make { msg: $"no site workspace under ($instance_root) or ($domain_root) — expected site/content" }
  }
  let inst = (if ($inst_ws | is-empty) { $build_ws } else { $inst_ws })

  let routes_ncl = ([$build_ws, "site", "config", "routes.ncl"] | path join)
  if not ($routes_ncl | path exists) { error make { msg: $"routes.ncl not found at ($routes_ncl)" } }

  # The declared routes. Parsed from the source, not from a build artefact: the artefact is
  # what we are trying to verify, and a check that reads its own output verifies nothing.
  let raw = (open --raw $routes_ncl)
  let declared = (
    $raw
    | parse --regex '(?s)make_route\s+"(?<component>\w+)"\s*\{(?<body>[^}]*)\}'
    | each {|r|
      let paths = ($r.body | parse --regex '"(?<p>/[^"]*)"' | get p)
      { component: $r.component, paths: $paths }
    }
  )

  # `AboutUsPage` → `about-us`. Mirrors kebab_case() in pages_htmx/src/lib.rs. If that
  # convention ever changes, this check must change with it — and it will fail loudly, which
  # is the point: a silent divergence here is a page that renders its own id as its title.
  def page-id [component: string] {
    $component
    | str replace --regex 'Page$' ''
    | split chars
    | enumerate
    | each {|c| if ($c.item =~ '[A-Z]') and ($c.index > 0) { $"-($c.item | str downcase)" } else { $c.item | str downcase } }
    | str join ""
  }

  mut findings = []

  # ── Routes the CONSUMER contributes ──────────────────────────────────────────
  # `merge_site_routes` (rustelo_core_lib) lets a site add its own per-language routes at runtime
  # from `site/config/routes/<lang>.toml`, merged over the image's. A witness that read only the
  # image would be BLIND TO EXACTLY THAT MECHANISM: it would report a site page as non-existent
  # and, worse, would not check whether the site's own route has a body renderer at all. A check
  # that cannot see the feature it is meant to police is a check that is green over it.
  #
  # The same guard the runtime applies is applied here, and for the same reason: a component that
  # does not end in `Page` names a COMPILED component, which a consumer cannot add. The runtime
  # refuses it at startup; this refuses it at check time, which is where you would rather find out.
  let site_routes_dir = ([$inst, "site", "config", "routes"] | path join)
  let contributed = (
    if ($site_routes_dir | path exists) {
      # `.ncl` AND `.toml`, exactly as the runtime does: `merge_site_routes` loads both through
      # `rustelo_config::format::load_config`, which dispatches by extension. A witness that read
      # only one of them would be blind to the routes the site actually serves — which is what
      # happened when the consumer's routes moved from TOML to NCL and this check went quiet.
      glob $"($site_routes_dir)/*.{ncl,toml}"
      | each {|f|
        let lang = ($f | path basename | str replace --regex '\.(ncl|toml)$' '')
        let cfg = (
          if ($f | str ends-with ".ncl") {
            let r = (do { ^nickel export $f } | complete)
            if $r.exit_code == 0 { ($r.stdout | from json) } else { { routes: [] } }
          } else {
            try { open --raw $f | from toml } catch { { routes: [] } }
          }
        )
        ($cfg.routes? | default []) | each {|r| { component: ($r.component? | default ""), paths: [($r.path? | default "")], lang: $lang, site: true } }
      }
      | flatten
    } else { [] }
  )
  for c in $contributed {
    if not ($c.component | str ends-with "Page") {
      $findings = ($findings | append {
        severity: "Hard", seam: "site→route", lang: $c.lang,
        what: $"($c.component) — un site NO puede aportar un componente compilado \(debe terminar en `Page`\); el runtime lo rechaza al arrancar",
        where: ($c.paths | first),
      })
    }
  }

  let declared = ($declared | append ($contributed | where {|c| $c.component | str ends-with "Page" } | each {|c| { component: $c.component, paths: $c.paths } }))
  let pages = ($declared | where {|r| $r.component | str ends-with "Page" } | uniq-by component)

  # The languages the site declares. One source, and rustelo already had this right.
  let site_ncl = ([$build_ws, "site", "config", "site.ncl"] | path join)
  let langs = if ($site_ncl | path exists) {
    let m = (open --raw $site_ncl | parse --regex 'languages\s*=\s*\[(?<l>[^\]]*)\]' | get l)
    if ($m | is-empty) { ["en"] } else { ($m | first | parse --regex '"(?<x>[a-z]{2})"' | get x) }
  } else { ["en"] }

  # ── What the check must actually establish ──────────────────────────────────
  #
  # An earlier version of this asserted only that `<page_id>-page-title` existed. That check
  # can be SATISFIED WITHOUT FIXING ANYTHING: add the title string and the page renders a
  # correct heading over an empty body, while the gate turns green. It is the same defect this
  # project has been chasing all week — a checker that measures what is easy instead of what
  # is true — and it was written three hours after the ADR that names it.
  #
  # What a page actually needs is a RENDERER FOR ITS BODY. `static_page::render` picks
  # `pages/<page_id>.j2` when it exists and falls back to the generic `static.j2`, which reads
  # exactly three keys: title, subtitle, description. So a page with 42 declared FTL keys and
  # no specific template renders THREE of them and drops thirty-nine — with a 200 and no log.
  #
  # This is the Leptos→htmx migration gap. Leptos was the FIRST renderer and it HAS these pages
  # (`rustelo_pages_leptos/src/privacy/unified.rs` renders the whole policy from those keys).
  # htmx came second and never ported them. Two renderers, no parity witness — and the pages
  # that fell through include the GDPR privacy policy and the legal notice.
  # Templates come from TWO levels and the check must see both, or it lies. `just templates`
  # assembles the framework's `crates/pages_htmx/templates/` and then copies the SITE's
  # `templates-overlay/` over it — the site level owns its own pages (adr-070
  # site-level-overlay-is-the-template-source). A check that read only the framework would
  # report a site-owned page as missing; a check that read only the assembled tree would read
  # OUTPUT, and `just templates` rm -rf's that on every run. Read the two SOURCES.
  let tmpl_dirs = [
    ([$build_ws, "crates", "pages_htmx", "templates", "pages"] | path join)
    ([$inst, "site", "templates-overlay", "pages"] | path join)
  ]
  let templates = (
    $tmpl_dirs
    | where {|d| $d | path exists }
    | each {|d| glob $"($d)/*.j2" | each {|f| $f | path basename | str replace --regex '\.j2$' '' } }
    | flatten
    | uniq
  )
  # static.j2 delegates these two classes to partials, so they DO get a body.
  let static_j2 = ([$build_ws, "crates", "pages_htmx", "templates", "pages", "static.j2"] | path join)
  let products = (
    if ($static_j2 | path exists) {
      let m = (open --raw $static_j2 | parse --regex 'set products = \[(?<p>[^\]]*)\]' | get p)
      if ($m | is-empty) { [] } else { ($m | first | parse --regex '"(?<x>[a-z]+)"' | get x) }
    } else { [] }
  )
  const VIA_PARTIAL = ["login"]
  # The three keys the generic template reads. Anything beyond them is written and dropped.
  const GENERIC_KEYS = 3

  for lang in $langs {
    let ftl_dir = ([$inst, "site", "i18n", "locales", $lang] | path join)
    if not ($ftl_dir | path exists) { continue }
    let keys = (
      glob $"($ftl_dir)/**/*.ftl"
      | each {|f| open --raw $f | parse --regex '(?m)^(?<k>[a-z0-9-]+)\s*=' | get k }
      | flatten
    )
    for p in $pages {
      let pid = (page-id $p.component)
      let declared_keys = ($keys | where {|k| ($k == $pid) or ($k | str starts-with $"($pid)-") } | length)
      let has_own = ($pid in $templates)
      let via_partial = ($pid in $products) or ($pid in $VIA_PARTIAL)

      if $has_own or $via_partial {
        # It has a body renderer. The only remaining question is whether it has a title at all.
        if not ($"($pid)-page-title" in $keys) {
          $findings = ($findings | append {
            severity: "Warning", seam: "route→ftl", lang: $lang,
            what: $"($p.component) → sin `($pid)-page-title` \(renderiza el page_id crudo\)",
            where: ($p.paths | first | default "?"),
          })
        }
      } else if $declared_keys > $GENERIC_KEYS {
        # THE ONE THAT MATTERS. Content written, and thrown away on every request.
        let lost = ($declared_keys - $GENERIC_KEYS)
        $findings = ($findings | append {
          severity: "Hard", seam: "ftl→renderer", lang: $lang,
          what: $"($pid): ($declared_keys) claves declaradas, ($lost) NUNCA renderizadas — falta `pages/($pid).j2`",
          where: ($p.paths | first | default "?"),
        })
      } else {
        # A route with no content at all. Not a lie — just empty.
        $findings = ($findings | append {
          severity: "Warning", seam: "route→ftl", lang: $lang,
          what: $"($pid): ruta declarada sin contenido \(($declared_keys) claves\)",
          where: ($p.paths | first | default "?"),
        })
      }
    }
  }

  # ── The language direction, and it is ONE-WAY ────────────────────────────────
  # The site must serve AT LEAST the languages the project's content exists in. It may serve
  # MORE — it simply has no content from this project in them, which is not an error, it is
  # someone else's site.
  #
  # The reverse check would be a disaster and was nearly written: `site.ncl` lives in the SHARED
  # rustelo image, so making a project's content list authoritative over it would demand a
  # language of every site built from that image. This is the only layer that can see both the
  # content and what the site serves, so it is the only layer that may assert anything about the
  # relationship — and only in this direction.
  let reg = ([$instance_root, ".ontoref", "ontology", "lexicon.ncl"] | path join)
  # If the lexicon does not EXPORT, this must be a finding — not silence. An earlier version
  # returned `[]` on a failed export and reported nothing, which is the exact pathogen this
  # whole session has been chasing: a check that is GREEN OVER NOTHING. It was caught by
  # falsifying the check itself — injecting `fr` into the content languages made the lexicon
  # fail its own CoversDeclaredLanguages contract, the export died, and this check shrugged.
  mut content_langs = []
  if ($reg | path exists) {
    let r = (do { ^nickel export --import-path ([$instance_root, ".ontoref"] | path join) $reg } | complete)
    if $r.exit_code == 0 {
      $content_langs = (($r.stdout | from json).languages)
    } else {
      $findings = ($findings | append {
        severity: "Hard", seam: "content→site", lang: "—",
        what: "el léxico del contenido NO EXPORTA — no se puede saber en qué idiomas existe",
        where: $reg,
      })
    }
  }
  for cl in $content_langs {
    if not ($cl in $langs) {
      $findings = ($findings | append {
        severity: "Hard", seam: "content→site", lang: $cl,
        what: $"el contenido existe en `($cl)` y el site NO lo sirve \(site.ncl: ($langs | str join ', ')\)",
        where: $site_ncl,
      })
    }
  }

  print $"rutas declaradas: ($declared | length) · páginas \(*Page\): ($pages | length) · idiomas: ($langs | str join ', ')"
  print $"  rutas   ← ($routes_ncl)"
  print $"  ftl+md  ← ($inst)/site/"
  print $"fuente: `resolve_static_page` resuelve desde el registro — añadir una página NO toca Rust"
  print ""

  if ($findings | is-empty) {
    print "✓ las tres capas coinciden: ruta → page_id → FTL, y sin markdown huérfano."
  } else {
    let hard = ($findings | where severity == "Hard")
    print $"($hard | length) Hard · (($findings | length) - ($hard | length)) aviso\(s\)"
    print ($findings | select severity seam lang what | sort-by severity seam)
  }

  if $check and (($findings | where severity == "Hard" | length) > 0) { exit 1 }
}
