#!/usr/bin/env ruby
# frozen_string_literal: false
#
# ARION AER-1 verifier - independent Ruby implementation (single file,
# stdlib only: json, digest, base64, find).
#
# Ported check-for-check from verify.js (ARION's independent Node.js
# verifier), itself an independent reimplementation of the AER-1
# receipt/chain/workflow verification rules in draft-zambo-aer1-09+
# written from the draft text.
#
# Implements:
#   - Section 3/4/5/6  : core receipt checks (members, types, provenance,
#                        strict base64 + strict UTF-8 canonical bytes,
#                        output commitment recomputation)
#   - Reference-producer profile (--profile): canonical payload must be a
#     JSON object carrying an "inputs" member
#   - Disinterested anchor tier (--anchor, or auto when the anchor uses the
#     tier vocabulary log/leaf_hash/anchored_at/proof)
#   - Section 7.1/7.2  : -07/-08 hash-chained job timelines (canonical-JSON
#                        entry digest binding seq, job_id, close, id, tool,
#                        provenance_class, output_hash)
#   - Section 7.4      : --chain06 = historical -06 construction (digest =
#                        SHA-256 of the decoded canonical_bytes alone); -06
#                        timelines MUST NOT verify under -07/-08 rules
#   - Section 7.3      : bare final-entry-digest commitment + -09 chain
#                        commitment artifact (job_id/final_entry_digest/
#                        entry_count/published_at)
#   - Section 8/8.1/8.2: workflow receipts + Merkle root over step
#                        receipt_ids (leaf = SHA-256(utf8(receipt_id)),
#                        odd-level last duplication)
#
# CLI:
#   ruby aer1verify.rb <file-or-dir> [--strict] [--chain|--chain06|
#       --workflow|--receipt] [--profile] [--anchor] [--json]
#   <dir> is scanned recursively for *.json files (sorted by path; dotfiles
#   and dot-directories skipped); each file's kind is auto-detected:
#   JSON array -> Section 7 timeline; object type "verifiable-workflow-
#   receipt" -> Section 8 workflow; object with "timeline"/"entries" ->
#   wrapped timeline; otherwise -> Section 3 receipt.
#   Prints one PASS/FAIL line per file (FAIL names the first failing
#   check), then "CONFORMANCE: <passed>/<total>". Exit 0 iff all pass.
#
# Ruby-vs-JS parity notes (see RUBY-PORT.md for the full list):
#   - JSON.parse returns Integer/Float; JS has only doubles. Every number
#     is normalized to a Float at load so Number.isInteger semantics
#     (integer-valued doubles incl. 2.0 and 1e20) carry over verbatim.
#   - Ruby rejects lone \uXXXX surrogates at parse time where JS accepts
#     them; the file loader rewrites unpaired surrogate escapes to U+FFFD
#     so the same documents reach the checks.
#   - Regex anchors use \A..\z (Ruby $ would match before a trailing
#     newline - the corpus pins trailing-newline rejections).
#
# Disclosure: written by ARION, an autonomous agent (AI-authored). It is an
# independent reimplementation from the draft text; the conformance kit's
# own runners were consulted only to pin corpus conventions the draft does
# not specify.

require "json"
require "digest"
require "base64"
require "find"

# Sentinel for "member absent" - JS distinguishes an absent property
# (undefined) from a present JSON null; Ruby nil conflates them.
UNDEFINED = Object.new

# --------------------------------------------------------------------
#  Primitives
# --------------------------------------------------------------------

def sha256hex(data)
  Digest::SHA256.hexdigest(data)
end

def sha256bin(data)
  Digest::SHA256.digest(data)
end

# A Ruby string's raw bytes as UTF-8 (mirrors Node's utf8 encode of a JS
# string; lone surrogates / WTF-8 bytes scrub to U+FFFD, the same code
# point V8's utf8 encoder emits for an unpaired surrogate).
def utf8_bytes(s)
  t = s.dup.force_encoding(Encoding::UTF_8)
  t.valid_encoding? ? t : t.scrub
end

# Strict UTF-8 decode (Section 4): non-UTF-8 canonical bytes fail closed.
def decode_utf8_strict?(buf)
  buf.dup.force_encoding(Encoding::UTF_8).valid_encoding?
end

# Base64 surface check. The kit convention (documented in the runners) is
# "alphabet plus at most two pad chars, total length a multiple of 4";
# non-canonical trailing pad bits are tolerated at decode time (Python's
# base64.b64decode(validate=True) and Node's Buffer.from(s,"base64")
# behave the same). B64_RE is used for both modes - verify.js's strict
# variant is identical.
B64_RE = /\A[A-Za-z0-9+\/]*={0,2}\z/

def decode_base64_strict(s, strict)
  return nil unless s.is_a?(String)
  ok = begin
    !B64_RE.match(s).nil? && (s.length % 4).zero?
  rescue Encoding::CompatibilityError
    false # e.g. a lone-surrogate string: the pattern cannot match in JS either
  end
  return nil unless ok
  Base64.decode64(s) # lenient decode, like Node (regex already gated)
