<?php
/**
 * aer1.php — Independent AER-1 conformance verifier (PHP 8.2, zero
 * dependencies, CLI only).
 *
 * Implements the verification rules of draft-zambo-aer1-10, matching the
 * reference runner aer-1/conformance.py rule for rule:
 *
 *   - Section 3   core receipt check (aer1_check)
 *   - reference-producer profile check (aer1_check_reference_profile)
 *   - Section 3/-09 strict id + anchored tier (aer1_check_disinterested_tier)
 *   - Section 7   -06 chain mechanics (aer1_verify_chain)
 *   - Section 7.1/-07 hardened entry digest + chain (aer1_verify_chain_v07)
 *   - Section 7.3 external chain commitment (aer1_verify_chain_commitment)
 *   - Section 8.1 Merkle root (aer1_merkle_root)
 *   - Section 8.2 steps 3-4 offline workflow check (aer1_verify_workflow)
 *
 * CLI usage:
 *   php aer1.php <receipt.json>          core check; exit 0 conformant,
 *                                        exit 1 rejected (reasons on stderr)
 *   php aer1.php --tier=strict <file>    core check + strict id/anchor tier
 *   php aer1.php --profile <file>        reference-producer profile check
 *   php aer1.php --anchor <file>         alias for --tier=strict
 *
 * This file is also require-able: every rule is exposed as a function and
 * the CLI block only runs when the file is invoked directly.
 *
 * JSON values are decoded by aer1_json_decode(): JSON objects become
 * Aer1JObject instances (an ArrayObject subclass supporting $obj['key']
 * access and foreach), JSON arrays become plain PHP lists. This keeps
 * the Python dict/list distinction exact — an empty {} never collapses
 * into an empty [] the way json_decode(assoc=true) would make it.
 *
 * Honest limit (mirrored from the reference runner): a prefix or suffix
 * of a valid chain re-linked end to end verifies under chain mechanics
 * alone; truncation resistance needs the Section 7.3 commitment. Float
 * serialization in the entry digest follows Python repr() semantics for
 * integral and ordinary floats; extreme exponent-notation floats could
 * diverge, and none appear in any digested member in the corpora (seq is
 * normalized to int before digesting).
 */

// ----------------------------------------------------------------------
// JSON decode preserving the object/list distinction.
// ----------------------------------------------------------------------

/** Marker type: a JSON object. Behaves like a map: $o['k'], foreach. */
class Aer1JObject extends ArrayObject {}

/**
 * Decode JSON like Python json.loads: objects -> Aer1JObject, arrays ->
 * PHP list, scalars -> scalar. Returns null on parse failure (check
 * json_last_error()).
 */
function aer1_json_decode(string $text)
{
    $v = json_decode($text);
    if (json_last_error() !== JSON_ERROR_NONE) {
        return null;
    }
    return aer1_convert_json($v);
}

function aer1_convert_json($v)
{
    if ($v instanceof stdClass) {
        $o = new Aer1JObject();
        foreach (get_object_vars($v) as $k => $item) {
            $o[$k] = aer1_convert_json($item);
        }
        return $o;
    }
    if (is_array($v)) {
        return array_map('aer1_convert_json', $v);
    }
    return $v;
}

/** True when $v is a JSON object (dict in Python terms). */
function aer1_is_obj($v): bool
{
    if ($v instanceof Aer1JObject) {
        return true;
    }
    // Plain assoc arrays built in PHP code count as objects too.
    return is_array($v) && !array_is_list($v);
}

/** True when $v is a JSON array (list in Python terms). */
function aer1_is_list($v): bool
{
    return is_array($v) && array_is_list($v);
}

/** Python 'key in dict'. */
function aer1_has($o, string $k): bool
{
    if ($o instanceof Aer1JObject) {
        return array_key_exists($k, (array) $o);
    }
    return is_array($o) && array_key_exists($k, $o);
}

/** Python dict.get(key). */
function aer1_get($o, string $k)
{
    if (aer1_has($o, $k)) {
        return $o[$k];
    }
    return null;
}

/** Object keys as strings (Python dict.keys()). */
function aer1_keys($o): array
{
    return array_map('strval', array_keys((array) $o));
}

/** Python: isinstance(x, int) and not bool, or integer-valued float. */
function aer1_is_int_like($v): bool
{
    if (is_bool($v)) {
        return false;
    }
    return is_int($v) || (is_float($v) && floor($v) === $v && is_finite($v));
}

// ----------------------------------------------------------------------
// Regexes. Python's \Z anchors at the absolute end of the string; the PCRE
// equivalent is \z (PCRE \Z would also match before a trailing newline —
// that difference is exactly what id-trailing-newline exercises).
// ----------------------------------------------------------------------

