/*
 * Aer1Verify.java — Java port of ARION's independent AER-1 verifier
 * (verify.js, draft-zambo-aer1-10, kit v1.4.4 @ 5e3f078).
 *
 * Zero-dependency: java.base only (java.security SHA-256, java.util.Base64,
 * java.nio.charset, java.util.regex).
 *
 * Value model (produced by Json.parse):
 *   JSON object -> LinkedHashMap<String,Object>   (insertion order kept)
 *   JSON array  -> ArrayList<Object>
 *   JSON string -> String
 *   JSON number -> Json.Num (raw lexeme + double)
 *   JSON bool   -> Boolean
 *   JSON null   -> Json.NULL  (Java null means "member absent")
 *
 * Failure-reason strings mirror verify.js verbatim; the conformance corpus
 * pins many of them via expected_failure / rejected_by substring checks.
 */
import java.math.BigDecimal;
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CharsetDecoder;
import java.nio.charset.CharsetEncoder;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public final class Aer1Verify {

    private Aer1Verify() {}

    /* ------------------------------------------------------------------ *
     *  Options / results                                                  *
     * ------------------------------------------------------------------ */

    public static final class Opts {
        public boolean strict = false;   // certifier-strictness profile
        public boolean profile = false;  // also run reference-producer check
        public boolean anchor = false;   // force disinterested-tier check
        public String kind = "auto";     // auto|receipt|chain|chain06|workflow
        public Opts() {}
        public Opts(boolean strict) { this.strict = strict; }
    }

    public static final class Failure {
        public final String check;
        public final String reason;
        public Failure(String check, String reason) { this.check = check; this.reason = reason; }
        @Override public String toString() { return check + ": " + reason; }
    }

    public static final class Result {
        public final String kind;
        public final List<Failure> failures;
        public Result(String kind, List<Failure> failures) { this.kind = kind; this.failures = failures; }
        public boolean ok() { return failures.isEmpty(); }
        public List<String> reasons() {
            List<String> out = new ArrayList<>(failures.size());
            for (Failure f : failures) out.add(f.reason);
            return out;
        }
    }

    /* ------------------------------------------------------------------ *
     *  Primitives                                                         *
     * ------------------------------------------------------------------ */

    private static final char[] HEX = "0123456789abcdef".toCharArray();

    static String hex(byte[] b) {
        StringBuilder sb = new StringBuilder(b.length * 2);
        for (byte x : b) sb.append(HEX[(x >> 4) & 0xf]).append(HEX[x & 0xf]);
        return sb.toString();
    }

    static byte[] sha256(byte[] b) {
        try {
            return MessageDigest.getInstance("SHA-256").digest(b);
        } catch (NoSuchAlgorithmException e) {
            throw new IllegalStateException(e);
        }
    }

    static String sha256hex(byte[] b) { return hex(sha256(b)); }

    /*
     * UTF-8 encode with U+FFFD for malformed input, matching Node's
     * Buffer.from(str, "utf8") behaviour on lone UTF-16 surrogates
     * (Java's default replacement byte is '?' — Node emits EF BF BD).
     */
    static byte[] utf8(String s) {
        CharsetEncoder enc = StandardCharsets.UTF_8.newEncoder()
            .onMalformedInput(CodingErrorAction.REPLACE)
            .onUnmappableCharacter(CodingErrorAction.REPLACE)
            .replaceWith(new byte[]{(byte) 0xEF, (byte) 0xBF, (byte) 0xBD});
        try {
            ByteBuffer bb = enc.encode(CharBuffer.wrap(s));
            byte[] out = new byte[bb.remaining()];
            bb.get(out);
            return out;
        } catch (CharacterCodingException e) {
            throw new IllegalStateException(e); // unreachable with REPLACE
        }
    }

    /* Strict UTF-8 decode (Section 4): non-UTF-8 canonical bytes fail closed. */
    static boolean decodeUtf8Strict(byte[] b) {
        CharsetDecoder dec = StandardCharsets.UTF_8.newDecoder()
            .onMalformedInput(CodingErrorAction.REPORT)
            .onUnmappableCharacter(CodingErrorAction.REPORT);
        try {
            dec.decode(ByteBuffer.wrap(b));
            return true;
        } catch (CharacterCodingException e) {
            return false;
        }
    }

    /*
     * Base64 surface check (kit convention): alphabet plus at most two pad
     * chars, total length a multiple of 4; non-canonical trailing pad bits
     * tolerated (java.util.Base64 decoder, like Node's Buffer.from(s,
     * "base64"), does not verify that unused pad bits are zero).
     */
    private static final Pattern B64_RE = Pattern.compile("[A-Za-z0-9+/]*={0,2}");

    static byte[] decodeBase64Strict(Object so, boolean strict) {
        if (!(so instanceof String)) return null;
        String s = (String) so;
        if (!B64_RE.matcher(s).matches() || s.length() % 4 != 0) return null;
        try {
            return Base64.getDecoder().decode(s);
        } catch (IllegalArgumentException e) {
            return null; // unreachable after the surface check, fail closed
        }
    }

    /* ------------------------------------------------------------------ *
     *  Field validators (Section 3)                                       *
     * ------------------------------------------------------------------ */

    private static final Pattern UUID_LOOSE_RE = Pattern.compile(
        "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}");
    private static final Pattern UUID_V4_RE = Pattern.compile(
        "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}");
    private static final Pattern HASH_RE = Pattern.compile("sha256:[0-9a-f]{64}");
    private static final Pattern HEX64_RE = Pattern.compile("[0-9a-f]{64}");
    private static final Pattern PROV_RE = Pattern.compile(
        "(EXECUTED BY [A-Z0-9][A-Z0-9 ._-]*|OBSERVED VIA GATEWAY|LOGGED BY AGENT)");
    private static final Pattern PROV_STRICT_RE = PROV_RE;
    private static final Pattern RFC3339_RE = Pattern.compile(
        "[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})");
    private static final Pattern RFC3339_STRICT_RE = RFC3339_RE;
    private static final Pattern TS_PARTS_RE = Pattern.compile(
        "([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})");

    static boolean isLeapYear(int y) {
        return (y % 4 == 0 && y % 100 != 0) || y % 400 == 0;
    }
    static int daysInMonth(int y, int m) {
        if (m == 2) return isLeapYear(y) ? 29 : 28;
        if (m == 4 || m == 6 || m == 9 || m == 11) return 30;
        return 31;
    }

    /*
     * Calendar validation (Section 3: "the timestamp MUST denote a valid
     * calendar date and time"). Numeric UTC offset bounded to strictly less
     * than 24h total, plus per-component bounds (hours <= 23, min <= 59).
     * Mirrors verify.js isCalendarDateTime() including its "replace the
     * first Z with +00:00" quirk.
     */
    static boolean isCalendarDateTime(String s, boolean strict) {
        int zi = s.indexOf('Z');
        String t = (zi < 0) ? s : s.substring(0, zi) + "+00:00" + s.substring(zi + 1);
        Matcher m = TS_PARTS_RE.matcher(t);
        if (!m.matches()) return false;
        int year = Integer.parseInt(m.group(1));
        int mon  = Integer.parseInt(m.group(2));
        int day  = Integer.parseInt(m.group(3));
        int hh   = Integer.parseInt(m.group(4));
        int mi   = Integer.parseInt(m.group(5));
        int ss   = Integer.parseInt(m.group(6));
        if (year < 1 || year > 9999) return false;
        if (mon < 1 || mon > 12) return false;
        if (day < 1 || day > daysInMonth(year, mon)) return false;
        if (hh > 23 || mi > 59 || ss > 59) return false;
        String off = m.group(8);
        if (!"Z".equals(off)) {
            int oh = Integer.parseInt(off.substring(1, 3));
            int om = Integer.parseInt(off.substring(4, 6));
            if (oh > 23 || om > 59) return false;
            int total = (off.charAt(0) == '-' ? -1 : 1) * (oh * 60 + om);
            if (!(total > -1440 && total < 1440)) return false;
        }
        return true;
    }

    static boolean isRfc3339Format(Object s, boolean strict) {
        if (!(s instanceof String)) return false;
        return (strict ? RFC3339_STRICT_RE : RFC3339_RE).matcher((String) s).matches();
    }
    static boolean isRfc3339(Object s, boolean strict) {
        return isRfc3339Format(s, strict) && isCalendarDateTime((String) s, strict);
    }

    static boolean isObject(Object v) { return v instanceof Map; }
    static boolean isNonEmptyString(Object v) {
        return v instanceof String && !((String) v).isEmpty();
    }

    /** JS truthiness (for `doc ? ...` and `doc.timeline || doc.entries`). */
    static boolean truthy(Object v) {
        if (v == null || v == Json.NULL) return false;
        if (v instanceof Boolean) return (Boolean) v;
        if (v instanceof String) return !((String) v).isEmpty();
        if (v instanceof Json.Num) {
            double d = ((Json.Num) v).value;
            return d != 0 && !Double.isNaN(d);
        }
        return true;
    }

    /*
     * JS Number::toString (ECMA-262 7.1.12.1): shortest round-trip digits,
     * decimal notation for 1e-6 <= |v| < 1e21, exponential outside.
     */
    static String jsNumToString(double v) {
        if (v == 0.0) return "0"; // covers -0
        if (Double.isNaN(v)) return "NaN";
        if (Double.isInfinite(v)) return v > 0 ? "Infinity" : "-Infinity";
        BigDecimal bd = BigDecimal.valueOf(v).stripTrailingZeros();
        String digits = bd.unscaledValue().abs().toString();
        int k = digits.length();
        int n = k - bd.scale(); // v = 0.digits * 10^n
        StringBuilder sb = new StringBuilder();
        if (v < 0) sb.append('-');
        if (k <= n && n <= 21) {
            sb.append(digits);
            for (int i = 0; i < n - k; i++) sb.append('0');
        } else if (0 < n && n <= 21) {
            sb.append(digits, 0, n).append('.').append(digits.substring(n));
        } else if (-6 < n && n <= 0) {
            sb.append("0.");
            for (int i = 0; i < -n; i++) sb.append('0');
            sb.append(digits);
        } else {
            sb.append(digits.charAt(0));
            if (k > 1) sb.append('.').append(digits.substring(1));
            sb.append('e');
            int e = n - 1;
            if (e > 0) sb.append('+');
            sb.append(e);
        }
        return sb.toString();
    }

    /* ------------------------------------------------------------------ *
     *  Section 3/4/5/6 — core receipt check                               *
     * ------------------------------------------------------------------ */

    private static final String[] CORE_MEMBERS = {
        "id", "receipt_schema_version", "created_at", "tool",
        "provenance_class", "canonical_bytes", "output_hash",
        "verification_status"
    };

    public static List<String> checkReceipt(Object receipt) {
        return checkReceipt(receipt, null);
    }

    public static List<String> checkReceipt(Object receipt, Opts opts) {
        boolean strict = opts != null && opts.strict;
        List<String> failures = new ArrayList<>();
        if (!isObject(receipt)) return List.of("receipt is not a JSON object");

        for (String field : CORE_MEMBERS) {
            if (!Json.has(receipt, field)) {
                failures.add("missing required member: " + field);
            }
        }
        if (!failures.isEmpty()) return failures;

        // id: lowercase UUID shape; strict mode pins UUID v4 + RFC 4122 variant.
        Object id = Json.get(receipt, "id");
        if (!(id instanceof String) ||
            !(strict ? UUID_V4_RE : UUID_LOOSE_RE).matcher((String) id).matches()) {
            failures.add(strict ? "id is not a UUID v4" : "id is not a lowercase UUID");
        }

        Object rsv = Json.get(receipt, "receipt_schema_version");
        if (strict) {
            if (!"0.3".equals(rsv)) {
                failures.add("receipt_schema_version is not 0.3");
            }
        } else if (!isNonEmptyString(rsv)) {
            failures.add("receipt_schema_version is not a non-empty string");
        }

        Object created = Json.get(receipt, "created_at");
        if (!(created instanceof String) || !isRfc3339Format(created, strict)) {
            failures.add("created_at is not RFC 3339");
        } else if (!isCalendarDateTime((String) created, strict)) {
            failures.add("created_at is not a valid calendar date/time");
        }

        Object tool = Json.get(receipt, "tool");
        if (!isObject(tool) ||
            !(isNonEmptyString(Json.get(tool, "name")) &&
              isNonEmptyString(Json.get(tool, "version")) &&
              isNonEmptyString(Json.get(tool, "scope")))) {
            failures.add("tool is not an object with name/version/scope strings");
        }

        Object p = Json.get(receipt, "provenance_class");
        if (!(p instanceof String) ||
            !(strict ? PROV_STRICT_RE : PROV_RE).matcher((String) p).matches()) {
            failures.add("provenance_class is not a known class");
        }

        // canonical_bytes: strict base64, then strict UTF-8 (Section 4).
        byte[] raw = decodeBase64Strict(Json.get(receipt, "canonical_bytes"), strict);
        if (raw == null) {
            failures.add("canonical_bytes is not valid base64");
        } else if (!decodeUtf8Strict(raw)) {
            failures.add("canonical_bytes is not valid UTF-8");
            raw = null;
        }

        // output_hash: format, then recomputation (Section 6 step 3).
        Object oh = Json.get(receipt, "output_hash");
        if (!(oh instanceof String) || !HASH_RE.matcher((String) oh).matches()) {
            failures.add("output_hash is not sha256: + lowercase hex digest");
        } else if (raw != null) {
            String want = "sha256:" + sha256hex(raw);
            if (!want.equals(oh)) {
                failures.add("output_hash does not match sha256(canonical_bytes)");
            }
        }

        if (!"verified".equals(Json.get(receipt, "verification_status"))) {
            failures.add("verification_status is not verified");
        }
        return failures;
    }

    /* ------------------------------------------------------------------ *
     *  Reference-producer profile (kit convention)                        *
     * ------------------------------------------------------------------ */

    public static List<String> checkReferenceProfile(Object receipt) {
        return checkReferenceProfile(receipt, null);
    }

    public static List<String> checkReferenceProfile(Object receipt, Opts opts) {
        boolean strict = opts != null && opts.strict;
        if (!isObject(receipt)) return List.of("receipt is not a JSON object");
        byte[] raw = decodeBase64Strict(Json.get(receipt, "canonical_bytes"), strict);
        if (raw == null) return List.of("canonical_bytes is not valid base64");
        if (!decodeUtf8Strict(raw)) return List.of("canonical_bytes is not valid UTF-8");
        Object payload;
        try {
            payload = Json.parse(new String(raw, StandardCharsets.UTF_8));
        } catch (Json.ParseException e) {
            return List.of("canonical payload is not JSON");
        }
        if (!isObject(payload)) return List.of("canonical payload is not a JSON object");
        if (!Json.has(payload, "inputs")) {
            return List.of("reference-producer payload omits the 'inputs' member");
        }
        return List.of();
    }

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

    private static final String[] TIER_MEMBERS = {"anchored_at", "leaf_hash", "log", "proof"};

    public static boolean anchorUsesTierShape(Object receipt) {
        Object a = isObject(receipt) ? Json.get(receipt, "anchor") : null;
        if (!isObject(a)) return false;
        for (String k : TIER_MEMBERS) if (Json.has(a, k)) return true;
        return false;
    }

    public static List<String> checkDisinterestedTier(Object receipt) {
        return checkDisinterestedTier(receipt, null);
    }

    public static List<String> checkDisinterestedTier(Object receipt, Opts opts) {
        boolean strict = opts != null && opts.strict;
        List<String> failures = new ArrayList<>();
        // -09: UUID v4 + RFC 4122 variant wherever the tier is claimed.
        Object rid = isObject(receipt) ? Json.get(receipt, "id") : null;
        if (!(rid instanceof String) || !UUID_V4_RE.matcher((String) rid).matches()) {
            failures.add("id is not a UUID v4 (strict profile)");
        }
        Object anchor = isObject(receipt) ? Json.get(receipt, "anchor") : null;
        if (!isObject(anchor)) return List.of("anchor is not an object");

        List<String> keys = new ArrayList<>();
        for (Object k : ((Map<?, ?>) anchor).keySet()) keys.add((String) k);
        Collections.sort(keys); // UTF-16 code-unit order == code-point order
        if (!String.join(",", keys).equals("anchored_at,leaf_hash,log,proof")) {
            failures.add("anchor has members other than log/leaf_hash/anchored_at/proof");
        }
        if (!isNonEmptyString(Json.get(anchor, "log"))) {
            failures.add("anchor.log is not a non-empty string");
        }
        Object leaf = Json.get(anchor, "leaf_hash");
        if (!(leaf instanceof String) || !HASH_RE.matcher((String) leaf).matches()) {
            failures.add("anchor.leaf_hash is not sha256: + lowercase hex digest");
        } else {
            // note: receipt may be a non-Map JSON value here (JS reads
            // .canonical_bytes off anything; missing -> base64 failure)
            byte[] raw = decodeBase64Strict(get0(receipt, "canonical_bytes"), strict);
            if (raw == null) {
                failures.add("canonical_bytes is not valid base64");
            } else if (!decodeUtf8Strict(raw)) {
                failures.add("canonical_bytes is not valid UTF-8 base64");
            } else {
                String want = "sha256:" + sha256hex(raw);
                if (!want.equals(leaf)) {
                    failures.add("anchor.leaf_hash does not match canonical_bytes");
                }
            }
        }
        if (!isRfc3339(Json.get(anchor, "anchored_at"), strict)) {
            failures.add("anchor.anchored_at is not a valid calendar date/time");
        }
        if (!isObject(Json.get(anchor, "proof"))) {
            failures.add("anchor.proof is not an object");
        }
        return failures;
    }

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

    /*
     * Escape a JSON string body per Section 7.1: ", \, control characters
     * get the RFC 8259 short escapes (\b \f \n \r \t) or \\u00XX; every
     * remaining UTF-16 code unit >= 0x7F (including U+007F DELETE, and
     * both halves of surrogate pairs) is emitted as \\uXXXX lowercase.
     */
    static String escapeCanonicalString(String str) {
        StringBuilder out = new StringBuilder(str.length() + 2);
        out.append('"');
        for (int i = 0; i < str.length(); i++) {
            char c = str.charAt(i);
            switch (c) {
                case '"':  out.append("\\\""); break;
                case '\\': out.append("\\\\"); break;
                case '\b': out.append("\\b");  break;
                case '\f': out.append("\\f");  break;
                case '\n': out.append("\\n");  break;
                case '\r': out.append("\\r");  break;
                case '\t': out.append("\\t");  break;
                default:
                    if (c < 0x20 || c >= 0x7f) {
                        out.append(String.format("\\u%04x", (int) c));
                    } else {
                        out.append(c);
                    }
            }
        }
        out.append('"');
        return out.toString();
    }

    /*
     * Canonical serialization: keys sorted by UTF-16 code unit, no ws.
     * Json.NULL serializes as "null"; a Java null means "absent member"
     * which — like JSON.parse's `undefined` values — is unserializable
     * (mirrors verify.js throwing on undefined).
     */
    static String canonicalJson(Object value) {
        if (value == Json.NULL) return "null";
        if (value == null) {
            throw new IllegalStateException("unserializable value in canonical JSON");
        }
        if (value instanceof Boolean) return (Boolean) value ? "true" : "false";
        if (value instanceof Json.Num) return jsNumToString(((Json.Num) value).value);
        if (value instanceof String) return escapeCanonicalString((String) value);
        if (value instanceof List) {
            StringBuilder sb = new StringBuilder("[");
            List<?> l = (List<?>) value;
            for (int i = 0; i < l.size(); i++) {
                if (i > 0) sb.append(',');
                sb.append(canonicalJson(l.get(i)));
            }
            return sb.append(']').toString();
        }
        if (isObject(value)) {
            Map<?, ?> m = (Map<?, ?>) value;
            List<String> keys = new ArrayList<>();
            for (Object k : m.keySet()) keys.add((String) k);
            Collections.sort(keys); // UTF-16 code unit order == JS a<b sort
            StringBuilder sb = new StringBuilder("{");
            boolean first = true;
            for (String k : keys) {
                if (!first) sb.append(',');
                first = false;
                sb.append(escapeCanonicalString(k)).append(':')
                  .append(canonicalJson(m.get(k)));
            }
            return sb.append('}').toString();
        }
        throw new IllegalStateException("unserializable value in canonical JSON");
    }

    /* The -07/-08 entry digest (Section 7.1); fails closed on bad base64. */
    static String entryDigestV07(Object entry, boolean strict) {
        byte[] raw = decodeBase64Strict(Json.get(entry, "canonical_bytes"), strict);
        if (raw == null) throw new IllegalStateException("canonical_bytes is not valid base64");
        Map<String, Object> payload = new LinkedHashMap<>();
        payload.put("close", Json.has(entry, "close") ? Json.get(entry, "close") : Boolean.FALSE);
        payload.put("id", Json.get(entry, "id"));
        payload.put("job_id", Json.get(entry, "job_id"));
        payload.put("output_hash", sha256hex(raw));
        payload.put("prev_digest", Json.get(entry, "prev_digest"));
        payload.put("provenance_class", Json.get(entry, "provenance_class"));
        payload.put("seq", Json.get(entry, "seq"));
        payload.put("tool", Json.get(entry, "tool"));
        return sha256hex(utf8(canonicalJson(payload)));
    }

    /* ------------------------------------------------------------------ *
     *  Section 7.2 — chain rules (-07/-08 construction)                   *
     * ------------------------------------------------------------------ */

    private static final String GENESIS = "0000000000000000000000000000000000000000000000000000000000000000";
    private static final String[] CHAIN_STRING_MEMBERS = {
        "id", "tool", "provenance_class", "job_id", "prev_digest"
    };

    public static List<String> verifyChainV07(Object timeline) {
        return verifyChainV07(timeline, null);
    }

    public static List<String> verifyChainV07(Object timeline, Opts opts) {
        boolean strict = opts != null && opts.strict;
        if (!Json.isArr(timeline) || Json.size(timeline) == 0) {
            return List.of("timeline is not a non-empty list");
        }
        List<?> tl = (List<?>) timeline;
        List<String> failures = new ArrayList<>();
        Object job = null;
        String prev = null;
        for (int i = 0; i < tl.size(); i++) {
            Object entry = tl.get(i);
            if (!isObject(entry)) return List.of("entry " + i + " is not an object");
            if (decodeBase64Strict(Json.get(entry, "canonical_bytes"), strict) == null) {
                return List.of("entry " + i + " canonical_bytes is not valid base64");
            }
            for (String m : CHAIN_STRING_MEMBERS) {
                if (!isNonEmptyString(Json.get(entry, m))) {
                    return List.of("entry " + i + " missing " + m);
                }
            }
            Object seq = Json.get(entry, "seq");
            if (!(seq instanceof Json.Num) || !((Json.Num) seq).isInteger()) {
                return List.of("entry " + i + " seq is not an integer");
            }
            double seqv = ((Json.Num) seq).value;
            if (seqv != i + 1) {
                failures.add("entry " + i + " seq " + jsNumToString(seqv) +
                             " breaks contiguity (expected " + (i + 1) + ")");
            }
            if (job == null) job = Json.get(entry, "job_id");
            else if (!Objects.equals(Json.get(entry, "job_id"), job)) {
                failures.add("entry " + i + " job_id mixes jobs");
            }
            Object pd = Json.get(entry, "prev_digest");
            if (i == 0) {
                if (!GENESIS.equals(pd)) {
                    failures.add("entry 0 prev_digest is not 64 zero characters");
                }
            } else if (!Objects.equals(pd, prev)) {
                failures.add("entry " + i +
                             " prev_digest does not match the digest of entry " + (i - 1));
            }
            boolean hasClose = Json.has(entry, "close");
            Object cv = get0(entry, "close");
            if (hasClose && !(cv instanceof Boolean)) {
                failures.add("entry " + i + " close is not a boolean");
            } else if (i < tl.size() - 1 && Boolean.TRUE.equals(cv)) {
                failures.add("entry " + i + " carries close:true before the last entry");
            }
            prev = entryDigestV07(entry, strict);
        }
        Object last = tl.get(tl.size() - 1);
        if (!isObject(last) || !Boolean.TRUE.equals(get0(last, "close"))) {
            failures.add("last entry does not carry close: true");
        }
        return failures;
    }

    /* like Json.get but tolerates a non-Map input by returning null */
    private static Object get0(Object obj, String key) {
        return isObject(obj) ? Json.get(obj, key) : null;
    }

    /* ------------------------------------------------------------------ *
     *  Historical -06 construction (Section 7.4): entry digest =          *
     *  SHA-256(decoded canonical_bytes). Strictly labeled legacy check.   *
     * ------------------------------------------------------------------ */

    public static List<String> verifyChainV06(Object timeline) {
        return verifyChainV06(timeline, null);
    }

    public static List<String> verifyChainV06(Object timeline, Opts opts) {
        boolean strict = opts != null && opts.strict;
        if (!Json.isArr(timeline) || Json.size(timeline) == 0) {
            return List.of("timeline is not a non-empty list");
        }
        List<?> tl = (List<?>) timeline;
        List<String> failures = new ArrayList<>();
        String prev = null;
        for (int i = 0; i < tl.size(); i++) {
            Object entry = tl.get(i);
            if (!isObject(entry)) {
                failures.add("entry " + i + " is not an object");
                break;
            }
            byte[] raw = decodeBase64Strict(Json.get(entry, "canonical_bytes"), strict);
            if (raw == null) {
                failures.add("entry " + i + " canonical_bytes is not valid base64");
                break;
            }
            String digest = sha256hex(raw);
            Object pd = Json.get(entry, "prev_digest");
            if (i == 0) {
                if (!(pd instanceof String) || !GENESIS.equals(pd)) {
                    failures.add("entry 0 prev_digest is not 64 zero characters");
                }
            } else if (!(pd instanceof String) || !pd.equals(prev)) {
                failures.add("entry " + i +
                             " prev_digest does not match the digest of entry " + (i - 1));
            }
            prev = digest;
        }
        Object last = tl.get(tl.size() - 1);
        if (isObject(last) && !Boolean.TRUE.equals(get0(last, "close"))) {
            failures.add("last entry does not carry close: true");
        }
        return failures;
    }

    /* ------------------------------------------------------------------ *
     *  Section 7.3 — external truncation commitment                       *
     * ------------------------------------------------------------------ */

    static String finalEntryDigestV07(Object timeline, Opts opts) {
        boolean strict = opts != null && opts.strict;
        if (!Json.isArr(timeline) || Json.size(timeline) == 0) return null;
        List<?> tl = (List<?>) timeline;
        Object last = tl.get(tl.size() - 1);
        if (!isObject(last)) return null;
        try {
            return entryDigestV07(last, strict);
        } catch (RuntimeException e) {
            return null;
        }
    }

    public static List<String> checkFinalEntryCommitment(Object timeline, Object commitment, Opts opts) {
        List<String> failures = new ArrayList<>();
        if (!(commitment instanceof String) || !HEX64_RE.matcher((String) commitment).matches()) {
            failures.add("external commitment is not a lowercase hex SHA-256 digest");
            return failures;
        }
        String digest = finalEntryDigestV07(timeline, opts);
        if (digest == null) {
            failures.add("cannot recompute the final entry digest");
        } else if (!digest.equals(commitment)) {
            failures.add("external commitment does not bind the final entry digest " +
                         "(expected " + commitment + ", recomputed " + digest + ")");
        }
        return failures;
    }

    /* ------------------------------------------------------------------ *
     *  Section 7.3 (-09) — chain commitment artifact                      *
     *                                                                     *
     *  Mirrors the kit's verifyChainCommitment() verbatim, including the   *
     *  lexical-float rejection of entry_count (the corpus writes 5.0 and   *
     *  the -10 kit runner marks \d+\.\d+ literals as {"__float__": N};      *
     *  Json.Num keeps the raw lexeme so we detect the same condition        *
     *  without preprocessing).                                            *
     * ------------------------------------------------------------------ */

    public static List<String> verifyChainCommitment(Object timeline, Object commitment, Opts opts) {
        boolean strict = opts != null && opts.strict;
        if (!Json.isArr(timeline) || Json.size(timeline) == 0) {
            return List.of("timeline is not a non-empty list");
        }
        if (!isObject(commitment)) {
            return List.of("commitment is not a JSON object");
        }
        List<?> tl = (List<?>) timeline;
        List<String> failures = new ArrayList<>();
        Object jobId = Json.get(commitment, "job_id");
        if (!isNonEmptyString(jobId)) {
            failures.add("commitment job_id is not a non-empty string");
        }
        Object digest = Json.get(commitment, "final_entry_digest");
        if (!(digest instanceof String) || !HEX64_RE.matcher((String) digest).matches()) {
            failures.add("commitment final_entry_digest is not 64 lowercase " +
                         "hex characters");
        }
        Object count = Json.get(commitment, "entry_count");
        if (!(count instanceof Json.Num) ||
            ((Json.Num) count).isMarkedFloat() ||
            !((Json.Num) count).isInteger()) {
            failures.add("commitment entry_count is not an integer");
        }
        Object published = Json.get(commitment, "published_at");
        if (!isRfc3339Format(published, strict)) {
            failures.add("commitment published_at is not RFC 3339");
        } else if (!isCalendarDateTime((String) published, strict)) {
            failures.add("commitment published_at is not a valid calendar " +
                         "date/time");
        }
        if (!failures.isEmpty()) return failures;

        // Member shapes are sound; now bind them to the timeline.
        if (((Json.Num) count).value != tl.size()) {
            failures.add("commitment entry_count " + jsNumToString(((Json.Num) count).value) +
                         " does not match the timeline length " + tl.size());
        }
        for (int i = 0; i < tl.size(); i++) {
            Object entry = tl.get(i);
            if (!isObject(entry) || !isNonEmptyString(Json.get(entry, "job_id"))) {
                failures.add("entry " + i + " has no usable job_id");
            } else if (!Objects.equals(Json.get(entry, "job_id"), jobId)) {
                failures.add("entry " + i +
                             " job_id does not match the commitment job_id");
            }
        }
        // Recompute the last entry's digest, failing closed like
        // verifyChainV07 (strict base64 plus the length % 4 guard).
        Object last = tl.get(tl.size() - 1);
        String want = null;
        try {
            Object cb = isObject(last) ? Json.get(last, "canonical_bytes") : null;
            if (decodeBase64Strict(cb, strict) == null) {
                throw new IllegalStateException("bad base64");
            }
            want = entryDigestV07(last, strict);
        } catch (RuntimeException e) {
            failures.add("final entry digest could not be recomputed: " +
                         e.getMessage());
        }
        if (want != null && !want.equals(digest)) {
            failures.add("commitment final_entry_digest does not match the " +
                         "recomputed digest of the last entry");
        }
        return failures;
    }

    /* ------------------------------------------------------------------ *
     *  Section 8.1 — Merkle root over ordered step receipt_id values      *
     * ------------------------------------------------------------------ */

    public static String merkleRoot(List<String> receiptIds) {
        if (receiptIds.isEmpty()) return sha256hex(new byte[0]);
        List<byte[]> level = new ArrayList<>();
        for (String rid : receiptIds) level.add(sha256(utf8(rid)));
        while (level.size() > 1) {
            if (level.size() % 2 == 1) level.add(level.get(level.size() - 1));
            List<byte[]> next = new ArrayList<>();
            for (int i = 0; i < level.size(); i += 2) {
                byte[] concat = new byte[level.get(i).length + level.get(i + 1).length];
                System.arraycopy(level.get(i), 0, concat, 0, level.get(i).length);
                System.arraycopy(level.get(i + 1), 0, concat, level.get(i).length,
                                 level.get(i + 1).length);
                next.add(sha256(concat));
            }
            level = next;
        }
        return hex(level.get(0));
    }

    /* ------------------------------------------------------------------ *
     *  Section 8.2 (offline part) — workflow structural checks            *
     * ------------------------------------------------------------------ */

    public static List<String> verifyWorkflowSteps(Object w) {
        if (!isObject(w)) return List.of("workflow is not a JSON object");
        Object steps = Json.get(w, "steps");
        if (!Json.isArr(steps) || Json.size(steps) == 0) {
            return List.of("workflow steps is not a non-empty list");
        }
        List<?> st = (List<?>) steps;
        List<String> failures = new ArrayList<>();
        for (int i = 0; i < st.size(); i++) {
            Object step = st.get(i);
            if (!isObject(step)) {
                failures.add("step " + (i + 1) + " is not an object");
                continue;
            }
            Object seq = Json.get(step, "seq");
            if (!(seq instanceof Json.Num) || !((Json.Num) seq).isInteger()) {
                failures.add("step " + (i + 1) + " seq is not an integer");
            }
            if (!(Json.get(step, "receipt_id") instanceof String)) {
                failures.add("step " + (i + 1) + " receipt_id is not a string");
            }
        }
        if (!failures.isEmpty()) return failures;
        for (int i = 0; i < st.size(); i++) {
            Object seq = Json.get(st.get(i), "seq");
            if (((Json.Num) seq).value != i + 1) {
                failures.add("step seq values are not 1.." + st.size() +
                             " in order (index " + i + " carries seq " +
                             jsNumToString(((Json.Num) seq).value) + ")");
                break;
            }
        }
        Set<Object> seen = new HashSet<>();
        for (Object step : st) {
            Object rid = Json.get(step, "receipt_id");
            if (seen.contains(rid)) {
                failures.add("duplicate receipt_id: " + rid);
                break;
            }
            seen.add(rid);
        }
        List<String> ids = new ArrayList<>();
        for (Object step : st) ids.add((String) Json.get(step, "receipt_id"));
        String root = merkleRoot(ids);
        Object mr = Json.get(w, "merkle_root");
        if (!(mr instanceof String) || !mr.equals(root)) {
            failures.add("merkle_root does not match the recomputed Section 8.1 root");
        }
        return failures;
    }

    /* ------------------------------------------------------------------ *
     *  Section 8 (full member check) — workflow receipt files             *
     * ------------------------------------------------------------------ */

    public static List<String> checkWorkflow(Object w) {
        return checkWorkflow(w, null);
    }

    public static List<String> checkWorkflow(Object w, Opts opts) {
        boolean strict = opts != null && opts.strict;
        List<String> failures = new ArrayList<>();
        if (!isObject(w)) return List.of("workflow is not a JSON object");
        String[] required = {"type", "version", "workflow_id", "receipt_id",
                             "session_id", "goal", "status", "steps",
                             "merkle_root", "output_hash", "verify_url"};
        for (String field : required) {
            if (!Json.has(w, field)) failures.add("missing required member: " + field);
        }
        if (!failures.isEmpty()) return failures;
        if (!"verifiable-workflow-receipt".equals(Json.get(w, "type"))) {
            failures.add("type is not \"verifiable-workflow-receipt\"");
        }
        if (!isNonEmptyString(Json.get(w, "version"))) {
            failures.add("version is not a non-empty string");
        }
        Pattern uuidRe = strict ? UUID_V4_RE : UUID_LOOSE_RE;
        Object wid = Json.get(w, "workflow_id");
        if (!(wid instanceof String) || !uuidRe.matcher((String) wid).matches()) {
            failures.add("workflow_id is not a UUID");
        }
        Object rid = Json.get(w, "receipt_id");
        if (!(rid instanceof String) || !uuidRe.matcher((String) rid).matches()) {
            failures.add("receipt_id is not a UUID");
        }
        for (String k : new String[]{"session_id", "goal", "status"}) {
            if (!isNonEmptyString(Json.get(w, k))) {
                failures.add(k + " is not a non-empty string");
            }
        }
        Object mr = Json.get(w, "merkle_root");
        if (!(mr instanceof String) || !HEX64_RE.matcher((String) mr).matches()) {
            failures.add("merkle_root is not a lowercase hex SHA-256 digest");
        }
        Object oh = Json.get(w, "output_hash");
        if (!(oh instanceof String) || !HEX64_RE.matcher((String) oh).matches()) {
            failures.add("output_hash is not a lowercase hex SHA-256 digest");
        }
        Object vu = Json.get(w, "verify_url");
        if (!isNonEmptyString(vu) ||
            !(((String) vu).startsWith("http://") || ((String) vu).startsWith("https://"))) {
            failures.add("verify_url is not an http(s) URL");
        }
        Object steps = Json.get(w, "steps");
        if (Json.isArr(steps)) {
            List<?> st = (List<?>) steps;
            for (int i = 0; i < st.size(); i++) {
                Object s = st.get(i);
                if (!isObject(s)) {
                    failures.add("step " + (i + 1) + " is not an object");
                    continue;
                }
                String[] smembers = {"seq", "tool", "receipt_id", "receipt_hash",
                                     "started_at", "ended_at", "status"};
                for (String field : smembers) {
                    if (!Json.has(s, field)) {
                        failures.add("step " + (i + 1) + " missing member: " + field);
                    }
                }
                Object seq = Json.get(s, "seq");
                if (!(seq instanceof Json.Num) || !((Json.Num) seq).isInteger()) {
                    failures.add("step " + (i + 1) + " seq is not an integer");
                }
                if (!isNonEmptyString(Json.get(s, "tool"))) {
                    failures.add("step " + (i + 1) + " tool is not a non-empty string");
                }
                Object srid = Json.get(s, "receipt_id");
                if (!(srid instanceof String) || !uuidRe.matcher((String) srid).matches()) {
                    failures.add("step " + (i + 1) + " receipt_id is not a UUID");
                }
                Object srh = Json.get(s, "receipt_hash");
                if (!(srh instanceof String) || !HEX64_RE.matcher((String) srh).matches()) {
                    failures.add("step " + (i + 1) + " receipt_hash is not hex SHA-256");
                }
                if (!isRfc3339(Json.get(s, "started_at"), strict)) {
                    failures.add("step " + (i + 1) + " started_at is not RFC 3339");
                }
                if (!isRfc3339(Json.get(s, "ended_at"), strict)) {
                    failures.add("step " + (i + 1) + " ended_at is not RFC 3339");
                }
                if (!isNonEmptyString(Json.get(s, "status"))) {
                    failures.add("step " + (i + 1) + " status is not a non-empty string");
                }
            }
        }
        failures.addAll(verifyWorkflowSteps(w));
        return failures;
    }

    /* ------------------------------------------------------------------ *
     *  Top-level dispatch                                                 *
     * ------------------------------------------------------------------ */

    private static void pushAll(List<Failure> results, String check, List<String> reasons) {
        for (String r : reasons) results.add(new Failure(check, r));
    }

    public static Result verifyDocument(Object doc, Opts opts) {
        if (opts == null) opts = new Opts();
        List<Failure> results = new ArrayList<>();

        String kind = opts.kind != null ? opts.kind : "auto";
        if ("auto".equals(kind)) {
            if (Json.isArr(doc)) kind = "chain";
            else if (isObject(doc) &&
                     (Json.isArr(get0(doc, "timeline")) || Json.isArr(get0(doc, "entries"))))
                kind = "chain";
            else if (isObject(doc) &&
                     "verifiable-workflow-receipt".equals(get0(doc, "type")))
                kind = "workflow";
            else kind = "receipt";
        }

        if ("chain".equals(kind) || "chain06".equals(kind)) {
            Object tl;
            if (Json.isArr(doc)) tl = doc;
            else if (isObject(doc)) {
                Object t0 = get0(doc, "timeline");
                tl = truthy(t0) ? t0 : get0(doc, "entries"); // JS: doc.timeline || doc.entries
            } else tl = null;
            pushAll(results, "chain06".equals(kind) ? "chain(-06)" : "chain(-07/-08)",
                    "chain06".equals(kind) ? verifyChainV06(tl, opts)
                                           : verifyChainV07(tl, opts));
            // Section 7.3: published final-entry commitment binding.
            if (isObject(doc) && get0(doc, "final_entry_digest") instanceof String) {
                pushAll(results, "final-entry-commitment",
                        checkFinalEntryCommitment(tl, get0(doc, "final_entry_digest"), opts));
            }
            // Section 7.3 (-09): chain commitment artifact.
            if (isObject(doc) && Json.has(doc, "commitment")) {
                pushAll(results, "chain-commitment(-09)",
                        verifyChainCommitment(tl, get0(doc, "commitment"), opts));
            }
            return new Result(kind, results);
        }
        if ("workflow".equals(kind)) {
            pushAll(results, "workflow", checkWorkflow(doc, opts));
            return new Result(kind, results);
        }
        // Receipt.
        pushAll(results, "core", checkReceipt(doc, opts));
        if (opts.profile) pushAll(results, "reference-profile", checkReferenceProfile(doc, opts));
        if (opts.anchor || anchorUsesTierShape(doc)) {
            pushAll(results, "disinterested-tier", checkDisinterestedTier(doc, opts));
        }
        return new Result(kind, results);
    }

    /**
     * Verify one raw JSON document. Mirrors verify.js verifyFile(): a parse
     * failure produces a single "parse" check result. Returns the Result
     * (kind + tagged failures); use {@link Result#reasons()} for the flat
     * failure-reason string list, or {@link #verifyText} for it directly.
     */
    public static Result verifyJsonText(String jsonText, Opts opts) {
        Object doc;
        try {
            doc = Json.parse(jsonText);
        } catch (Json.ParseException e) {
            List<Failure> f = new ArrayList<>();
            f.add(new Failure("parse", "not valid JSON: " + e.getMessage()));
            return new Result("?", f);
        }
        return verifyDocument(doc, opts);
    }

    /**
     * Primary API: verify raw JSON text and return the flat list of
     * failure-reason strings (empty == conformant). Reasons match the JS
     * verifier's strings verbatim.
     */
    public static List<String> verifyText(String jsonText, Opts opts) {
        return verifyJsonText(jsonText, opts).reasons();
    }

    /** Convenience: receipt-kind verification of raw text. */
    public static List<String> verifyReceiptText(String jsonText,
                                                 boolean strict,
                                                 boolean profile,
                                                 boolean anchor) {
        Opts o = new Opts();
        o.strict = strict;
        o.profile = profile;
        o.anchor = anchor;
        o.kind = "receipt";
        return verifyText(jsonText, o);
    }

    /* ------------------------------------------------------------------ *
     *  CLI (mirror of verify.js main)                                     *
     * ------------------------------------------------------------------ */

    private static List<String> collectJsonFiles(java.io.File target) {
        List<String> out = new ArrayList<>();
        if (target.isDirectory()) {
            String[] names = target.list();
            if (names == null) return out;
            List<String> sorted = new ArrayList<>();
            for (String n : names) {
                if (n.endsWith(".json") && !n.startsWith(".")) sorted.add(n);
            }
            Collections.sort(sorted);
            for (String n : sorted) out.add(new java.io.File(target, n).getPath());
        } else {
            out.add(target.getPath());
        }
        return out;
    }

    private static Result verifyFile(String path, Opts opts) {
        String text;
        try {
            text = new String(java.nio.file.Files.readAllBytes(
                java.nio.file.Paths.get(path)), StandardCharsets.UTF_8);
        } catch (java.io.IOException e) {
            List<Failure> f = new ArrayList<>();
            f.add(new Failure("read", "cannot read file: " + e.getMessage()));
            return new Result("?", f);
        }
        return verifyJsonText(text, opts);
    }

    public static void main(String[] argv) {
        Opts opts = new Opts();
        List<String> targets = new ArrayList<>();
        for (String a : argv) {
            switch (a) {
                case "--strict":   opts.strict = true; break;
                case "--chain06":  opts.kind = "chain06"; break;
                case "--chain":    opts.kind = "chain"; break;
                case "--workflow": opts.kind = "workflow"; break;
                case "--receipt":  opts.kind = "receipt"; break;
                case "--profile":  opts.profile = true; break;
                case "--anchor":   opts.anchor = true; break;
                default: targets.add(a);
            }
        }
        if (targets.isEmpty()) {
            System.err.println("usage: java Aer1Verify <file-or-dir> " +
                "[--strict] [--chain|--chain06|--workflow|--receipt] [--profile] [--anchor]");
            System.exit(2);
        }
        List<String> all = new ArrayList<>();
        for (String t : targets) all.addAll(collectJsonFiles(new java.io.File(t)));
        int bad = 0;
        for (String f : all) {
            Result r = verifyFile(f, opts);
            boolean ok = r.failures.isEmpty();
            if (!ok) bad++;
            String label = "[" + r.kind + "]";
            if (ok) {
                System.out.println("PASS " + label + " " + f);
            } else {
                Failure first = r.failures.get(0);
                System.out.println("FAIL " + label + " " + f + " \u2014 " + first.reason +
                    (r.failures.size() > 1 ? " (+" + (r.failures.size() - 1) + " more)" : ""));
            }
        }
        System.out.println("\n" + (all.size() - bad) + "/" + all.size() + " verified");
        System.exit(bad == 0 ? 0 : 1);
    }
}