end

# --------------------------------------------------------------------
#  Field validators (Section 3)
# --------------------------------------------------------------------

# \z anchors == JS ^...$ on whole input (Ruby $ would allow a trailing
# newline - id-trailing-newline.json pins this).
UUID_LOOSE_RE = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/
UUID_V4_RE    = /\A[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\z/

# output_hash / leaf_hash: "sha256:" + 64 lowercase hex chars.
HASH_RE = /\Asha256:[0-9a-f]{64}\z/
HEX64_RE = /\A[0-9a-f]{64}\z/

# Provenance classes (Section 5).
PROV_RE = /\A(EXECUTED BY [A-Z0-9][A-Z0-9 ._-]*|OBSERVED VIA GATEWAY|LOGGED BY AGENT)\z/

RFC3339_RE  = /\A[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]+)?(Z|[+-][0-9]{2}:[0-9]{2})\z/
TS_PARTS_RE = /\A([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2})(\.[0-9]+)?(Z|[+-][0-9]{2}:[0-9]{2})\z/

def re_full?(re, s)
  !re.match(s).nil?
rescue Encoding::CompatibilityError
  false
end

def leap_year?(y)
  (y % 4).zero? && (y % 100).nonzero? || (y % 400).zero?
end

def days_in_month(y, m)
  return (leap_year?(y) ? 29 : 28) if m == 2
  return 30 if [4, 6, 9, 11].include?(m)
  31
end

# Calendar validation (Section 3: "the timestamp MUST denote a valid
# calendar date and time"). Numeric UTC offset bounded strictly inside
# 24h total; hours <= 23 and minutes <= 59 per component. The
# s.sub("Z","+00:00") mirrors JS's first-occurrence replace quirk.
def is_calendar_datetime(s, strict)
  m = TS_PARTS_RE.match(s.sub("Z", "+00:00"))
  return false unless m
  year = m[1].to_i; mon = m[2].to_i; day = m[3].to_i
  hh = m[4].to_i; mi = m[5].to_i; ss = m[6].to_i
  return false if year < 1 || year > 9999
  return false if mon < 1 || mon > 12
  return false if day < 1 || day > days_in_month(year, mon)
  return false if hh > 23 || mi > 59 || ss > 59
  off = m[8]
  if off != "Z"
    oh = off[1, 2].to_i; om = off[4, 2].to_i
    return false if oh > 23 || om > 59
    total = (off[0] == "-" ? -1 : 1) * (oh * 60 + om)
    return false unless total > -1440 && total < 1440
  end
  true
end

def is_rfc3339_format(s, strict)
  s.is_a?(String) && re_full?(RFC3339_RE, s)
end

def is_rfc3339(s, strict)
  is_rfc3339_format(s, strict) && is_calendar_datetime(s, strict)
end

def is_object(v)
  v.is_a?(Hash)
end

def is_non_empty_string(v)
  v.is_a?(String) && !v.empty?
end

# JS truthiness for `doc.timeline || doc.entries` (Ruby treats 0 and ""
# as truthy; JS does not).
def js_truthy?(v)
  return false if v.nil? || v.equal?(false)
  return false if v.is_a?(Numeric) && (v.zero? || (v.is_a?(Float) && v.nan?))
  return false if v.is_a?(String) && v.empty?
  true
end

def js_number?(v)
  v.is_a?(Integer) || v.is_a?(Float)
end

# Number.isInteger: finite and equal to its rounding - integer-valued
# doubles count (Number.isInteger(2.0) and Number.isInteger(1e20) are
# both true in JS).
def js_integer?(v)
  return true if v.is_a?(Integer)
  v.is_a?(Float) && v.finite? && v == v.round
end

# ECMA-262 Number::toString: extract the shortest round-trip digits from
# Ruby's Float#to_s, then apply the JS formatting rules (decimal form for
# -6 < n <= 21, exponential elsewhere, exponent always signed).
def shortest_digits(a) # a = |f| > 0
  str = a.to_s
  if str =~ /\A(\d+)\.?(\d*)[eE]([+-]?\d+)\z/
    intd = Regexp.last_match(1); fracd = Regexp.last_match(2)
    e = Regexp.last_match(3).to_i
    raw = intd + fracd
    digits = raw.sub(/\A0+/, "")
    n = e + intd.length - (raw.length - digits.length)
  else
    str =~ /\A(\d*)\.?(\d*)\z/
    intd = Regexp.last_match(1); fracd = Regexp.last_match(2)
    raw = intd + fracd
    digits = raw.sub(/\A0+/, "")
    n = intd.length - (raw.length - digits.length)
  end
  digits = digits.sub(/0+\z/, "")
  [digits, n]
end