const AER1_UUID_RE = '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/';
const AER1_STRICT_UUID_RE = '/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\z/';
const AER1_RFC3339_RE = '/^[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/';
const AER1_HASH_RE = '/^sha256:[0-9a-f]{64}\z/';
const AER1_PROV_RE = '/^(EXECUTED BY [A-Z0-9][A-Z0-9 ._-]*|OBSERVED VIA GATEWAY|LOGGED BY AGENT)\z/';
const AER1_B64_RE = '/^[A-Za-z0-9+\/]*={0,2}\z/';
// Python COMMIT_DIGEST_RE uses $ which DOES match before one trailing
// newline; replicate that exact semantics.
const AER1_COMMIT_DIGEST_RE = '/^[0-9a-f]{64}\n?\z/';
const AER1_HEXDIGEST_RE = '/^[0-9a-f]{64}\z/';

const AER1_CORE = [
    'id', 'receipt_schema_version', 'created_at', 'tool',
    'provenance_class', 'canonical_bytes', 'output_hash',
    'verification_status',
];

// ----------------------------------------------------------------------
// Strict RFC 3339 calendar validation (port of _strict_rfc3339).
// ----------------------------------------------------------------------

function aer1_strict_rfc3339($s): bool
{
    if (!is_string($s) || !preg_match(AER1_RFC3339_RE, $s)) {
        return false;
    }
    // Numeric offset HH must be 00-23, MM 00-59 (the shape is guaranteed).
    if (substr($s, -1) !== 'Z') {
        $off = substr($s, -6); // [+-]HH:MM
        $oh = (int) substr($off, 1, 2);
        $om = (int) substr($off, 4, 2);
        if (!($oh >= 0 && $oh <= 23 && $om >= 0 && $om <= 59)) {
            return false;
        }
    }
    $year   = (int) substr($s, 0, 4);
    $month  = (int) substr($s, 5, 2);
    $day    = (int) substr($s, 8, 2);
    $hour   = (int) substr($s, 11, 2);
    $minute = (int) substr($s, 14, 2);
    $second = (int) substr($s, 17, 2);
    if (!($month >= 1 && $month <= 12)) {
        return false;
    }
    if (!($hour >= 0 && $hour <= 23 && $minute >= 0 && $minute <= 59
          && $second >= 0 && $second <= 60)) {
        // second=60 allows the RFC 3339 leap second.
        return false;
    }
    if (in_array($month, [1, 3, 5, 7, 8, 10, 12], true)) {
        $maxDay = 31;
    } elseif (in_array($month, [4, 6, 9, 11], true)) {
        $maxDay = 30;
    } else {
        $isLeap = ($year % 4 === 0 && ($year % 100 !== 0 || $year % 400 === 0));
        $maxDay = $isLeap ? 29 : 28;
    }
    return $day >= 1 && $day <= $maxDay;
}

// ----------------------------------------------------------------------
// Strict base64 decode mirroring Python base64.b64decode(validate=True):
// alphabet-only input, '=' padding only at the end (0-2 chars), and total
// length a multiple of 4. PHP's strict base64_decode() tolerates missing
// padding, so the length check is required to match Python.
// Returns the raw bytes, or null on failure.
// ----------------------------------------------------------------------

function aer1_b64_decode($s): ?string
{
    if (!is_string($s) || !preg_match(AER1_B64_RE, $s)) {
        return null;
    }
    if (strlen($s) % 4 !== 0) {
        return null;
    }
    $raw = base64_decode($s, true);
    return $raw === false ? null : $raw;
}

/** Strict UTF-8 validation (PCRE //u rejects overlongs, surrogates, >U+10FFFF). */
function aer1_is_utf8(string $s): bool
{
    return preg_match('//u', $s) === 1;
}

// ----------------------------------------------------------------------
// Section 7.1 canonical JSON serialization — byte-exact port of Python
// json.dumps(payload, sort_keys=True, separators=(",", ":")) with the
// default ensure_ascii=True escaping.
// ----------------------------------------------------------------------

/** Escape one string exactly as Section 7.1 specifies. */
function aer1_json_escape(string $s): string
{
    $out = '"';
    // Decode UTF-8 to fixed-width UTF-32BE for a code-point walk.
    $utf32 = iconv('UTF-8', 'UTF-32BE', $s);
    if ($utf32 === false) {
        // Input came from the JSON decoder and is always valid UTF-8;
        // fail closed anyway.
        return '""';
    }
    $n = strlen($utf32);
    for ($i = 0; $i < $n; $i += 4) {
        $cp = unpack('N', substr($utf32, $i, 4))[1];
        if ($cp === 0x22) {
            $out .= '\\"';
        } elseif ($cp === 0x5C) {
            $out .= '\\\\';
        } elseif ($cp === 0x08) {
            $out .= '\\b';
        } elseif ($cp === 0x09) {
            $out .= '\\t';
        } elseif ($cp === 0x0A) {
            $out .= '\\n';
        } elseif ($cp === 0x0C) {
            $out .= '\\f';
        } elseif ($cp === 0x0D) {
            $out .= '\\r';
        } elseif ($cp >= 0x20 && $cp <= 0x7E) {
            $out .= chr($cp);
        } elseif ($cp <= 0xFFFF) {
            $out .= sprintf('\\u%04x', $cp);
        } else {
            // Surrogate pair, lowercase hex.
            $v = $cp - 0x10000;
            $out .= sprintf('\\u%04x\\u%04x', 0xD800 + ($v >> 10), 0xDC00 + ($v & 0x3FF));
        }
    }
    return $out . '"';
}

