#!/usr/bin/env ruby
# frozen_string_literal: true
#
# aer1.rb -- independent AER-1 conformance verifier (core tier + strict id
# profile + reference-producer profile + disinterested/anchored tier),
# plus the Section 7 chain checks (v06 regression + v07 hardened), the
# Section 7.3 external chain commitment check, and the Section 8.1/8.2
# Merkle + workflow mechanics.
#
# Ported check-for-check from the reference runner aer-1/conformance.py
# (draft-zambo-aer1-10). Standard library only: openssl, json, base64.
#
# CLI:   ruby aer1.rb <receipt.json>
#   exit 0 and silence        -> receipt conforms to the Section 3 core check
#   exit 1 + reason on stderr -> receipt is non-conformant
#
# The same file is `require_relative`d by run_conformance.rb, which gates
# every corpus vector against the functions below.

require "openssl"
require "json"
require "base64"

module AER1
  module_function

  CORE = %w[id receipt_schema_version created_at tool
            provenance_class canonical_bytes output_hash
            verification_status].freeze

  UUID_RE        = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/
  STRICT_UUID_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/
  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/
  HASH_RE        = /\Asha256:[0-9a-f]{64}\z/
  PROV_RE        = /\A(?:EXECUTED BY [A-Z0-9][A-Z0-9 ._-]*|OBSERVED VIA GATEWAY|LOGGED BY AGENT)\z/
  B64_RE         = %r{\A[A-Za-z0-9+/]*={0,2}\z}
  COMMIT_DIGEST_RE = /\A[0-9a-f]{64}\z/
  HEX64_RE         = /\A[0-9a-f]{64}\z/

  # ------------------------------------------------------------------
  # base64 -- byte-exact port of CPython base64.b64decode(s, validate=True)
  #
  # CPython's strict-mode decoder tolerates EXCESS trailing '=' once the
  # data quads are complete (e.g. "AAAA=" -> 3 bytes), which Ruby's
  # Base64.strict_decode64 rejects. Rules derived from the reference:
  #   - all chars in [A-Za-z0-9+/=], ASCII only
  #   - no leading padding, no discontinuous padding (all '=' trail)
  #   - data_len % 4 == 1 -> error
  #   - data_len % 4 == 2 -> exactly 2 pads; % 4 == 3 -> exactly 1 pad
  #   - data_len % 4 == 0 -> any number of trailing pads allowed
  # Returns the decoded binary String, or nil on failure.
  def b64decode(s)
    return nil unless s.is_a?(String) && s.ascii_only?
    return nil unless s =~ %r{\A[A-Za-z0-9+/=]*\z}
    return nil if s.start_with?("=")
    data = s.sub(/=+\z/, "")
    return nil if data.include?("=")
    pad = s.length - data.length
    case data.length % 4
    when 1 then return nil
    when 2 then return nil unless pad == 2
    when 3 then return nil unless pad == 1
    end
    padded = data.ljust((data.length + 3) / 4 * 4, "=")
    Base64.strict_decode64(padded)
  rescue ArgumentError, TypeError
    nil
  end

  # ------------------------------------------------------------------
  # Strict RFC 3339 timestamp check (port of _strict_rfc3339). Shape alone
  # is not enough: offsets must be in range and the date must exist on the
  # proleptic Gregorian calendar. Fail closed.
  def strict_rfc3339(s)
    return false unless s.is_a?(String) && s =~ RFC3339_RE
    unless s.end_with?("Z")
      off = s[-6, 6]               # "[+-]HH:MM"
      oh = off[1, 2].to_i
      om = off[4, 2].to_i
      return false unless oh.between?(0, 23) && om.between?(0, 59)
    end
    year   = s[0, 4].to_i
    month  = s[5, 2].to_i
    day    = s[8, 2].to_i
    hour   = s[11, 2].to_i
    minute = s[14, 2].to_i
    second = s[17, 2].to_i
    return false unless month.between?(1, 12)
    return false unless hour.between?(0, 23) &&
                        minute.between?(0, 59) &&
                        second.between?(0, 60) # 60: leap second
    max_day =
      case month
      when 1, 3, 5, 7, 8, 10, 12 then 31
      when 4, 6, 9, 11 then 30
      else
        leap = (year % 4).zero? && (!(year % 100).zero? || (year % 400).zero?)
        leap ? 29 : 28
      end
    day.between?(1, max_day)
  end

  # ------------------------------------------------------------------
  # Section 3 core check. Returns an array of failure reasons; [] means
  # the receipt conforms.
  def check(receipt)
    return ["receipt is not a JSON object"] unless receipt.is_a?(Hash)
    failures = []
    CORE.each do |field|
      failures << "missing required member: #{field}" unless receipt.key?(field)
    end
    return failures unless failures.empty?

    id = receipt["id"]
    unless id.is_a?(String) && id =~ UUID_RE
      failures << "id is not a lowercase UUID"
    end

    rsv = receipt["receipt_schema_version"]
    unless rsv.is_a?(String) && !rsv.empty?
      failures << "receipt_schema_version is not a non-empty string"
    end

    ca = receipt["created_at"]
    if !ca.is_a?(String)
      failures << "created_at is not a string"
    elsif ca !~ RFC3339_RE
      failures << "created_at is not RFC 3339"
    elsif !strict_rfc3339(ca)
      failures << "created_at is not a valid calendar date/time"
    end

    tool = receipt["tool"]
    unless tool.is_a?(Hash) &&
           %w[name version scope].all? { |k| tool[k].is_a?(String) && !tool[k].empty? }
      failures << "tool is not an object with name/version/scope strings"
    end

    pc = receipt["provenance_class"]
    unless pc.is_a?(String) && pc =~ PROV_RE
      failures << "provenance_class is not a known class"
    end

    cb = receipt["canonical_bytes"]
    raw = nil
    if !cb.is_a?(String) || cb !~ B64_RE
      failures << "canonical_bytes is not valid base64"
    else
      raw = b64decode(cb)
      if raw.nil?
        failures << "canonical_bytes is not valid base64"
      else
        utf8 = raw.dup.force_encoding(Encoding::UTF_8)
        unless utf8.valid_encoding?
          failures << "canonical_bytes is not valid UTF-8"
          raw = nil # mirrors reference: hash comparison is skipped
        end
      end
    end

    oh = receipt["output_hash"]
    if !oh.is_a?(String) || oh !~ HASH_RE
      failures << "output_hash is not sha256: + lowercase hex digest"
    elsif raw
      want = "sha256:" + OpenSSL::Digest::SHA256.hexdigest(raw)
      if oh != want
        failures << "output_hash does not match sha256(canonical_bytes)"
      end
    end

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

    # Version agreement + provenance binding (reference runner checks
    # 9a/9c, draft -13). Legacy envelopes (outer 0.2/0.3 with no inner
    # version commitment) pass; otherwise the inner payload MUST commit a
    # receipt_schema_version equal to the outer one, and a committed
    # issuer_claims.provenance_class MUST equal the outer value.
    if raw
      text = raw.dup.force_encoding(Encoding::UTF_8)
      inner = begin
        JSON.parse(text)
      rescue JSON::ParserError, EncodingError
        nil
      end
      inner_obj = inner.is_a?(Hash)
      inner_has_v = inner_obj && inner.key?("receipt_schema_version")
      inner_version = inner_has_v ? inner["receipt_schema_version"] : nil
      outer_version = receipt["receipt_schema_version"]
      legacy_probe = inner_has_v ? inner_version : outer_version
      is_legacy = legacy_probe == "0.2" || legacy_probe == "0.3"
      unless is_legacy
        if inner_has_v && receipt.key?("receipt_schema_version")
          if inner_version.to_s != outer_version.to_s
            failures << "receipt_schema_version: outer #{outer_version.inspect} " \
                        "does not match committed #{inner_version.inspect}"
          end
        else
          failures << "receipt_schema_version: one side missing " \
                      "(outer=#{outer_version.inspect} committed=#{inner_version.inspect})"
        end
        if inner_obj
          icl = inner["issuer_claims"]
          has_ic = icl.is_a?(Hash) && icl.key?("provenance_class")
          ic = has_ic ? icl["provenance_class"] : nil
          opc = receipt["provenance_class"]
          if has_ic && receipt.key?("provenance_class")
            if ic != opc
              failures << "provenance binding: outer #{opc.inspect} " \
                          "does not match committed #{ic.inspect}"
            end
          else
            failures << "provenance binding: one side missing " \
                        "(outer=#{opc.inspect} committed=#{ic.inspect})"
          end
        else
          failures << "provenance binding: canonical payload did not decode to an object"
        end
      end
    end

    # Section 3.1 metadata_hash (draft -14): REQUIRED when
    # receipt_schema_version is "0.4" or later; whenever present a
    # verifier MUST recompute the canonical metadata block and compare.
    if receipt.key?("metadata_hash")
      mh = receipt["metadata_hash"]
      if !mh.is_a?(String) || mh !~ HASH_RE
        failures << "metadata_hash is not sha256: + lowercase hex digest"
      else
        meta_tool = tool.is_a?(Hash) ?
          { "name" => tool["name"], "scope" => tool["scope"], "version" => tool["version"] } :
          tool
        meta = {
          "created_at" => receipt["created_at"],
          "id" => receipt["id"],
          "output_hash" => receipt["output_hash"],
          "provenance_class" => receipt["provenance_class"],
          "receipt_schema_version" => receipt["receipt_schema_version"],
          "tool" => meta_tool,
        }
        want = "sha256:" + OpenSSL::Digest::SHA256.hexdigest(py_canonical_json(meta))
        if mh != want
          failures << "metadata_hash does not match sha256(canonical metadata block)"
        end
      end
    elsif rsv.is_a?(String) && version_geq(rsv, "0.4")
      failures << "metadata_hash is required at receipt_schema_version 0.4 or later"
    end
    failures
  end

  # Dotted-version compare: true when a >= b numerically per segment.
  def version_geq(a, b)
    pa = a.to_s.split(".").map(&:to_i)
    pb = b.to_s.split(".").map(&:to_i)
    [pa.length, pb.length].max.times do |i|
      d = (pa[i] || 0) - (pb[i] || 0)
      return d > 0 if d != 0
    end
    true
  end

  # ------------------------------------------------------------------
  # Reference-producer profile check (PRODUCER CONVENTION, not interop):
  # the canonical payload must be a JSON object carrying 'inputs'.
  def check_reference_profile(receipt)
    return ["receipt is not a JSON object"] unless receipt.is_a?(Hash)
    cb = receipt["canonical_bytes"]
    return ["canonical_bytes is not a string"] unless cb.is_a?(String)
    raw = b64decode(cb)
    return ["canonical_bytes is not valid base64"] if raw.nil?
    text = raw.dup.force_encoding(Encoding::UTF_8)
    return ["canonical_bytes is not valid UTF-8"] unless text.valid_encoding?
    begin
      payload = JSON.parse(text)
    rescue JSON::ParserError, EncodingError
      return ["canonical payload is not JSON"]
    end
    return ["canonical payload is not a JSON object"] unless payload.is_a?(Hash)
    unless payload.key?("inputs")
      return ["reference-producer payload omits the 'inputs' member"]
    end
    []
  end

  # ------------------------------------------------------------------
  # Disinterested (transparency-log anchored) tier: strict UUIDv4 id +
  # anchor member checks.
  def check_disinterested_tier(receipt)
    failures = []
    rid = receipt.is_a?(Hash) ? receipt["id"] : nil
    unless rid.is_a?(String) && rid =~ STRICT_UUID_RE
      failures << "id is not a UUID v4 (strict profile)"
    end
    anchor = receipt.is_a?(Hash) ? receipt["anchor"] : nil
    return ["anchor is not an object"] unless anchor.is_a?(Hash)
    if anchor.keys.sort != %w[anchored_at leaf_hash log proof]
      failures << "anchor has members other than log/leaf_hash/anchored_at/proof"
    end
    log = anchor["log"]
    unless log.is_a?(String) && !log.empty?
      failures << "anchor.log is not a non-empty string"
    end
    leaf = anchor["leaf_hash"]
    if !leaf.is_a?(String) || leaf !~ HASH_RE
      failures << "anchor.leaf_hash is not sha256: + lowercase hex digest"
    else
      cb = receipt["canonical_bytes"]
      raw = nil
      if !cb.is_a?(String) || cb !~ B64_RE
        failures << "canonical_bytes is not valid base64"
      else
        raw = b64decode(cb)
        if raw.nil?
          failures << "canonical_bytes is not valid UTF-8 base64"
        else
          utf8 = raw.dup.force_encoding(Encoding::UTF_8)
          # NOTE: mirrors the reference exactly -- on a UTF-8 failure the
          # decoded bytes are RETAINED, so the leaf_hash comparison below
          # still runs against sha256 of the raw bytes.
          failures << "canonical_bytes is not valid UTF-8 base64" unless utf8.valid_encoding?
        end
      end
      if raw
        want = "sha256:" + OpenSSL::Digest::SHA256.hexdigest(raw)
        if leaf != want
          failures << "anchor.leaf_hash does not match canonical_bytes"
        end
      end
    end
    anchored_at = anchor["anchored_at"]
    if !anchored_at.is_a?(String) || anchored_at !~ RFC3339_RE
      failures << "anchor.anchored_at is not RFC 3339"
    elsif !strict_rfc3339(anchored_at)
      failures << "anchor.anchored_at is not a valid calendar date/time"
    end
    unless anchor["proof"].is_a?(Hash)
      failures << "anchor.proof is not an object"
    end
    failures
  end

  # ------------------------------------------------------------------
  # Section 8.1 canonical Merkle root: leaf = SHA-256 over the UTF-8 bytes
  # of the receipt_id; internal node = SHA-256 over raw left || raw right;
  # odd trailing node duplicated; empty tree -> SHA-256 of empty input.
  def merkle_root_canonical(receipt_ids)
    return OpenSSL::Digest::SHA256.hexdigest("") if receipt_ids.empty?
    level = receipt_ids.map { |rid| OpenSSL::Digest::SHA256.digest(rid.to_s) }
    while level.length > 1
      level << level[-1] if level.length.odd?
      level = (0...level.length).step(2).map do |i|
        OpenSSL::Digest::SHA256.digest(level[i] + level[i + 1])
      end
    end
    level[0].unpack1("H*")
  end

  # ------------------------------------------------------------------
  # Python-compatible integer check: int, or float with integral value;
  # booleans excluded (Ruby booleans are not Integers, matching the
  # reference's explicit isinstance(seq, bool) exclusion).
  def py_int?(v)
    v.is_a?(Integer) || (v.is_a?(Float) && v.finite? && v == v.truncate)
  end

  # Section 8.2 steps 3-4: offline workflow verification.
  def verify_workflow(workflow)
    return ["workflow is not a JSON object"] unless workflow.is_a?(Hash)
    failures = []
    if workflow["type"] != "verifiable-workflow-receipt"
      failures << 'workflow type is not "verifiable-workflow-receipt"'
    end
    version = workflow["version"]
    unless version.is_a?(String) && !version.empty?
      failures << "workflow version is not a non-empty string"
    end
    wid = workflow["workflow_id"]
    unless wid.is_a?(String) && wid =~ UUID_RE
      failures << "workflow workflow_id is not a lowercase UUID"
    end
    wrid = workflow["receipt_id"]
    unless wrid.is_a?(String) && wrid =~ UUID_RE
      failures << "workflow receipt_id is not a lowercase UUID"
    end
    unless workflow["session_id"].is_a?(String) && !workflow["session_id"].empty?
      failures << "workflow session_id is not a non-empty string"
    end
    unless workflow["goal"].is_a?(String) && !workflow["goal"].empty?
      failures << "workflow goal is not a non-empty string"
    end
    unless workflow["status"].is_a?(String) && !workflow["status"].empty?
      failures << "workflow status is not a non-empty string"
    end
    oh = workflow["output_hash"]
    unless oh.is_a?(String) && oh =~ HEX64_RE
      failures << "workflow output_hash is not a 64-char lowercase hex SHA-256 digest"
    end
    vu = workflow["verify_url"]
    unless vu.is_a?(String) && (vu.start_with?("http://") || vu.start_with?("https://"))
      failures << "workflow verify_url is not an http(s) URL string"
    end
    steps = workflow["steps"]
    unless steps.is_a?(Array) && !steps.empty?
      failures << "workflow steps is not a non-empty list"
      return failures
    end
    steps.each_with_index do |step, i|
      unless step.is_a?(Hash)
        failures << "step #{i + 1} is not an object"
        next
      end
      failures << "step #{i + 1} seq is not an integer" unless py_int?(step["seq"])
      failures << "step #{i + 1} receipt_id is not a string" unless step["receipt_id"].is_a?(String)
      unless step["tool"].is_a?(String) && !step["tool"].empty?
        failures << "step #{i + 1} tool is not a non-empty string"
      end
      rh = step["receipt_hash"]
      unless rh.is_a?(String) && rh =~ HEX64_RE
        failures << "step #{i + 1} receipt_hash is not a 64-char lowercase hex SHA-256 digest"
      end
      sa = step["started_at"]
      unless sa.is_a?(String) && strict_rfc3339(sa)
        failures << "step #{i + 1} started_at is not a valid RFC 3339 timestamp"
      end
      ea = step["ended_at"]
      unless ea.is_a?(String) && strict_rfc3339(ea)
        failures << "step #{i + 1} ended_at is not a valid RFC 3339 timestamp"
      end
      unless step["status"].is_a?(String) && !step["status"].empty?
        failures << "step #{i + 1} status is not a non-empty string"
      end
    end
    return failures unless failures.empty?
    n = steps.length
    steps.each_with_index do |step, i|
      next if step["seq"] == i + 1
      failures << "step seq values are not 1..#{n} in order (index #{i} carries seq #{step['seq']})"
      break
    end
    seen = {}
    steps.each do |step|
      rid = step["receipt_id"]
      if seen.key?(rid)
        failures << "duplicate receipt_id: #{rid}"
        break
      end
      seen[rid] = true
    end
    root = merkle_root_canonical(steps.map { |s| s["receipt_id"] })
    mr = workflow["merkle_root"]
    unless mr.is_a?(String) && mr == root
      failures << "merkle_root does not match the recomputed Section 8.1 root"
    end
    failures
  end

  # ------------------------------------------------------------------
  # Section 7 (v06) chain mechanics.
  def verify_chain(timeline)
    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 entry.is_a?(Hash)
        failures << "entry #{i} is not an object"
        break
      end
      cb = entry["canonical_bytes"]
      if !cb.is_a?(String) || cb !~ B64_RE
        failures << "entry #{i} canonical_bytes is not valid base64"
        break
      end
      raw = b64decode(cb)
      if raw.nil?
        failures << "entry #{i} canonical_bytes is not valid base64"
        break
      end
      digest = OpenSSL::Digest::SHA256.hexdigest(raw)
      pd = entry["prev_digest"]
      if i.zero?
        unless pd.is_a?(String) && pd == "0" * 64
          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 last.is_a?(Hash) && last["close"] != true
      failures << "last entry does not carry close: true"
    end
    failures
  end

  # ------------------------------------------------------------------
  # Python json.dumps(str, ensure_ascii=True) escaping: short escapes for
  # \b \t \n \f \r \" \\, \uXXXX (lowercase hex) for C0 controls without a
  # short escape AND every codepoint >= 0x7F (U+007F included); non-BMP
  # codepoints become \uXXXX\uXXXX surrogate pairs.
  PY_SHORT_ESCAPES = {
    0x08 => "\\b", 0x09 => "\\t", 0x0a => "\\n", 0x0c => "\\f",
    0x0d => "\\r", 0x22 => '\\"', 0x5c => "\\\\"
  }.freeze

  def py_escape_str(str)
    out = +"\""
    str.each_codepoint do |cp|
      esc = PY_SHORT_ESCAPES[cp]
      if esc
        out << esc
      elsif cp < 0x20 || cp >= 0x7f
        if cp <= 0xffff
          out << format("\\u%04x", cp)
        else
          v = cp - 0x10000
          out << format("\\u%04x\\u%04x", 0xd800 + (v >> 10), 0xdc00 + (v & 0x3ff))
        end
      else
        out << cp.chr(Encoding::UTF_8)
      end
    end
    out << "\""
    out
  end

  # Python repr(float) approximation. The corpus never exercises non-integral
  # floats in a digested payload (seq is normalized to Integer first); kept
  # for completeness/fail-closed robustness.
  def py_float_repr(f)
    return "NaN" if f.nan?
    return "Infinity" if f.infinite? == 1
    return "-Infinity" if f.infinite? == -1
    return "#{f.truncate}.0" if f == f.truncate
    f.to_s
  end

  # Python json.dumps(obj, sort_keys=True, separators=(",", ":"),
  # ensure_ascii=True).
  def py_canonical_json(obj)
    case obj
    when Hash
      inner = obj.keys.sort.map do |k|
        "#{py_escape_str(k.to_s)}:#{py_canonical_json(obj[k])}"
      end
      "{#{inner.join(",")}}"
    when Array
      "[#{obj.map { |e| py_canonical_json(e) }.join(",")}]"
    when String
      py_escape_str(obj)
    when Integer
      obj.to_s
    when Float
      py_float_repr(obj)
    when true, false
      obj.to_s
    when nil
      "null"
    else
      raise TypeError, "object of type #{obj.class} is not JSON serializable"
    end
  end

  # ------------------------------------------------------------------
  # Section 7.1 (-07) entry digest: SHA-256 hex over the canonical JSON of
  # {prev_digest, seq, job_id, close, id, tool, provenance_class,
  # output_hash} with keys sorted, no whitespace. Missing `close` digests
  # as false. Raises on missing members / bad base64 (fail closed).
  def chain_entry_digest_v07(entry)
    cb = entry.fetch("canonical_bytes")
    raise TypeError, "canonical_bytes is not a string" unless cb.is_a?(String)
    raw = b64decode(cb)
    raise ArgumentError, "canonical_bytes is not valid base64" if raw.nil?
    output_hash = OpenSSL::Digest::SHA256.hexdigest(raw)
    seq = entry.fetch("seq")
    seq = seq.to_i if seq.is_a?(Float) && seq.finite? && seq == seq.truncate
    payload = {
      "prev_digest"      => entry.fetch("prev_digest"),
      "seq"              => seq,
      "job_id"           => entry.fetch("job_id"),
      "close"            => (entry.key?("close") ? entry["close"] : false),
      "id"               => entry.fetch("id"),
      "tool"             => entry.fetch("tool"),
      "provenance_class" => entry.fetch("provenance_class"),
      "output_hash"      => output_hash
    }
    blob = py_canonical_json(payload)
    OpenSSL::Digest::SHA256.hexdigest(blob)
  end

  # Section 7 (-07) hardened chain mechanics.
  def verify_chain_v07(timeline)
    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 entry.is_a?(Hash)
      cb = entry["canonical_bytes"]
      unless cb.is_a?(String) && cb =~ B64_RE
        return ["entry #{i} canonical_bytes is not valid base64"]
      end
      return ["entry #{i} canonical_bytes is not valid base64"] if b64decode(cb).nil?
      %w[id tool provenance_class job_id prev_digest].each do |m|
        v = entry[m]
        return ["entry #{i} missing #{m}"] unless v.is_a?(String) && !v.empty?
      end
      seq = entry["seq"]
      return ["entry #{i} seq is not an integer"] unless py_int?(seq)
      seq = seq.to_i
      if seq != i + 1
        failures << "entry #{i} seq #{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?
        unless pd == "0" * 64
          failures << "entry 0 prev_digest is not 64 zero characters"
        end
      elsif pd != prev
        failures << "entry #{i} prev_digest does not match the digest of entry #{i - 1}"
      end
      close = entry["close"]
      if entry.key?("close") && !(close == true || close == false)
        failures << "entry #{i} close is not a boolean"
      elsif i < timeline.length - 1 && close == true
        failures << "entry #{i} carries close:true before the last entry"
      end
      prev = chain_entry_digest_v07(entry)
    end
    last = timeline[-1]
    if !last.is_a?(Hash) || last["close"] != true
      failures << "last entry does not carry close: true"
    end
    failures
  end

  # ------------------------------------------------------------------
  # Section 7.3 (-09) external chain commitment check.
  def verify_chain_commitment(timeline, commitment)
    return ["timeline is not a non-empty list"] unless timeline.is_a?(Array) && !timeline.empty?
    return ["commitment is not a JSON object"] unless commitment.is_a?(Hash)
    failures = []
    job_id = commitment["job_id"]
    unless job_id.is_a?(String) && !job_id.empty?
      failures << "commitment job_id is not a non-empty string"
    end
    digest = commitment["final_entry_digest"]
    unless digest.is_a?(String) && digest =~ COMMIT_DIGEST_RE
      failures << "commitment final_entry_digest is not 64 lowercase hex characters"
    end
    count = commitment["entry_count"]
    failures << "commitment entry_count is not an integer" unless count.is_a?(Integer)
    published = commitment["published_at"]
    if !published.is_a?(String) || published !~ RFC3339_RE
      failures << "commitment published_at is not RFC 3339"
    elsif !strict_rfc3339(published)
      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 #{count} does not match the timeline length #{timeline.length}"
    end
    timeline.each_with_index do |entry, i|
      if !entry.is_a?(Hash) || !entry["job_id"].is_a?(String) || entry["job_id"].empty?
        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
    begin
      want = chain_entry_digest_v07(timeline[-1])
      if digest != want
        failures << "commitment final_entry_digest does not match the recomputed digest of the last entry"
      end
    rescue StandardError => e
      failures << "final entry digest could not be recomputed: #{e.message}"
    end
    failures
  end
end

# ---------------------------------------------------------------------
# CLI: ruby aer1.rb <receipt.json>
if __FILE__ == $PROGRAM_NAME
  if ARGV.length != 1
    warn "usage: ruby aer1.rb <receipt.json>"
    exit 2
  end
  begin
    receipt = JSON.parse(File.read(ARGV[0], encoding: "UTF-8"))
  rescue StandardError => e
    warn "aer1: cannot parse #{ARGV[0]}: #{e.message}"
    exit 1
  end
  fails = AER1.check(receipt)
  if fails.empty?
    exit 0
  else
    fails.each { |f| warn "aer1: #{f}" }
    exit 1
  end
end