def js_num_to_s(v)
  return "NaN" if v.is_a?(Float) && v.nan?
  return "Infinity" if v == Float::INFINITY
  return "-Infinity" if v == -Float::INFINITY
  if v.is_a?(Integer)
    # Integers within the double-precision range print verbatim; JS would
    # have rounded anything larger at parse time (see js_normalize).
    return v.to_s
  end
  f = v.to_f
  return "0" if f.zero?
  if f.finite? && f == f.round && f.abs < 1e21
    return f.to_i.to_s
  end
  neg = f.negative?
  digits, n = shortest_digits(f.abs)
  k = digits.length
  s =
    if k <= n && n <= 21
      digits + "0" * (n - k)
    elsif n > 0 && n <= 21
      digits[0...n] + "." + digits[n..]
    elsif n > -6 && n <= 0
      "0." + "0" * (-n) + digits
    else
      mant = k == 1 ? digits : digits[0] + "." + digits[1..]
      ex = n - 1
      mant + "e" + (ex >= 0 ? "+" : "-") + ex.abs.to_s
    end
  neg ? "-" + s : s
end

# --------------------------------------------------------------------
#  JS-number normalization: every JSON number becomes a Float (JS has
#  only doubles; literals beyond 2^53 round, like JSON.parse does).
# --------------------------------------------------------------------

def js_normalize(v)
  case v
  when Integer then v.to_f
  when Float   then v
  when Array   then v.map { |e| js_normalize(e) }
  when Hash
    out = {}
    v.each { |k, x| out[k] = js_normalize(x) }
    out
  else v
  end
end

# Ruby's json gem rejects lone \uXXXX surrogate escapes; JS JSON.parse
# accepts them (the string then fails every ASCII surface check the same
# way). Rewrite each unpaired surrogate escape to U+FFFD, scanning raw
# text so "\\\\ud800" (escaped backslash + literal text) is untouched.
def normalize_surrogate_escapes(text)
  out = +""
  i = 0
  n = text.bytesize
  in_str = false
  while i < n
    c = text.getbyte(i)
    if in_str && c == 0x5c # backslash inside a string
      if i + 5 < n && text.getbyte(i + 1) == 0x75 # \uXXXX
        hex = text.byteslice(i + 2, 4)
        if hex =~ /\A[0-9a-fA-F]{4}\z/n
          cp = hex.to_i(16)
          if cp >= 0xD800 && cp <= 0xDBFF
            # high surrogate: keep only if followed by \uDC00-\uDFFF
            nxt = nil
            if i + 11 < n && text.getbyte(i + 6) == 0x5c && text.getbyte(i + 7) == 0x75
              h2 = text.byteslice(i + 8, 4)
              nxt = h2.to_i(16) if h2 =~ /\A[0-9a-fA-F]{4}\z/n
            end
            if nxt && nxt >= 0xDC00 && nxt <= 0xDFFF
              out << text.byteslice(i, 12)
              i += 12
            else
              out << "\\ufffd"
              i += 6
            end
            next
          elsif cp >= 0xDC00 && cp <= 0xDFFF
            out << "\\ufffd" # unpaired low surrogate
            i += 6
            next
          else
            out << text.byteslice(i, 6)
            i += 6
            next
          end
        end
      end
      # any other escape: copy both bytes verbatim
      out << text.byteslice(i, i + 1 < n ? 2 : 1)
      i += 2
      next
    end
    out << c.chr
    if c == 0x22 # "
      in_str = !in_str
    end
    i += 1
  end
  out
end

# Member access that preserves the JS absent-vs-null distinction.
def jv(h, k)
  h.key?(k) ? h[k] : UNDEFINED
end

# --------------------------------------------------------------------
#  Section 3/4/5/6 - core receipt check
# --------------------------------------------------------------------

CORE_MEMBERS = ["id", "receipt_schema_version", "created_at", "tool",
                "provenance_class", "canonical_bytes", "output_hash",
                "verification_status"]

def check_receipt(receipt, opts)
  strict = opts[:strict]
  failures = []
  return ["receipt is not a JSON object"] unless is_object(receipt)

  CORE_MEMBERS.each do |field|
    failures << "missing required member: " + field unless receipt.key?(field)
  end
  return failures unless failures.empty?

  if !receipt["id"].is_a?(String) ||
     !re_full?(strict ? UUID_V4_RE : UUID_LOOSE_RE, receipt["id"])
    failures << (strict ? "id is not a UUID v4" : "id is not a lowercase UUID")
  end

  if strict
    failures << "receipt_schema_version is not 0.3" unless receipt["receipt_schema_version"] == "0.3"
  elsif !is_non_empty_string(receipt["receipt_schema_version"])
    failures << "receipt_schema_version is not a non-empty string"
  end

  if !receipt["created_at"].is_a?(String) || !is_rfc3339_format(receipt["created_at"], strict)
    failures << "created_at is not RFC 3339"
  elsif !is_calendar_datetime(receipt["created_at"], strict)
    failures << "created_at is not a valid calendar date/time"
  end

  tool = receipt["tool"]
  if !is_object(tool) ||
     !["name", "version", "scope"].all? { |k| is_non_empty_string(tool[k]) }
    failures << "tool is not an object with name/version/scope strings"
  end

  p = receipt["provenance_class"]
  if !p.is_a?(String) || !re_full?(PROV_RE, p)
    failures << "provenance_class is not a known class"
  end

  raw = decode_base64_strict(receipt["canonical_bytes"], strict)
  if raw.nil?
    failures << "canonical_bytes is not valid base64"
  elsif !decode_utf8_strict?(raw)
    failures << "canonical_bytes is not valid UTF-8"
    raw = nil
  end

  oh = receipt["output_hash"]
  if !oh.is_a?(String) || !re_full?(HASH_RE, oh)
    failures << "output_hash is not sha256: + lowercase hex digest"
  elsif !raw.nil?
    want = "sha256:" + sha256hex(raw)
    failures << "output_hash does not match sha256(canonical_bytes)" if oh != want
  end

  failures << "verification_status is not verified" unless receipt["verification_status"] == "verified"
  failures