/** Serialize a float the way Python repr()/json.dumps does, best effort. */
function aer1_json_float(float $f): string
{
    if (is_nan($f)) {
        return 'NaN';
    }
    if ($f === INF) {
        return 'Infinity';
    }
    if ($f === -INF) {
        return '-Infinity';
    }
    $s = json_encode($f, JSON_PRESERVE_ZERO_FRACTION);
    if ($s === false) {
        return '0';
    }
    // PHP emits "1.0E+16"; Python repr emits "1e+16" and pads the
    // exponent to at least two digits. Normalize toward Python's form.
    if (preg_match('/^(-?\d+(?:\.\d+)?)[eE]([+-]?)(\d+)$/', $s, $m)) {
        $mant = preg_replace('/\.0+$/', '', $m[1]);
        $mant = rtrim(rtrim($mant, '0'), '.');
        if ($mant === '' || $mant === '-') {
            $mant .= '0';
        }
        $digits = str_pad($m[3], 2, '0', STR_PAD_LEFT);
        return $mant . 'e' . ($m[2] === '-' ? '-' : '+') . $digits;
    }
    if (strpos($s, '.') === false) {
        return $s . '.0'; // integral float: Python repr keeps ".0"
    }
    return $s;
}

/** Canonical serializer: sort object keys, no whitespace, ASCII escaping. */
function aer1_json_dumps($v): string
{
    if ($v === null) {
        return 'null';
    }
    if (is_bool($v)) {
        return $v ? 'true' : 'false';
    }
    if (is_int($v)) {
        return (string) $v;
    }
    if (is_float($v)) {
        return aer1_json_float($v);
    }
    if (is_string($v)) {
        return aer1_json_escape($v);
    }
    if (aer1_is_obj($v)) {
        // Sort object members by key. Byte order of UTF-8 strings equals
        // Unicode code-point order, matching Python's sort_keys.
        $pairs = [];
        foreach ($v as $k => $item) {
            $pairs[] = [(string) $k, $item];
        }
        usort($pairs, fn($a, $b) => strcmp($a[0], $b[0]));
        $parts = [];
        foreach ($pairs as [$k, $item]) {
            $parts[] = aer1_json_escape($k) . ':' . aer1_json_dumps($item);
        }
        return '{' . implode(',', $parts) . '}';
    }
    if (aer1_is_list($v)) {
        $parts = [];
        foreach ($v as $item) {
            $parts[] = aer1_json_dumps($item);
        }
        return '[' . implode(',', $parts) . ']';
    }
    return 'null'; // unreachable for JSON-decoded values
}

// ----------------------------------------------------------------------
// Section 3 core receipt check. Port of conformance.py check().
// Returns a list of failure reason strings; [] means conformant.
// ----------------------------------------------------------------------

function aer1_check($receipt): array
{
    if (!aer1_is_obj($receipt)) {
        return ['receipt is not a JSON object'];
    }
    $failures = [];
    foreach (AER1_CORE as $field) {
        if (!aer1_has($receipt, $field)) {
            $failures[] = "missing required member: $field";
        }
    }
    if ($failures) {
        return $failures;
    }
    $id = aer1_get($receipt, 'id');
    if (!is_string($id) || !preg_match(AER1_UUID_RE, $id)) {
        $failures[] = 'id is not a lowercase UUID';
    }
    $rsv = aer1_get($receipt, 'receipt_schema_version');
    if (!is_string($rsv) || $rsv === '') {
        $failures[] = 'receipt_schema_version is not a non-empty string';
    }
    $createdAt = aer1_get($receipt, 'created_at');
    if (!is_string($createdAt)) {
        $failures[] = 'created_at is not a string';
    } elseif (!preg_match(AER1_RFC3339_RE, $createdAt)) {
        $failures[] = 'created_at is not RFC 3339';
    } elseif (!aer1_strict_rfc3339($createdAt)) {
        $failures[] = 'created_at is not a valid calendar date/time';
    }
    $tool = aer1_get($receipt, 'tool');
    $toolOk = aer1_is_obj($tool);
    if ($toolOk) {
        foreach (['name', 'version', 'scope'] as $k) {
            $v = aer1_get($tool, $k);
            if (!is_string($v) || $v === '') {
                $toolOk = false;
                break;
            }
        }
    }
    if (!$toolOk) {
        $failures[] = 'tool is not an object with name/version/scope strings';
    }
    $prov = aer1_get($receipt, 'provenance_class');
    if (!is_string($prov) || !preg_match(AER1_PROV_RE, $prov)) {
        $failures[] = 'provenance_class is not a known class';
    }
    $cb = aer1_get($receipt, 'canonical_bytes');
    $raw = null;
    if (!is_string($cb) || !preg_match(AER1_B64_RE, $cb)) {
        $failures[] = 'canonical_bytes is not valid base64';
    } else {
        $raw = aer1_b64_decode($cb);
        if ($raw === null) {
            $failures[] = 'canonical_bytes is not valid base64';
        } elseif (!aer1_is_utf8($raw)) {
            $failures[] = 'canonical_bytes is not valid UTF-8';
            $raw = null;
        }
    }
    $oh = aer1_get($receipt, 'output_hash');
    if (!is_string($oh) || !preg_match(AER1_HASH_RE, $oh)) {
        $failures[] = 'output_hash is not sha256: + lowercase hex digest';
    } elseif ($raw !== null) {
        $want = 'sha256:' . hash('sha256', $raw);
        if ($oh !== $want) {
            $failures[] = 'output_hash does not match sha256(canonical_bytes)';
        }
    }
    if (aer1_get($receipt, 'verification_status') !== 'verified') {
        $failures[] = 'verification_status is not verified';
    }

    // 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 !== null) {
        $inner = aer1_json_decode($raw);
        $innerObj = aer1_is_obj($inner);
        $innerHasV = $innerObj && aer1_has($inner, 'receipt_schema_version');
        $innerVersion = $innerHasV ? aer1_get($inner, 'receipt_schema_version') : null;
        $outerHasV = aer1_has($receipt, 'receipt_schema_version');
        $outerVersion = aer1_get($receipt, 'receipt_schema_version');
        $legacyProbe = $innerHasV ? $innerVersion : $outerVersion;
        $isLegacy = $legacyProbe === '0.2' || $legacyProbe === '0.3';
        if (!$isLegacy) {
            if ($innerHasV && $outerHasV) {
                if ((string) $innerVersion !== (string) $outerVersion) {
                    $failures[] = 'receipt_schema_version: outer ' . json_encode($outerVersion) .
                        ' does not match committed ' . json_encode($innerVersion);
                }
            } else {
                $failures[] = 'receipt_schema_version: one side missing (outer=' .
                    json_encode($outerVersion) . ' committed=' . json_encode($innerVersion) . ')';
            }
            if ($innerObj) {
                $icl = aer1_get($inner, 'issuer_claims');
                $hasIc = aer1_is_obj($icl) && aer1_has($icl, 'provenance_class');
                $ic = $hasIc ? aer1_get($icl, 'provenance_class') : null;
                $opc = aer1_get($receipt, 'provenance_class');
                $opcDefined = aer1_has($receipt, 'provenance_class');
                if ($hasIc && $opcDefined) {
                    if ($ic !== $opc) {
                        $failures[] = 'provenance binding: outer ' . json_encode($opc) .
                            ' does not match committed ' . json_encode($ic);
                    }
                } else {
                    $failures[] = 'provenance binding: one side missing (outer=' .
                        json_encode($opc) . ' committed=' . json_encode($ic) . ')';
                }
            } else {
                $failures[] = 'provenance binding: canonical payload did not decode to an object';
            }
        }
    }

    // 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.
    $hasMeta = aer1_has($receipt, 'metadata_hash');
    if (!$hasMeta) {
        $rsvNow = aer1_get($receipt, 'receipt_schema_version');
        if (is_string($rsvNow) && aer1_version_geq($rsvNow, '0.4')) {
            $failures[] = 'metadata_hash is required at receipt_schema_version 0.4 or later';
        }
    } else {
        $mh = aer1_get($receipt, 'metadata_hash');
        if (!is_string($mh) || !preg_match(AER1_HASH_RE, $mh)) {
            $failures[] = 'metadata_hash is not sha256: + lowercase hex digest';
        } else {
            $metaTool = aer1_is_obj($tool) ? [
                'name' => aer1_get($tool, 'name'),
                'scope' => aer1_get($tool, 'scope'),
                'version' => aer1_get($tool, 'version'),
            ] : $tool;
            $meta = [
                'created_at' => aer1_get($receipt, 'created_at'),
                'id' => aer1_get($receipt, 'id'),
                'output_hash' => aer1_get($receipt, 'output_hash'),
                'provenance_class' => aer1_get($receipt, 'provenance_class'),
                'receipt_schema_version' => aer1_get($receipt, 'receipt_schema_version'),
                'tool' => $metaTool,
            ];
            $want = 'sha256:' . hash('sha256', aer1_json_dumps($meta));
            if ($mh !== $want) {
                $failures[] = 'metadata_hash does not match sha256(canonical metadata block)';
            }
        }
    }
    return $failures;
}

/** Dotted-version compare: true when $a >= $b numerically per segment. */
function aer1_version_geq(string $a, string $b): bool
{
    $pa = array_map('intval', explode('.', $a));
    $pb = array_map('intval', explode('.', $b));
    $n = max(count($pa), count($pb));
    for ($i = 0; $i < $n; $i++) {
        $d = ($pa[$i] ?? 0) - ($pb[$i] ?? 0);
        if ($d !== 0) {
            return $d > 0;
        }
    }
    return true;
}