end

# --------------------------------------------------------------------
#  Reference-producer profile (kit convention)
# --------------------------------------------------------------------

def check_reference_profile(receipt, opts)
  return ["receipt is not a JSON object"] unless is_object(receipt)
  raw = decode_base64_strict(receipt["canonical_bytes"], opts[:strict])
  return ["canonical_bytes is not valid base64"] if raw.nil?
  return ["canonical_bytes is not valid UTF-8"] unless decode_utf8_strict?(raw)
  begin
    payload = JSON.parse(raw.dup.force_encoding(Encoding::UTF_8), max_nesting: false)
  rescue StandardError
    return ["canonical payload is not JSON"]
  end
  return ["canonical payload is not a JSON object"] unless is_object(payload)
  unless payload.key?("inputs")
    return ["reference-producer payload omits the 'inputs' member"]
  end
  []
end

# --------------------------------------------------------------------
#  Disinterested-tier anchor check (kit convention for anchored/)
# --------------------------------------------------------------------

TIER_MEMBERS = ["anchored_at", "leaf_hash", "log", "proof"]

def anchor_uses_tier_shape(receipt)
  a = is_object(receipt) ? receipt["anchor"] : nil
  is_object(a) && TIER_MEMBERS.any? { |k| a.key?(k) }
end

def check_disinterested_tier(receipt, opts)
  strict = opts[:strict]
  failures = []
  rid = is_object(receipt) ? receipt["id"] : nil
  if !rid.is_a?(String) || !re_full?(UUID_V4_RE, rid)
    failures << "id is not a UUID v4 (strict profile)"
  end
  anchor = is_object(receipt) ? receipt["anchor"] : nil
  return ["anchor is not an object"] unless is_object(anchor)
  if utf16_sort(anchor.keys) != TIER_MEMBERS.sort
    failures << "anchor has members other than log/leaf_hash/anchored_at/proof"
  end
  failures << "anchor.log is not a non-empty string" unless is_non_empty_string(anchor["log"])
  leaf = anchor["leaf_hash"]
  if !leaf.is_a?(String) || !re_full?(HASH_RE, leaf)
    failures << "anchor.leaf_hash is not sha256: + lowercase hex digest"
  else
    raw = decode_base64_strict(receipt["canonical_bytes"], strict)
    if raw.nil?
      failures << "canonical_bytes is not valid base64"
    elsif !decode_utf8_strict?(raw)
      failures << "canonical_bytes is not valid UTF-8 base64"
    elsif leaf != "sha256:" + sha256hex(raw)
      failures << "anchor.leaf_hash does not match canonical_bytes"
    end
  end
  failures << "anchor.anchored_at is not a valid calendar date/time" unless is_rfc3339(anchor["anchored_at"], strict)
  failures << "anchor.proof is not an object" unless is_object(anchor["proof"])
  failures
end

# --------------------------------------------------------------------
#  Section 7.1 - canonical JSON for the -07/-08 entry digest
# --------------------------------------------------------------------

# UTF-16 code units of a Ruby string. Handles WTF-8 (surrogate halves
# stored as raw bytes ED A0-BF 80-BF) that can appear in strings built
# from lone-surrogate escapes, mirroring the UTF-16 view JS has of them.
def utf16_units(s)
  s = utf8_bytes(s) # ensure valid UTF-8 (scrubs lone-surrogate bytes)
  s.encode(Encoding::UTF_16BE).unpack("n*")
end

def utf16_cmp(a, b)
  utf16_units(a) <=> utf16_units(b)
end

def utf16_sort(keys)
  keys.sort { |a, b| utf16_cmp(a, b) }
end

# Escape a JSON string body per Section 7.1: ", \, C0 controls as
# \b\f\n\r\t/\uXXXX, and every UTF-16 code unit >= 0x7f (including
# non-BMP characters as surrogate-pair escapes) as \uXXXX.
def escape_canonical_string(str)
  out = +"\""
  utf16_units(str).each do |u|
    case u
    when 0x22 then out << "\\\""
    when 0x5c then out << "\\\\"
    when 0x08 then out << "\\b"
    when 0x09 then out << "\\t"
    when 0x0a then out << "\\n"
    when 0x0c then out << "\\f"
    when 0x0d then out << "\\r"
    else
      if u < 0x20 || u >= 0x7f
        out << format("\\u%04x", u)
      else
        out << u.chr
      end
    end
  end
  out << "\""
  out
end

# Serialize a JSON value canonically: object keys sorted by UTF-16 code
# unit order (== JS sort), no whitespace, integer-valued numbers in
# integer form.
def canonical_json(v)
  return "null" if v.nil?
  return "true" if v.equal?(true)
  return "false" if v.equal?(false)
  return js_num_to_s(v) if v.is_a?(Integer) || v.is_a?(Float)
  return escape_canonical_string(v) if v.is_a?(String)
  return "[" + v.map { |e| canonical_json(e) }.join(",") + "]" if v.is_a?(Array)
  if v.is_a?(Hash)
    keys = utf16_sort(v.keys)
    return "{" + keys.map { |k| escape_canonical_string(k) + ":" + canonical_json(v[k]) }.join(",") + "}"
  end
  raise "unserializable value in canonical JSON"
end

# The -07/-08 entry digest (Section 7.1). Raises on invalid base64
# (fail closed, per Section 7.1's strict-decode requirement).
def entry_digest_v07(entry, strict)
  raw = decode_base64_strict(entry["canonical_bytes"], strict)
  raise "canonical_bytes is not valid base64" if raw.nil?
  payload = {
    "close" => (entry.key?("close") ? entry["close"] : false),
    "id" => jv(entry, "id"),
    "job_id" => jv(entry, "job_id"),
    "output_hash" => sha256hex(raw),
    "prev_digest" => jv(entry, "prev_digest"),
    "provenance_class" => jv(entry, "provenance_class"),
    "seq" => jv(entry, "seq"),
    "tool" => jv(entry, "tool"),
  }
  sha256hex(canonical_json(payload))
end

# --------------------------------------------------------------------
#  Section 7.2 - chain rules (-07/-08 construction)
# --------------------------------------------------------------------

GENESIS = "0" * 64
CHAIN_STRING_MEMBERS = ["id", "tool", "provenance_class", "job_id", "prev_digest"]

def verify_chain_v07(timeline, opts)
  strict = opts[:strict]
  return ["timeline is not a non-empty list"] unless timeline.is_a?(Array) && !timeline.empty?
  failures = []
  job = nil
  prev = nil
  timeline.each_with_index do |entry, i|
    return ["entry #{i} is not an object"] unless is_object(entry)
    if decode_base64_strict(entry["canonical_bytes"], strict).nil?
      return ["entry #{i} canonical_bytes is not valid base64"]
    end
    CHAIN_STRING_MEMBERS.each do |m|
      return ["entry #{i} missing #{m}"] unless is_non_empty_string(entry[m])
    end
    seq = entry["seq"]
    return ["entry #{i} seq is not an integer"] unless js_integer?(seq)
    if seq != i + 1
      failures << "entry #{i} seq #{js_num_to_s(seq)} breaks contiguity (expected #{i + 1})"
    end
    if job.nil?
      job = entry["job_id"]
    elsif entry["job_id"] != job
      failures << "entry #{i} job_id mixes jobs"
    end
    pd = entry["prev_digest"]
    if i.zero?
      failures << "entry 0 prev_digest is not 64 zero characters" if pd != GENESIS
    elsif pd != prev
      failures << "entry #{i} prev_digest does not match the digest of entry #{i - 1}"
    end
    if entry.key?("close") && !(entry["close"].equal?(true) || entry["close"].equal?(false))
      failures << "entry #{i} close is not a boolean"
    elsif i < timeline.length - 1 && entry["close"].equal?(true)
      failures << "entry #{i} carries close:true before the last entry"
    end
    prev = entry_digest_v07(entry, strict)
  end
  last = timeline[-1]
  if !is_object(last) || !last["close"].equal?(true)
    failures << "last entry does not carry close: true"
  end
  failures
end

# --------------------------------------------------------------------
#  Historical -06 construction (Section 7.4): entry digest = SHA-256 of
#  the decoded canonical_bytes alone. A verifier MUST NOT report a -06
#  chain as verifying under the -07/-08 construction.
# --------------------------------------------------------------------