// ----------------------------------------------------------------------
// Reference-producer profile check (port of check_reference_profile).
// Producer convention only — core verifiers MUST NOT require it.
// ----------------------------------------------------------------------

function aer1_check_reference_profile($receipt): array
{
    if (!aer1_is_obj($receipt)) {
        return ['receipt is not a JSON object'];
    }
    $cb = aer1_get($receipt, 'canonical_bytes');
    if (!is_string($cb)) {
        return ['canonical_bytes is not a string'];
    }
    $raw = aer1_b64_decode($cb);
    if ($raw === null) {
        return ['canonical_bytes is not valid base64'];
    }
    if (!aer1_is_utf8($raw)) {
        return ['canonical_bytes is not valid UTF-8'];
    }
    $payload = aer1_json_decode($raw);
    if ($payload === null && json_last_error() !== JSON_ERROR_NONE) {
        return ['canonical payload is not JSON'];
    }
    if (!aer1_is_obj($payload)) {
        return ['canonical payload is not a JSON object'];
    }
    if (!aer1_has($payload, 'inputs')) {
        return ["reference-producer payload omits the 'inputs' member"];
    }
    return [];
}

// ----------------------------------------------------------------------
// Anchored / disinterested tier (port of check_disinterested_tier).
// ----------------------------------------------------------------------

function aer1_check_disinterested_tier($receipt): array
{
    $failures = [];
    $rid = aer1_is_obj($receipt) ? aer1_get($receipt, 'id') : null;
    if (!is_string($rid) || !preg_match(AER1_STRICT_UUID_RE, $rid)) {
        $failures[] = 'id is not a UUID v4 (strict profile)';
    }
    $anchor = aer1_is_obj($receipt) ? aer1_get($receipt, 'anchor') : null;
    if (!aer1_is_obj($anchor)) {
        return ['anchor is not an object'];
    }
    $wantKeys = ['anchored_at', 'leaf_hash', 'log', 'proof'];
    $gotKeys = aer1_keys($anchor);
    sort($gotKeys);
    if ($gotKeys !== $wantKeys) {
        $failures[] = 'anchor has members other than log/leaf_hash/anchored_at/proof';
    }
    $log = aer1_get($anchor, 'log');
    if (!is_string($log) || $log === '') {
        $failures[] = 'anchor.log is not a non-empty string';
    }
    $leaf = aer1_get($anchor, 'leaf_hash');
    if (!is_string($leaf) || !preg_match(AER1_HASH_RE, $leaf)) {
        $failures[] = 'anchor.leaf_hash is not sha256: + lowercase hex digest';
    } else {
        $cb = aer1_get($receipt, 'canonical_bytes');
        $raw = null;
        if (!is_string($cb) || !preg_match(AER1_B64_RE, $cb)) {
            $failures[] = 'canonical_bytes is not valid base64';
        } else {
            $raw = aer1_b64_decode($cb);
            if ($raw === null || !aer1_is_utf8($raw)) {
                $failures[] = 'canonical_bytes is not valid UTF-8 base64';
                $raw = null;
            }
        }
        if ($raw !== null) {
            $want = 'sha256:' . hash('sha256', $raw);
            if ($leaf !== $want) {
                $failures[] = 'anchor.leaf_hash does not match canonical_bytes';
            }
        }
    }
    $anchoredAt = aer1_get($anchor, 'anchored_at');
    if (!is_string($anchoredAt) || !preg_match(AER1_RFC3339_RE, $anchoredAt)) {
        $failures[] = 'anchor.anchored_at is not RFC 3339';
    } elseif (!aer1_strict_rfc3339($anchoredAt)) {
        $failures[] = 'anchor.anchored_at is not a valid calendar date/time';
    }
    if (!aer1_is_obj(aer1_get($anchor, 'proof'))) {
        $failures[] = 'anchor.proof is not an object';
    }
    return $failures;
}

// ----------------------------------------------------------------------
// Section 8.1 Merkle root (port of _merkle_root_canonical).
// ----------------------------------------------------------------------

function aer1_merkle_root(array $receiptIds): string
{
    if (!$receiptIds) {
        return hash('sha256', '');
    }
    $level = array_map(fn($r) => hash('sha256', (string) $r, true), $receiptIds);
    while (count($level) > 1) {
        if (count($level) % 2 !== 0) {
            $level[] = $level[count($level) - 1];
        }
        $next = [];
        for ($i = 0; $i < count($level); $i += 2) {
            $next[] = hash('sha256', $level[$i] . $level[$i + 1], true);
        }
        $level = $next;
    }
    return bin2hex($level[0]);
}

// ----------------------------------------------------------------------
// Section 8.2 offline workflow verification (port of verify_workflow).
// ----------------------------------------------------------------------