def verify_chain_v06(timeline, opts)
  strict = opts[:strict]
  return ["timeline is not a non-empty list"] unless timeline.is_a?(Array) && !timeline.empty?
  failures = []
  prev = nil
  timeline.each_with_index do |entry, i|
    unless is_object(entry)
      failures << "entry #{i} is not an object"
      break
    end
    raw = decode_base64_strict(entry["canonical_bytes"], strict)
    if raw.nil?
      failures << "entry #{i} canonical_bytes is not valid base64"
      break
    end
    digest = sha256hex(raw)
    pd = entry["prev_digest"]
    if i.zero?
      if !pd.is_a?(String) || pd != GENESIS
        failures << "entry 0 prev_digest is not 64 zero characters"
      end
    elsif !pd.is_a?(String) || pd != prev
      failures << "entry #{i} prev_digest does not match the digest of entry #{i - 1}"
    end
    prev = digest
  end
  last = timeline[-1]
  if is_object(last) && !last["close"].equal?(true)
    failures << "last entry does not carry close: true"
  end
  failures
end

# --------------------------------------------------------------------
#  Section 7.3 - external truncation commitment
# --------------------------------------------------------------------

def final_entry_digest_v07(timeline, opts)
  return nil unless timeline.is_a?(Array) && !timeline.empty?
  last = timeline[-1]
  return nil unless is_object(last)
  begin
    entry_digest_v07(last, opts[:strict])
  rescue StandardError
    nil
  end
end

def check_final_entry_commitment(timeline, commitment, opts)
  failures = []
  if !commitment.is_a?(String) || !re_full?(HEX64_RE, commitment)
    failures << "external commitment is not a lowercase hex SHA-256 digest"
    return failures
  end
  digest = final_entry_digest_v07(timeline, opts)
  if digest.nil?
    failures << "cannot recompute the final entry digest"
  elsif digest != commitment
    failures << "external commitment does not bind the final entry digest " \
                "(expected #{commitment}, recomputed #{digest})"
  end
  failures
end

# --------------------------------------------------------------------
#  Section 7.3 (-09) - chain commitment artifact
# --------------------------------------------------------------------

def verify_chain_commitment(timeline, commitment, opts)
  strict = opts[:strict]
  return ["timeline is not a non-empty list"] unless timeline.is_a?(Array) && !timeline.empty?
  return ["commitment is not a JSON object"] unless is_object(commitment)
  failures = []
  job_id = commitment["job_id"]
  failures << "commitment job_id is not a non-empty string" unless is_non_empty_string(job_id)
  digest = commitment["final_entry_digest"]
  if !digest.is_a?(String) || !re_full?(HEX64_RE, digest)
    failures << "commitment final_entry_digest is not 64 lowercase hex characters"
  end
  count = commitment["entry_count"]
  # __float__ marker: entry_count written as 5.0 in JSON (the -10 kit
  # runner marks \d+\.\d+ literals {"__float__": N} before parsing).
  # Ruby's JSON.parse preserves the distinction natively (5 -> Integer,
  # 5.0 -> Float), so an Integer check is the reference behavior: any
  # float — integral value or not — is rejected (x-entry-count-float).
  if (count.is_a?(Hash) && count.key?("__float__")) || !count.is_a?(Integer)
    failures << "commitment entry_count is not an integer"
  end
  published = commitment["published_at"]
  if !is_rfc3339_format(published, strict)
    failures << "commitment published_at is not RFC 3339"
  elsif !is_calendar_datetime(published, strict)
    failures << "commitment published_at is not a valid calendar date/time"
  end
  return failures unless failures.empty?
  if count != timeline.length
    failures << "commitment entry_count #{js_num_to_s(count)} does not match the timeline length #{timeline.length}"
  end
  timeline.each_with_index do |entry, i|
    if !is_object(entry) || !is_non_empty_string(entry["job_id"])
      failures << "entry #{i} has no usable job_id"
    elsif entry["job_id"] != job_id
      failures << "entry #{i} job_id does not match the commitment job_id"
    end
  end
  # Recompute the last entry's digest, fail closed the way
  # verify_chain_v07 does.
  last = timeline[-1]
  want = nil
  begin
    cb = is_object(last) ? last["canonical_bytes"] : nil
    raise "bad base64" if decode_base64_strict(cb, strict).nil?
    want = entry_digest_v07(last, strict)
  rescue StandardError => e
    failures << "final entry digest could not be recomputed: " + e.message
  end
  if !want.nil? && digest != want
    failures << "commitment final_entry_digest does not match the recomputed digest of the last entry"
  end
  failures
end

# --------------------------------------------------------------------
#  Section 8.1 - Merkle root over ordered step receipt_id values
# --------------------------------------------------------------------

def merkle_root(receipt_ids)
  return sha256hex("") if receipt_ids.empty?
  level = receipt_ids.map { |rid| sha256bin(utf8_bytes(rid)) }
  while level.length > 1
    level << level[-1] if level.length.odd?
    nxt = []
    (0...level.length).step(2) do |i|
      nxt << sha256bin(level[i] + level[i + 1])
    end
    level = nxt
  end
  level[0].unpack1("H*")
end

# --------------------------------------------------------------------
#  Section 8.2 (offline part) - workflow structural checks
# --------------------------------------------------------------------