function aer1_verify_workflow($workflow): array
{
    if (!aer1_is_obj($workflow)) {
        return ['workflow is not a JSON object'];
    }
    $failures = [];
    if (aer1_get($workflow, 'type') !== 'verifiable-workflow-receipt') {
        $failures[] = 'workflow type is not "verifiable-workflow-receipt"';
    }
    $version = aer1_get($workflow, 'version');
    if (!is_string($version) || $version === '') {
        $failures[] = 'workflow version is not a non-empty string';
    }
    $wid = aer1_get($workflow, 'workflow_id');
    if (!is_string($wid) || !preg_match(AER1_UUID_RE, $wid)) {
        $failures[] = 'workflow workflow_id is not a lowercase UUID';
    }
    $wrid = aer1_get($workflow, 'receipt_id');
    if (!is_string($wrid) || !preg_match(AER1_UUID_RE, $wrid)) {
        $failures[] = 'workflow receipt_id is not a lowercase UUID';
    }
    $sid = aer1_get($workflow, 'session_id');
    if (!is_string($sid) || $sid === '') {
        $failures[] = 'workflow session_id is not a non-empty string';
    }
    $goal = aer1_get($workflow, 'goal');
    if (!is_string($goal) || $goal === '') {
        $failures[] = 'workflow goal is not a non-empty string';
    }
    $status = aer1_get($workflow, 'status');
    if (!is_string($status) || $status === '') {
        $failures[] = 'workflow status is not a non-empty string';
    }
    $oh = aer1_get($workflow, 'output_hash');
    if (!is_string($oh) || !preg_match(AER1_HEXDIGEST_RE, $oh)) {
        $failures[] = 'workflow output_hash is not a 64-char lowercase hex SHA-256 digest';
    }
    $vu = aer1_get($workflow, 'verify_url');
    if (!is_string($vu)
        || !(str_starts_with($vu, 'http://') || str_starts_with($vu, 'https://'))) {
        $failures[] = 'workflow verify_url is not an http(s) URL string';
    }
    $steps = aer1_get($workflow, 'steps');
    if (!aer1_is_list($steps) || !$steps) {
        $failures[] = 'workflow steps is not a non-empty list';
        return $failures;
    }
    foreach ($steps as $i => $step) {
        $n = $i + 1;
        if (!aer1_is_obj($step)) {
            $failures[] = "step $n is not an object";
            continue;
        }
        if (!aer1_is_int_like(aer1_get($step, 'seq'))) {
            $failures[] = "step $n seq is not an integer";
        }
        if (!is_string(aer1_get($step, 'receipt_id'))) {
            $failures[] = "step $n receipt_id is not a string";
        }
        $tool = aer1_get($step, 'tool');
        if (!is_string($tool) || $tool === '') {
            $failures[] = "step $n tool is not a non-empty string";
        }
        $rh = aer1_get($step, 'receipt_hash');
        if (!is_string($rh) || !preg_match(AER1_HEXDIGEST_RE, $rh)) {
            $failures[] = "step $n receipt_hash is not a 64-char lowercase hex SHA-256 digest";
        }
        $sa = aer1_get($step, 'started_at');
        if (!is_string($sa) || !aer1_strict_rfc3339($sa)) {
            $failures[] = "step $n started_at is not a valid RFC 3339 timestamp";
        }
        $ea = aer1_get($step, 'ended_at');
        if (!is_string($ea) || !aer1_strict_rfc3339($ea)) {
            $failures[] = "step $n ended_at is not a valid RFC 3339 timestamp";
        }
        $st = aer1_get($step, 'status');
        if (!is_string($st) || $st === '') {
            $failures[] = "step $n status is not a non-empty string";
        }
    }
    if ($failures) {
        return $failures;
    }
    $n = count($steps);
    foreach ($steps as $i => $step) {
        if (aer1_get($step, 'seq') != $i + 1) { // int or integer-valued float
            $failures[] = "step seq values are not 1..$n in order (index $i carries seq "
                . aer1_get($step, 'seq') . ')';
            break;
        }
    }
    $seen = [];
    foreach ($steps as $step) {
        $rid = aer1_get($step, 'receipt_id');
        if (isset($seen[$rid])) {
            $failures[] = "duplicate receipt_id: $rid";
            break;
        }
        $seen[$rid] = true;
    }
    $ids = array_map(fn($s) => aer1_get($s, 'receipt_id'), $steps);
    $root = aer1_merkle_root($ids);
    $mr = aer1_get($workflow, 'merkle_root');
    if (!is_string($mr) || $mr !== $root) {
        $failures[] = 'merkle_root does not match the recomputed Section 8.1 root';
    }
    return $failures;
}

// ----------------------------------------------------------------------
// Section 7 (-06) chain mechanics. Port of verify_chain().
// ----------------------------------------------------------------------