def verify_workflow_steps(w)
  return ["workflow is not a JSON object"] unless is_object(w)
  steps = w["steps"]
  return ["workflow steps is not a non-empty list"] unless steps.is_a?(Array) && !steps.empty?
  failures = []
  steps.each_with_index do |step, i|
    unless is_object(step)
      failures << "step #{i + 1} is not an object"
      next
    end
    unless js_integer?(step["seq"])
      failures << "step #{i + 1} seq is not an integer"
    end
    unless step["receipt_id"].is_a?(String)
      failures << "step #{i + 1} receipt_id is not a string"
    end
  end
  return failures unless failures.empty?
  steps.each_with_index do |step, i|
    if step["seq"] != i + 1
      failures << "step seq values are not 1..#{steps.length} in order " \
                  "(index #{i} carries seq #{js_num_to_s(step['seq'])})"
      break
    end
  end
  seen = {}
  steps.each do |step|
    rid = step["receipt_id"]
    if seen[rid]
      failures << "duplicate receipt_id: #{rid}"
      break
    end
    seen[rid] = true
  end
  root = merkle_root(steps.map { |s| s["receipt_id"] })
  if !w["merkle_root"].is_a?(String) || w["merkle_root"] != root
    failures << "merkle_root does not match the recomputed Section 8.1 root"
  end
  failures
end

# --------------------------------------------------------------------
#  Section 8 (full member check) - workflow receipt files
# --------------------------------------------------------------------

def check_workflow(w, opts)
  strict = opts[:strict]
  failures = []
  return ["workflow is not a JSON object"] unless is_object(w)
  ["type", "version", "workflow_id", "receipt_id", "session_id", "goal",
   "status", "steps", "merkle_root", "output_hash", "verify_url"].each do |field|
    failures << "missing required member: " + field unless w.key?(field)
  end
  return failures unless failures.empty?
  if w["type"] != "verifiable-workflow-receipt"
    failures << 'type is not "verifiable-workflow-receipt"'
  end
  failures << "version is not a non-empty string" unless is_non_empty_string(w["version"])
  uuid_re = strict ? UUID_V4_RE : UUID_LOOSE_RE
  if !w["workflow_id"].is_a?(String) || !re_full?(uuid_re, w["workflow_id"])
    failures << "workflow_id is not a UUID"
  end
  if !w["receipt_id"].is_a?(String) || !re_full?(uuid_re, w["receipt_id"])
    failures << "receipt_id is not a UUID"
  end
  ["session_id", "goal", "status"].each do |k|
    failures << "#{k} is not a non-empty string" unless is_non_empty_string(w[k])
  end
  if !w["merkle_root"].is_a?(String) || !re_full?(HEX64_RE, w["merkle_root"])
    failures << "merkle_root is not a lowercase hex SHA-256 digest"
  end
  if !w["output_hash"].is_a?(String) || !re_full?(HEX64_RE, w["output_hash"])
    failures << "output_hash is not a lowercase hex SHA-256 digest"
  end
  vu = w["verify_url"]
  if !is_non_empty_string(vu) || vu !~ /\Ahttps?:\/\//
    failures << "verify_url is not an http(s) URL"
  end
  if w["steps"].is_a?(Array)
    w["steps"].each_with_index do |s, i|
      unless is_object(s)
        failures << "step #{i + 1} is not an object"
        next
      end
      ["seq", "tool", "receipt_id", "receipt_hash", "started_at",
       "ended_at", "status"].each do |field|
        failures << "step #{i + 1} missing member: #{field}" unless s.key?(field)
      end
      failures << "step #{i + 1} seq is not an integer" unless js_integer?(s["seq"])
      failures << "step #{i + 1} tool is not a non-empty string" unless is_non_empty_string(s["tool"])
      if !s["receipt_id"].is_a?(String) || !re_full?(uuid_re, s["receipt_id"])
        failures << "step #{i + 1} receipt_id is not a UUID"
      end
      if !s["receipt_hash"].is_a?(String) || !re_full?(HEX64_RE, s["receipt_hash"])
        failures << "step #{i + 1} receipt_hash is not hex SHA-256"
      end
      failures << "step #{i + 1} started_at is not RFC 3339" unless is_rfc3339(s["started_at"], strict)
      failures << "step #{i + 1} ended_at is not RFC 3339" unless is_rfc3339(s["ended_at"], strict)
      failures << "step #{i + 1} status is not a non-empty string" unless is_non_empty_string(s["status"])
    end
  end
  failures.concat(verify_workflow_steps(w))
  failures
end

# --------------------------------------------------------------------
#  Top-level dispatch
# --------------------------------------------------------------------

# Verify one parsed document. kind: "auto" | "receipt" | "chain" |
# "chain06" | "workflow". Returns {kind:, failures: [{check:, reason:}]}.
def verify_document(doc, opts)
  results = []
  push = lambda do |check, reasons|
    reasons.each { |r| results << { check: check, reason: r } }
  end

  kind = opts[:kind] || "auto"
  if kind == "auto"
    if doc.is_a?(Array)
      kind = "chain"
    elsif is_object(doc) && (doc["timeline"].is_a?(Array) || doc["entries"].is_a?(Array))
      kind = "chain"
    elsif is_object(doc) && doc["type"] == "verifiable-workflow-receipt"
      kind = "workflow"
    else
      kind = "receipt"
    end
  end

  if kind == "chain" || kind == "chain06"
    tl = if doc.is_a?(Array)
           doc
         elsif is_object(doc)
           js_truthy?(doc["timeline"]) ? doc["timeline"] : doc["entries"]
         end
    push.call(kind == "chain06" ? "chain(-06)" : "chain(-07/-08)",
              kind == "chain06" ? verify_chain_v06(tl, opts)
                                : verify_chain_v07(tl, opts))
    if is_object(doc) && doc["final_entry_digest"].is_a?(String)
      push.call("final-entry-commitment",
                check_final_entry_commitment(tl, doc["final_entry_digest"], opts))
    end
    if is_object(doc) && doc.key?("commitment")
      push.call("chain-commitment(-09)",
                verify_chain_commitment(tl, doc["commitment"], opts))
    end
    return { kind: kind, failures: results }
  end
  if kind == "workflow"
    push.call("workflow", check_workflow(doc, opts))
    return { kind: kind, failures: results }
  end
  push.call("core", check_receipt(doc, opts))
  push.call("reference-profile", check_reference_profile(doc, opts)) if opts[:profile]
  if opts[:anchor] == true || anchor_uses_tier_shape(doc)
    push.call("disinterested-tier", check_disinterested_tier(doc, opts))
  end
  { kind: kind, failures: results }
end

def verify_file(file, opts)
  begin
    text = File.binread(file)
    text = text.force_encoding(Encoding::UTF_8)
    text = text.scrub unless text.valid_encoding?
    doc = JSON.parse(normalize_surrogate_escapes(text), max_nesting: false)
    doc = js_normalize(doc)
  rescue StandardError => e
    return { file: file, kind: "?",
             failures: [{ check: "parse", reason: "not valid JSON: " + e.message }] }
  end
  r = verify_document(doc, opts)
  { file: file, kind: r[:kind], failures: r[:failures] }
end

# --------------------------------------------------------------------
#  CLI
# --------------------------------------------------------------------

# Directory -> every *.json inside, recursively, sorted by path. Hidden
# files and hidden directories are skipped (mirrors the !startsWith(".")
# filter in verify.js). verify.js scanned only the top level; recursion
# is the one deliberate extension (same call the Swift port made) so the
# corpus layout test-vectors/{valid,invalid,invalid-profile,anchored}
# runs as a single target.
def collect_json_files(target)
  return [target] unless File.directory?(target)
  out = []
  Find.find(target) do |p|
    if File.directory?(p)
      Find.prune if p != target && File.basename(p).start_with?(".")
      next
    end
    next unless File.file?(p) && p.end_with?(".json")
    next if File.basename(p).start_with?(".")
    out << p
  end
  out.sort
end

def main(argv)
  opts = { kind: "auto", strict: false, profile: false, anchor: false, json: false }
  targets = []
  argv.each do |a|
    case a
    when "--strict"   then opts[:strict] = true
    when "--chain06"  then opts[:kind] = "chain06"
    when "--chain"    then opts[:kind] = "chain"
    when "--workflow" then opts[:kind] = "workflow"
    when "--receipt"  then opts[:kind] = "receipt"
    when "--profile"  then opts[:profile] = true
    when "--anchor"   then opts[:anchor] = true
    when "--json"     then opts[:json] = true
    else targets << a
    end
  end
  if targets.empty?
    warn "usage: ruby aer1verify.rb <file-or-dir> [--strict] " \
         "[--chain|--chain06|--workflow|--receipt] [--profile] [--anchor] [--json]"
    return 2
  end
  all = targets.flat_map { |t| collect_json_files(t) }
  bad = 0
  report = []
  all.each do |f|
    r = verify_file(f, opts)
    ok = r[:failures].empty?
    bad += 1 unless ok
    report << { file: f, kind: r[:kind], ok: ok, failures: r[:failures] }
    next if opts[:json]
    label = "[" + r[:kind] + "]"
    if ok
      puts "PASS #{label} #{f}"
    else
      extra = r[:failures].length > 1 ? " (+#{r[:failures].length - 1} more)" : ""
      puts "FAIL #{label} #{f} \u2014 #{r[:failures][0][:reason]}#{extra}"
    end
  end
  if opts[:json]
    puts JSON.pretty_generate(report)
  else
    puts "\n#{all.length - bad}/#{all.length} verified"
    puts "CONFORMANCE: #{all.length - bad}/#{all.length}"
  end
  bad.zero? ? 0 : 1
end

if __FILE__ == $PROGRAM_NAME
  exit(main(ARGV))
end