function aer1_verify_chain($timeline): array
{
    if (!aer1_is_list($timeline) || !$timeline) {
        return ['timeline is not a non-empty list'];
    }
    $failures = [];
    $prev = null;
    foreach ($timeline as $i => $entry) {
        if (!aer1_is_obj($entry)) {
            $failures[] = "entry $i is not an object";
            break;
        }
        $cb = aer1_get($entry, 'canonical_bytes');
        if (!is_string($cb) || !preg_match(AER1_B64_RE, $cb)) {
            $failures[] = "entry $i canonical_bytes is not valid base64";
            break;
        }
        $raw = aer1_b64_decode($cb);
        if ($raw === null) {
            $failures[] = "entry $i canonical_bytes is not valid base64";
            break;
        }
        $digest = hash('sha256', $raw);
        $pd = aer1_get($entry, 'prev_digest');
        if ($i === 0) {
            if (!is_string($pd) || $pd !== str_repeat('0', 64)) {
                $failures[] = 'entry 0 prev_digest is not 64 zero characters';
            }
        } elseif (!is_string($pd) || $pd !== $prev) {
            $failures[] = "entry $i prev_digest does not match the digest of entry " . ($i - 1);
        }
        $prev = $digest;
    }
    // The suffix-truncation kill: the chain is not closed without it.
    $last = $timeline[count($timeline) - 1];
    if (aer1_is_obj($last) && aer1_get($last, 'close') !== true) {
        $failures[] = 'last entry does not carry close: true';
    }
    return $failures;
}

// ----------------------------------------------------------------------
// Section 7.1 (-07/-10) entry digest. Port of chain_entry_digest_v07().
// Throws Aer1DigestError on bad base64 / missing members (Python raises).
// ----------------------------------------------------------------------

class Aer1DigestError extends RuntimeException {}

function aer1_chain_entry_digest_v07($entry): string
{
    if (!aer1_is_obj($entry)) {
        throw new Aer1DigestError('entry is not an object');
    }
    foreach (['canonical_bytes', 'prev_digest', 'seq', 'job_id', 'id',
              'tool', 'provenance_class'] as $m) {
        if (!aer1_has($entry, $m)) {
            throw new Aer1DigestError("missing member: $m");
        }
    }
    $raw = aer1_b64_decode(aer1_get($entry, 'canonical_bytes'));
    if ($raw === null) {
        throw new Aer1DigestError('canonical_bytes is not valid base64');
    }
    $outputHash = hash('sha256', $raw);
    $seq = aer1_get($entry, 'seq');
    if (is_float($seq) && floor($seq) === $seq) {
        $seq = (int) $seq;
    }
    $payload = [
        'prev_digest'      => aer1_get($entry, 'prev_digest'),
        'seq'              => $seq,
        'job_id'           => aer1_get($entry, 'job_id'),
        'close'            => aer1_has($entry, 'close') ? aer1_get($entry, 'close') : false,
        'id'               => aer1_get($entry, 'id'),
        'tool'             => aer1_get($entry, 'tool'),
        'provenance_class' => aer1_get($entry, 'provenance_class'),
        'output_hash'      => $outputHash,
    ];
    return hash('sha256', aer1_json_dumps($payload));
}

// ----------------------------------------------------------------------
// Section 7 (-07/-10) hardened chain mechanics. Port of verify_chain_v07().
// ----------------------------------------------------------------------

function aer1_verify_chain_v07($timeline): array
{
    if (!aer1_is_list($timeline) || !$timeline) {
        return ['timeline is not a non-empty list'];
    }
    $failures = [];
    $job = null;
    $prev = null;
    $count = count($timeline);
    foreach ($timeline as $i => $entry) {
        if (!aer1_is_obj($entry)) {
            return ["entry $i is not an object"];
        }
        $cb = aer1_get($entry, 'canonical_bytes');
        if (!is_string($cb) || !preg_match(AER1_B64_RE, $cb)) {
            return ["entry $i canonical_bytes is not valid base64"];
        }
        if (aer1_b64_decode($cb) === null) {
            return ["entry $i canonical_bytes is not valid base64"];
        }
        foreach (['id', 'tool', 'provenance_class', 'job_id', 'prev_digest'] as $m) {
            $v = aer1_get($entry, $m);
            if (!is_string($v) || $v === '') {
                return ["entry $i missing $m"];
            }
        }
        $seq = aer1_get($entry, 'seq');
        if (!aer1_is_int_like($seq)) {
            return ["entry $i seq is not an integer"];
        }
        $seq = (int) $seq;
        if ($seq !== $i + 1) {
            $failures[] = "entry $i seq $seq breaks contiguity (expected " . ($i + 1) . ')';
        }
        $jobId = aer1_get($entry, 'job_id');
        if ($job === null) {
            $job = $jobId;
        } elseif ($jobId !== $job) {
            $failures[] = "entry $i job_id mixes jobs";
        }
        $pd = aer1_get($entry, 'prev_digest');
        if ($i === 0) {
            if ($pd !== str_repeat('0', 64)) {
                $failures[] = 'entry 0 prev_digest is not 64 zero characters';
            }
        } elseif ($pd !== $prev) {
            $failures[] = "entry $i prev_digest does not match the digest of entry " . ($i - 1);
        }
        if (aer1_has($entry, 'close') && !is_bool(aer1_get($entry, 'close'))) {
            $failures[] = "entry $i close is not a boolean";
        } elseif ($i < $count - 1 && aer1_get($entry, 'close') === true) {
            $failures[] = "entry $i carries close:true before the last entry";
        }
        $prev = aer1_chain_entry_digest_v07($entry);
    }
    $last = $timeline[$count - 1];
    if (!aer1_is_obj($last) || aer1_get($last, 'close') !== true) {
        $failures[] = 'last entry does not carry close: true';
    }
    return $failures;
}

// ----------------------------------------------------------------------
// Section 7.3 (-09) external chain commitment. Port of
// verify_chain_commitment().
// ----------------------------------------------------------------------

function aer1_verify_chain_commitment($timeline, $commitment): array
{
    if (!aer1_is_list($timeline) || !$timeline) {
        return ['timeline is not a non-empty list'];
    }
    if (!aer1_is_obj($commitment)) {
        return ['commitment is not a JSON object'];
    }
    $failures = [];
    $jobId = aer1_get($commitment, 'job_id');
    if (!is_string($jobId) || $jobId === '') {
        $failures[] = 'commitment job_id is not a non-empty string';
    }
    $digest = aer1_get($commitment, 'final_entry_digest');
    if (!is_string($digest) || !preg_match(AER1_COMMIT_DIGEST_RE, $digest)) {
        $failures[] = 'commitment final_entry_digest is not 64 lowercase hex characters';
    }
    $count = aer1_get($commitment, 'entry_count');
    if (is_bool($count) || !is_int($count)) {
        $failures[] = 'commitment entry_count is not an integer';
    }
    $published = aer1_get($commitment, 'published_at');
    if (!is_string($published) || !preg_match(AER1_RFC3339_RE, $published)) {
        $failures[] = 'commitment published_at is not RFC 3339';
    } elseif (!aer1_strict_rfc3339($published)) {
        $failures[] = 'commitment published_at is not a valid calendar date/time';
    }
    if ($failures) {
        return $failures;
    }
    // Member shapes are sound; now bind them to the timeline.
    if ($count !== count($timeline)) {
        $failures[] = "commitment entry_count $count does not match the timeline length "
            . count($timeline);
    }
    foreach ($timeline as $i => $entry) {
        $eJob = aer1_is_obj($entry) ? aer1_get($entry, 'job_id') : null;
        if (!is_string($eJob) || $eJob === '') {
            $failures[] = "entry $i has no usable job_id";
        } elseif ($eJob !== $jobId) {
            $failures[] = "entry $i job_id does not match the commitment job_id";
        }
    }
    try {
        $last = $timeline[count($timeline) - 1];
        $want = aer1_chain_entry_digest_v07($last);
    } catch (Throwable $e) {
        $failures[] = 'final entry digest could not be recomputed: ' . $e->getMessage();
    }
    if (isset($want) && $digest !== $want) {
        $failures[] = 'commitment final_entry_digest does not match the recomputed '
            . 'digest of the last entry';
    }
    return $failures;
}

// ----------------------------------------------------------------------
// CLI entry point.
// ----------------------------------------------------------------------

function aer1_cli(array $argv): int
{
    $args = $argv;
    array_shift($args); // script name
    $mode = 'core';
    $path = null;
    foreach ($args as $a) {
        if ($a === '--tier=strict' || $a === '--strict' || $a === '--anchor') {
            $mode = 'strict';
        } elseif ($a === '--profile') {
            $mode = 'profile';
        } elseif ($a === '-h' || $a === '--help' || $a === 'help') {
            fwrite(STDERR, "usage: php aer1.php [--tier=strict|--profile] <receipt.json>\n");
            return 2;
        } elseif ($path === null) {
            $path = $a;
        }
    }
    if ($path === null) {
        fwrite(STDERR, "error: no receipt file given\n");
        fwrite(STDERR, "usage: php aer1.php [--tier=strict|--profile] <receipt.json>\n");
        return 2;
    }
    $text = @file_get_contents($path);
    if ($text === false) {
        fwrite(STDERR, "error: cannot read $path\n");
        return 2;
    }
    $receipt = aer1_json_decode($text);
    if ($receipt === null && json_last_error() !== JSON_ERROR_NONE) {
        fwrite(STDERR, "rejected: input is not valid JSON (" . json_last_error_msg() . ")\n");
        return 1;
    }
    $failures = aer1_check($receipt);
    if ($mode === 'profile' && !$failures) {
        $failures = aer1_check_reference_profile($receipt);
    }
    if ($mode === 'strict' && !$failures) {
        $failures = aer1_check_disinterested_tier($receipt);
    }
    if ($failures) {
        foreach ($failures as $f) {
            fwrite(STDERR, "rejected: $f\n");
        }
        return 1;
    }
    echo "verified: $path conforms to AER-1 core ($mode mode)\n";
    return 0;
}

if (PHP_SAPI === 'cli' && isset($argv[0]) && realpath($argv[0]) === __FILE__) {
    exit(aer1_cli($argv));
}
