#!/usr/bin/env python3
"""txra_agenttrail.py — a second implementation of Seal1618 SPEC §3.3.

The specification's profile of the IETF Internet-Draft
draft-sharif-agent-audit-trail-01 (an autonomous software agent's
tamper-evident audit trail), implemented a second time, from the text of the
specification and the draft alone, in a different language, sharing no code
with the reference implementation (txra-verify.cjs). It exists so that the
specification is shown to be implementable from what it says, and so that a
reader who does not run Node can check a trail — or produce one — with what
a laptop already has: Python 3 and the openssl command.

    python3 txra_agenttrail.py verify  <trail.json|.jsonl> [--key <pem|jwk>] [--json]
    python3 txra_agenttrail.py vectors <agenttrail-vectors.json>
    python3 txra_agenttrail.py produce [--sign <private.pem>] [--format wrapper|array|jsonl]
    python3 txra_agenttrail.py keygen  <private.pem> <public.pem>
    python3 txra_agenttrail.py jcs     [<file.json>]        (canonical form, RFC 8785)

Exit status: 0 for VERIFIED (open sessions included); 1 for FAILED,
INCOMPLETE or UNVERIFIED; 2 for a usage error.

Dependencies: the Python standard library. Signatures (ECDSA P-256, SHA-256)
are checked and produced through the `openssl` command; where it is absent a
supplied key cannot be read and the verdict is INCOMPLETE — an inability,
stated, never a pass.

Where this file and the reference implementation disagree about a document,
that disagreement is a finding about one of them and the reason both exist.
The reference's test suite runs this file against the published conformance
vectors and against trails this file produces, in both directions.
"""

from __future__ import annotations

import base64
import datetime as _dt
import hashlib
import json
import os
import re
import secrets
import shutil
import subprocess
import sys
import tempfile
import uuid
from decimal import Decimal

SCHEMA = 'txra.agenttrail.v1'
PROFILE = 'draft-sharif-agent-audit-trail-01'

ACTION_TYPES = ('tool_call', 'tool_response', 'decision', 'delegation', 'escalation', 'error', 'lifecycle')
OUTCOMES = ('success', 'failure', 'timeout', 'denied', 'escalated')
TRUST_LEVELS = ('L0', 'L1', 'L2', 'L3', 'L4')
PHASES = ('pre_execution', 'post_execution', 'concurrent')
LIFECYCLE_EVENTS = ('session_start', 'session_end', 'pause', 'resume', 'configuration_change',
                    'key_rotation', 'trust_level_change', 'record_deleted')
ERROR_CATEGORIES = ('transport', 'authentication', 'authorization', 'validation', 'timeout', 'internal', 'external')
HEX64 = re.compile(r'^[0-9a-f]{64}$')
UUID_RE = re.compile(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', re.I)
UUID_V4_RE = re.compile(r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$', re.I)
RFC3339 = re.compile(r'^(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):(\d{2}):(\d{2})(\.\d+)?([Zz]|[+-]\d{2}:\d{2})$')
URI_SCHEME = re.compile(r'^[a-z][a-z0-9+.-]*:', re.I)
SEMVER = re.compile(r'^\d+\.\d+\.\d+')
MAX_RECORD_BYTES = 256 * 1024
B64URL = re.compile(r'^[A-Za-z0-9_-]+$')


# ── RFC 8785, JSON Canonicalization Scheme ──────────────────────────────
# JCS defines its output in terms of ECMAScript's JSON.stringify: strings
# and numbers serialise as that function serialises them, object members
# are sorted by the UTF-16 code units of their names, arrays keep their
# order, no whitespace. Python's json module is NOT that function (it
# escapes non-ASCII by default, sorts by code point, prints 5.0 as "5.0"),
# so the three pieces are written out here.

def jcs_string(s: str) -> str:
    out = ['"']
    for ch in s:
        cp = ord(ch)
        if ch == '"':
            out.append('\\"')
        elif ch == '\\':
            out.append('\\\\')
        elif ch == '\b':
            out.append('\\b')
        elif ch == '\f':
            out.append('\\f')
        elif ch == '\n':
            out.append('\\n')
        elif ch == '\r':
            out.append('\\r')
        elif ch == '\t':
            out.append('\\t')
        elif cp < 0x20 or 0xD800 <= cp <= 0xDFFF:
            # control characters, and lone surrogates (JSON.stringify's
            # well-formed output rule), as lowercase \u escapes
            out.append('\\u%04x' % cp)
        else:
            out.append(ch)
    out.append('"')
    return ''.join(out)


def jcs_number(x: float) -> str:
    """ECMAScript Number::toString for a finite double."""
    if x != x or x in (float('inf'), float('-inf')):
        raise ValueError('JCS: non-finite number')
    if x == 0:
        return '0'  # +0 and -0 both print as 0
    sign = '-' if x < 0 else ''
    t = Decimal(repr(abs(x))).as_tuple()  # repr is the shortest round-trip form, as ES6 requires
    digits = ''.join(str(d) for d in t.digits).lstrip('0')
    exp = t.exponent
    # strip trailing zeros from the digit string, moving them into the exponent
    stripped = digits.rstrip('0')
    exp += len(digits) - len(stripped)
    digits = stripped or '0'
    k = len(digits)
    n = exp + k  # decimal point position: value = 0.digits × 10^n
    if k <= n <= 21:
        body = digits + '0' * (n - k)
    elif 0 < n <= 21:
        body = digits[:n] + '.' + digits[n:]
    elif -6 < n <= 0:
        body = '0.' + '0' * (-n) + digits
    else:
        e = n - 1
        mant = digits if k == 1 else digits[0] + '.' + digits[1:]
        body = mant + 'e' + ('+' if e > 0 else '-') + str(abs(e))
    return sign + body


def jcs(value) -> str:
    if value is None:
        return 'null'
    if value is True:
        return 'true'
    if value is False:
        return 'false'
    if isinstance(value, str):
        return jcs_string(value)
    if isinstance(value, int):
        # JSON numbers are IEEE doubles to a JCS consumer; an integer beyond
        # 2^53 is what a double makes of it, not what Python keeps.
        return str(value) if abs(value) < 2 ** 53 else jcs_number(float(value))
    if isinstance(value, float):
        return jcs_number(value)
    if isinstance(value, list):
        return '[' + ','.join(jcs(v) for v in value) + ']'
    if isinstance(value, dict):
        items = sorted(value.items(), key=lambda kv: kv[0].encode('utf-16-be'))
        return '{' + ','.join(jcs_string(k) + ':' + jcs(v) for k, v in items) + '}'
    raise TypeError('JCS: unsupported value of type %s' % type(value).__name__)


def sha256_hex(b: bytes) -> str:
    return hashlib.sha256(b).hexdigest()


def record_hash(record) -> str:
    """§6.1 — hex(SHA-256(JCS(record))), over the record AS STORED (signature included)."""
    return sha256_hex(jcs(record).encode('utf-8'))


def session_hash(records) -> str:
    """§8.3 — SHA-256 over the concatenated RAW digests prev_hash(1) … prev_hash(N)."""
    h = hashlib.sha256()
    for r in records[1:]:
        h.update(bytes.fromhex(str(r.get('prev_hash'))))
    return h.hexdigest()


# ── base64url, strict ───────────────────────────────────────────────────

def b64url_decode(s: str):
    if not isinstance(s, str) or not B64URL.match(s):
        return None
    try:
        return base64.urlsafe_b64decode(s + '=' * (-len(s) % 4))
    except Exception:
        return None


def b64url_encode(b: bytes) -> str:
    return base64.urlsafe_b64encode(b).decode('ascii').rstrip('=')


# ── ECDSA P-256 through the openssl command ─────────────────────────────
# The draft's envelope is the 64-byte IEEE P1363 r‖s; openssl speaks DER.

def _der_int(n: int) -> bytes:
    b = n.to_bytes((n.bit_length() + 7) // 8 or 1, 'big')
    if b[0] & 0x80:
        b = b'\x00' + b
    return b'\x02' + bytes([len(b)]) + b


def _der_len(n: int) -> bytes:
    return bytes([n]) if n < 0x80 else b'\x81' + bytes([n])


def der_from_p1363(raw: bytes) -> bytes:
    r = int.from_bytes(raw[:32], 'big')
    s = int.from_bytes(raw[32:], 'big')
    body = _der_int(r) + _der_int(s)
    return b'\x30' + _der_len(len(body)) + body


def p1363_from_der(der: bytes) -> bytes:
    if der[0] != 0x30:
        raise ValueError('not a DER SEQUENCE')
    i = 2 if der[1] < 0x80 else 2 + (der[1] & 0x7F)
    out = b''
    for _ in range(2):
        if der[i] != 0x02:
            raise ValueError('not a DER INTEGER')
        ln = der[i + 1]
        val = der[i + 2:i + 2 + ln].lstrip(b'\x00')
        out += val.rjust(32, b'\x00')
        i += 2 + ln
    return out


SPKI_P256_PREFIX = bytes.fromhex('3059301306072a8648ce3d020106082a8648ce3d030107034200')


def public_key_pem(key) -> str:
    """The reader's key as PEM (SPKI) — from PEM text or a JWK (object or text)."""
    if isinstance(key, str):
        s = key.strip()
        if s.startswith('{'):
            key = json.loads(s)
        else:
            if '-----BEGIN' not in s:
                raise ValueError('not PEM and not a JWK')
            return s + '\n'
    if not isinstance(key, dict):
        raise ValueError('key is neither PEM text nor a JWK object')
    if key.get('kty') != 'EC' or key.get('crv') != 'P-256':
        raise ValueError('JWK is not an EC P-256 key')
    x = b64url_decode(key.get('x', ''))
    y = b64url_decode(key.get('y', ''))
    if x is None or y is None or len(x) != 32 or len(y) != 32:
        raise ValueError('JWK x/y are not 32-byte base64url coordinates')
    der = SPKI_P256_PREFIX + b'\x04' + x + y
    b64 = base64.b64encode(der).decode('ascii')
    lines = [b64[i:i + 64] for i in range(0, len(b64), 64)]
    return '-----BEGIN PUBLIC KEY-----\n' + '\n'.join(lines) + '\n-----END PUBLIC KEY-----\n'


def openssl_path():
    return shutil.which('openssl')


def key_readable(pem: str) -> bool:
    exe = openssl_path()
    if exe is None:
        return False
    with tempfile.NamedTemporaryFile('w', suffix='.pem', delete=False) as f:
        f.write(pem)
        path = f.name
    try:
        r = subprocess.run([exe, 'pkey', '-pubin', '-in', path, '-noout'], capture_output=True)
        return r.returncode == 0
    finally:
        os.unlink(path)


def verify_signature(pem: str, message: bytes, raw_sig: bytes) -> bool:
    exe = openssl_path()
    if exe is None:
        return False
    d = tempfile.mkdtemp()
    try:
        kp, sp, mp = os.path.join(d, 'k.pem'), os.path.join(d, 's.der'), os.path.join(d, 'm.bin')
        with open(kp, 'w') as f:
            f.write(pem)
        with open(sp, 'wb') as f:
            f.write(der_from_p1363(raw_sig))
        with open(mp, 'wb') as f:
            f.write(message)
        r = subprocess.run([exe, 'dgst', '-sha256', '-verify', kp, '-signature', sp, mp], capture_output=True)
        return r.returncode == 0
    finally:
        shutil.rmtree(d, ignore_errors=True)


def sign_message(private_pem_path: str, message: bytes) -> bytes:
    exe = openssl_path()
    if exe is None:
        raise RuntimeError('openssl is not available; cannot sign')
    d = tempfile.mkdtemp()
    try:
        mp, sp = os.path.join(d, 'm.bin'), os.path.join(d, 's.der')
        with open(mp, 'wb') as f:
            f.write(message)
        r = subprocess.run([exe, 'dgst', '-sha256', '-sign', private_pem_path, '-out', sp, mp], capture_output=True)
        if r.returncode != 0:
            raise RuntimeError('openssl could not sign: ' + r.stderr.decode('utf-8', 'replace').strip())
        with open(sp, 'rb') as f:
            return p1363_from_der(f.read())
    finally:
        shutil.rmtree(d, ignore_errors=True)


def keygen(private_path: str, public_path: str) -> None:
    exe = openssl_path()
    if exe is None:
        raise RuntimeError('openssl is not available; cannot generate a key')
    d = tempfile.mkdtemp()
    try:
        ec = os.path.join(d, 'ec.pem')
        subprocess.run([exe, 'ecparam', '-name', 'prime256v1', '-genkey', '-noout', '-out', ec], check=True, capture_output=True)
        subprocess.run([exe, 'pkcs8', '-topk8', '-nocrypt', '-in', ec, '-out', private_path], check=True, capture_output=True)
        subprocess.run([exe, 'ec', '-in', ec, '-pubout', '-out', public_path], check=True, capture_output=True)
    finally:
        shutil.rmtree(d, ignore_errors=True)


# ── Timestamps at the precision the document carries ───────────────────

def instant_ns(ts) :
    """Nanoseconds since the epoch for an RFC 3339 timestamp with an offset, or None."""
    if not isinstance(ts, str):
        return None
    m = RFC3339.match(ts)
    if not m:
        return None
    y, mo, d, h, mi, s = (int(m.group(i)) for i in range(1, 7))
    try:
        naive = _dt.datetime(y, mo, d, h, mi, s)
    except ValueError:
        return None
    off = m.group(8)
    if off in ('Z', 'z'):
        offset_min = 0
    else:
        sign = 1 if off[0] == '+' else -1
        offset_min = sign * (int(off[1:3]) * 60 + int(off[4:6]))
    utc = naive - _dt.timedelta(minutes=offset_min)
    seconds = (utc - _dt.datetime(1970, 1, 1)) // _dt.timedelta(seconds=1)
    frac = (m.group(7) or '.')[1:]
    frac_ns = int((frac + '000000000')[:9]) if frac else 0
    return seconds * 1_000_000_000 + frac_ns


# ── The verifier ────────────────────────────────────────────────────────

def _detail(r):
    d = r.get('action_detail')
    return d if isinstance(d, dict) else None


def _is_tombstone(r) -> bool:
    d = _detail(r)
    return r.get('action_type') == 'lifecycle' and d is not None and d.get('event') == 'record_deleted'


def _required_detail_fields(r):
    t = r.get('action_type')
    if t == 'tool_call':
        return ['tool_name', 'parameters_hash']
    if t == 'tool_response':
        return ['tool_name', 'response_hash', 'parent_call_id']
    if t == 'decision':
        return ['decision_type']
    if t == 'delegation':
        return ['delegate_agent_id', 'delegate_trust_level', 'task_description_hash']
    if t == 'escalation':
        return ['escalation_reason', 'escalation_target']
    if t == 'error':
        return ['error_code', 'error_message', 'error_category', 'recoverable']
    if t == 'lifecycle':
        return ['event', 'deletion_reason', 'deleted_at', 'original_action_type'] if _is_tombstone(r) else ['event']
    return []


def _plural(n: int, one: str, many: str) -> str:
    return one if n == 1 else many


def _first(problems, n: int) -> str:
    return '; '.join(problems[:n]) + ('; and %d more' % (len(problems) - n) if len(problems) > n else '')


def verify(doc, public_key=None) -> dict:
    checks = []
    not_checked = [
        'the truth of what the records describe — a verified chain proves this is the trail that was written, '
        'in this order, not that the agent acted well or that its action_detail is accurate',
    ]

    def add(name, ok, detail):
        checks.append({'name': name, 'ok': bool(ok), 'detail': detail})

    def fail(summary):
        return {'ok': False, 'checks': checks, 'notChecked': not_checked, 'summary': summary}

    # shape
    wrapper = None
    if isinstance(doc, list):
        records = doc
        add('schema', True, "native export — an array of records with no wrapper (the draft's own shape)")
    elif isinstance(doc, dict) and doc.get('schema') == SCHEMA:
        if not isinstance(doc.get('records'), list):
            add('schema', False, SCHEMA + ' without a records array')
            return fail('UNVERIFIED — not an agent audit trail')
        records = doc['records']
        wrapper = doc
        profile_ok = doc.get('profile') == PROFILE
        add('schema', profile_ok, ('%s · profile %s' % (SCHEMA, PROFILE)) if profile_ok
            else '%s names profile %s — this tool implements %s only' % (SCHEMA, doc.get('profile'), PROFILE))
        if not profile_ok:
            return fail('UNVERIFIED — this document names a profile revision this tool does not implement (%s). '
                        'Nothing has contradicted anything; get a verifier that implements it, or treat the trail as unchecked.'
                        % (doc.get('profile'),))
    else:
        add('schema', False, 'neither a txra.agenttrail.v1 document nor an array of audit records')
        return fail('UNVERIFIED — not an agent audit trail')
    if len(records) == 0:
        add('records', False, 'no records at all')
        return fail('UNVERIFIED — an empty trail evidences nothing (a session begins with a genesis record, §8.1)')

    # §3 — every record is a record
    structural = []
    not_v4 = 0
    for i, r in enumerate(records):
        at = 'record %d' % i
        if not isinstance(r, dict):
            structural.append(at + ': not an object')
            continue
        if len(json.dumps(r, ensure_ascii=False, separators=(',', ':')).encode('utf-8')) > MAX_RECORD_BYTES:
            structural.append(at + ': exceeds 256 KB (§3.3 — MUST be rejected)')
        rid = r.get('record_id')
        if not isinstance(rid, str) or not UUID_RE.match(rid):
            structural.append(at + ': record_id is not a UUID')
        elif not UUID_V4_RE.match(rid):
            not_v4 += 1
        if instant_ns(r.get('timestamp')) is None:
            structural.append(at + ': timestamp is not RFC 3339 with an offset')
        aid = r.get('agent_id')
        if not isinstance(aid, str) or not aid or not URI_SCHEME.match(aid):
            structural.append(at + ': agent_id is not a URI')
        if not isinstance(r.get('agent_version'), str) or not SEMVER.match(r['agent_version']):
            structural.append(at + ': agent_version is not a semantic version')
        sid = r.get('session_id')
        if not isinstance(sid, str) or not UUID_RE.match(sid):
            structural.append(at + ': session_id is not a UUID')
        if r.get('action_type') not in ACTION_TYPES:
            structural.append('%s: action_type %s is not a registered value (§7)' % (at, json.dumps(r.get('action_type'))))
        if r.get('outcome') not in OUTCOMES:
            structural.append('%s: outcome %s is not a registered value (§3.1)' % (at, json.dumps(r.get('outcome'))))
        if r.get('trust_level') not in TRUST_LEVELS:
            structural.append('%s: trust_level %s is not L0–L4' % (at, json.dumps(r.get('trust_level'))))
        if r.get('record_phase') not in PHASES:
            structural.append('%s: record_phase %s is not a permitted value' % (at, json.dumps(r.get('record_phase'))))
        if not (r.get('parent_record_id') is None or isinstance(r.get('parent_record_id'), str)):
            structural.append(at + ': parent_record_id must be a string or null')
        ph = r.get('prev_hash')
        if not (ph is None or (isinstance(ph, str) and HEX64.match(ph))):
            structural.append(at + ': prev_hash must be null or 64 lowercase hex characters')
        d = _detail(r)
        if d is None:
            structural.append(at + ': action_detail is not an object')
        else:
            if len(d) == 0:
                structural.append(at + ': action_detail is empty (§3.3 — at least one relevant field)')
            reserved = [k for k in d if k.startswith('aat_')]
            if reserved:
                structural.append('%s: action_detail uses the reserved aat_ prefix (%s)' % (at, ', '.join(reserved)))
            missing = [f for f in _required_detail_fields(r) if f not in d]
            if missing:
                structural.append('%s: %s action_detail lacks %s (§7)' % (at, r.get('action_type'), ', '.join(missing)))
            if r.get('action_type') == 'lifecycle' and 'event' in d and d['event'] not in LIFECYCLE_EVENTS:
                structural.append('%s: lifecycle event %s is not one the draft defines (§7.7, §9.3)' % (at, json.dumps(d['event'])))
            if r.get('action_type') == 'error' and 'error_category' in d and d['error_category'] not in ERROR_CATEGORIES:
                structural.append('%s: error_category %s is not one the draft defines (§7.6)' % (at, json.dumps(d['error_category'])))
            if r.get('action_type') == 'error' and 'recoverable' in d and not isinstance(d['recoverable'], bool):
                structural.append(at + ': recoverable must be a boolean (§7.6)')
        if 'nonce' in r and (not isinstance(r['nonce'], str) or not re.match(r'^[0-9a-f]{32,}$', r['nonce'])):
            structural.append(at + ': nonce must be lowercase hex of at least 32 characters (§3.2)')
        if 'signature' in r:
            sig = b64url_decode(r['signature']) if isinstance(r['signature'], str) else None
            if sig is None or len(sig) != 64:
                structural.append(at + ': signature must be Base64url of a 64-byte P1363 r‖s value (§6.2)')
        if _is_tombstone(r) and (not isinstance(r.get('tombstone_hash'), str) or not HEX64.match(r['tombstone_hash'])):
            structural.append(at + ': a tombstone MUST carry tombstone_hash (§9.3)')
    if structural:
        add('records', False, _first(structural, 4))
        return fail('FAILED — %d %s the record format (§3, §7)' % (len(structural), _plural(len(structural), 'record violates', 'records violate')))
    add('records', True, '%d records, every mandatory field present and well-formed%s' % (
        len(records),
        (' (%d record id%s not version-4 UUIDs — a §3.1 conformance deviation, not an integrity finding)'
         % (not_v4, '' if not_v4 == 1 else 's')) if not_v4 else ''))

    # §8.1 — genesis
    g = records[0]
    gd = _detail(g)
    genesis_problems = []
    if g.get('action_type') != 'lifecycle' or gd.get('event') != 'session_start':
        genesis_problems.append('first record is not lifecycle/session_start')
    if g.get('parent_record_id') is not None:
        genesis_problems.append('parent_record_id is not null')
    if g.get('prev_hash') is not None:
        genesis_problems.append('prev_hash is not null')
    if g.get('record_phase') != 'concurrent':
        genesis_problems.append('record_phase is %s, MUST be concurrent' % g.get('record_phase'))
    add('genesis', not genesis_problems,
        ('session %s opened by %s v%s at %s, trust %s, recording %s' % (
            g.get('session_id'), g.get('agent_id'), g.get('agent_version'), g.get('timestamp'), g.get('trust_level'),
            gd.get('recording_mode', 'mode unstated'))) if not genesis_problems else '; '.join(genesis_problems))

    # §6.1 / §6.3 step 2 — the chain, with §9.3 tombstones
    broken = []
    accepted_across = 0
    for i in range(1, len(records)):
        prev, cur = records[i - 1], records[i]
        if cur.get('prev_hash') == record_hash(prev):
            continue
        if _is_tombstone(prev) and cur.get('prev_hash') == prev.get('tombstone_hash'):
            accepted_across += 1
            continue
        broken.append('record %d: prev_hash does not equal hex(SHA-256(JCS(record %d)))%s' % (
            i, i - 1, ' nor the tombstone_hash it carries' if _is_tombstone(prev) else ''))
    tombstones = sum(1 for r in records if _is_tombstone(r))
    links = len(records) - 1
    add('chain', not broken,
        ('%d link%s re-derive under RFC 8785 + SHA-256%s' % (
            links, '' if links == 1 else 's',
            (' — %d record%s DELETED (tombstoned, §9.3): content destroyed, chain accepted across %s by the stored tombstone hash'
             % (tombstones, '' if tombstones == 1 else 's', 'it' if accepted_across == 1 else 'them')) if tombstones else ''))
        if not broken else _first(broken, 3))

    # §6.3 step 5 / §8.2 — linkage and identity
    link_problems = []
    seen = set()
    for i, r in enumerate(records):
        rid = str(r.get('record_id'))
        if rid in seen:
            link_problems.append('record %d: duplicate record_id %s (§3.1 — MUST be flagged)' % (i, rid))
        seen.add(rid)
        if r.get('session_id') != g.get('session_id'):
            link_problems.append("record %d: session_id differs from the genesis record's" % i)
        if i > 0 and r.get('parent_record_id') != records[i - 1].get('record_id'):
            link_problems.append("record %d: parent_record_id is not record %d's record_id" % (i, i - 1))
        if r.get('action_type') == 'tool_response':
            parent_call = str(_detail(r).get('parent_call_id'))
            earlier = next((p for p in records[:i] if p.get('record_id') == parent_call), None)
            if earlier is None or earlier.get('action_type') != 'tool_call':
                link_problems.append('record %d: tool_response.parent_call_id %s is not an earlier tool_call in this session (§7.2)' % (i, parent_call))
    add('linkage', not link_problems,
        ('every parent_record_id names the record before it; %d distinct record ids in one session' % len(records))
        if not link_problems else _first(link_problems, 3))

    # §6.3 step 4 / §3.3 — timestamps
    backdated = None
    for i in range(1, len(records)):
        a = instant_ns(records[i - 1].get('timestamp'))
        b = instant_ns(records[i].get('timestamp'))
        if b < a:
            backdated = 'record %d (%s) precedes record %d (%s)' % (i, records[i].get('timestamp'), i - 1, records[i - 1].get('timestamp'))
            break
    add('timestamps', backdated is None,
        ('monotonically non-decreasing from %s to %s' % (records[0].get('timestamp'), records[-1].get('timestamp')))
        if backdated is None else 'backdated — ' + backdated)

    # §6.3 step 6 / §4.2 — phases
    phase_problems = []
    for i, r in enumerate(records):
        must_be_pre = (r.get('action_type') == 'decision' and r.get('outcome') in ('denied', 'escalated')) \
            or (r.get('action_type') == 'delegation' and r.get('outcome') == 'denied')
        if must_be_pre and r.get('record_phase') != 'pre_execution':
            phase_problems.append('record %d: %s/%s MUST be pre_execution (§4.2), is %s' % (i, r.get('action_type'), r.get('outcome'), r.get('record_phase')))
    add('phases', not phase_problems,
        'every denied or escalated decision, and every denied delegation, was recorded before execution (§4.2)'
        if not phase_problems else _first(phase_problems, 3))

    # §6.3 step 7 — nonces
    nonces = [r['nonce'] for r in records if isinstance(r.get('nonce'), str)]
    dup_nonce = next((n for i, n in enumerate(nonces) if nonces.index(n) != i), None)
    add('nonces', dup_nonce is None,
        ('none present' if not nonces else '%d present, all distinct within the session' % len(nonces))
        if dup_nonce is None else 'nonce %s… appears more than once in the session (§13.5 replay)' % dup_nonce[:16])

    # §8.3 — close
    close_idx = next((i for i, r in enumerate(records)
                      if r.get('action_type') == 'lifecycle' and _detail(r) is not None and _detail(r).get('event') == 'session_end'), -1)
    last = records[-1]
    closed = False
    close_ok = True
    if close_idx == -1:
        close_detail = "no close record — the session is open or orphaned (§8.3); the trail's END is not established by anything inside it"
        not_checked.append('completeness of the tail — without a close record, a trail cut short after its last record is '
                           'indistinguishable from one that ended there; a session_end record carrying session_hash is what would establish the end')
    elif close_idx != len(records) - 1:
        close_ok = False
        close_detail = 'record %d closes the session but %d record(s) follow it (§8.3 — the close record is last)' % (close_idx, len(records) - 1 - close_idx)
    else:
        closed = True
        cd = _detail(last)
        expected = session_hash(records)
        stated = cd.get('session_hash')
        problems = []
        if stated != expected:
            problems.append('session_hash %s… does not equal hex(SHA-256(prev_hash(1)‖…‖prev_hash(%d))) = %s…' % (str(stated)[:16], len(records) - 1, expected[:16]))
        if last.get('record_phase') != 'post_execution':
            problems.append('close record_phase is %s, MUST be post_execution' % last.get('record_phase'))
        rc = cd.get('record_count')
        if isinstance(rc, int) and not isinstance(rc, bool) and rc != len(records):
            problems.append('record_count states %d, %d records present' % (rc, len(records)))
        close_ok = not problems
        close_detail = ('session_hash covers all %d records — a trail cut short after any of them would not re-derive it' % len(records)) if close_ok else '; '.join(problems)
    add('close', close_ok, close_detail)

    # §6.2 / §6.3 step 3 — signatures
    signed = [r for r in records if isinstance(r.get('signature'), str)]
    sig_ok = True
    key_unreadable = None
    key_loaded = False
    if not signed:
        add('signatures', True, 'none present — the records are unsigned')
    else:
        if public_key is None or public_key == '':
            not_checked.append('signatures — %d of %d records carry an ECDSA P-256 signature and no public key was supplied; '
                               "supply the agent's key (--key) to check them. Until then a signature is a claim, not a check" % (len(signed), len(records)))
        else:
            pem = None
            try:
                pem = public_key_pem(public_key)
                if not key_readable(pem):
                    raise ValueError('openssl could not read the key' if openssl_path() else 'openssl is not available to read the key')
            except Exception as e:  # noqa: BLE001 — the reason travels into the verdict
                key_unreadable = str(e)
                add('signatures', False, '%d present; the supplied public key could not be read (%s)' % (len(signed), key_unreadable))
            if key_unreadable is None:
                key_loaded = True
                bad = []
                for i, r in enumerate(records):
                    if not isinstance(r.get('signature'), str):
                        continue
                    unsigned = {k: v for k, v in r.items() if k != 'signature'}
                    if not verify_signature(pem, jcs(unsigned).encode('utf-8'), b64url_decode(r['signature'])):
                        bad.append(i)
                sig_ok = not bad
                add('signatures', sig_ok,
                    ('%d of %d records signed; every signature verifies under the supplied key (ECDSA P-256, SHA-256 over the record without its signature field)'
                     % (len(signed), len(records))) if sig_ok
                    else 'record%s %s fail%s signature verification under the supplied key' % ('' if len(bad) == 1 else 's', ', '.join(str(b) for b in bad), 's' if len(bad) == 1 else ''))
        if len(signed) != len(records):
            not_checked.append('%d of %d records carry no signature — the chain still binds them, the key does not' % (len(records) - len(signed), len(records)))

    # the wrapper's own integrity statement, re-derived
    integrity_ok = True
    if wrapper is not None and 'integrity' in wrapper:
        it = wrapper['integrity']
        derived = record_hash(last)
        problems = []
        if not isinstance(it, dict):
            problems.append('integrity is not an object')
        else:
            if it.get('terminalHash') != derived:
                problems.append('the file states terminal hash %s…, its last record derives %s…' % (str(it.get('terminalHash'))[:16], derived[:16]))
            if it.get('recordCount') != len(records) or isinstance(it.get('recordCount'), bool):
                problems.append('the file states %s records and carries %d' % (it.get('recordCount'), len(records)))
        integrity_ok = not problems
        add('integrity', integrity_ok,
            ("the file's own terminal hash and record count re-derive from its records (%s…, %d)" % (derived[:16], len(records)))
            if integrity_ok else '; '.join(problems))

    # what no trail can show from the inside
    self_recorded = gd.get('recording_mode') == 'self' or (
        'recording_mode' not in gd and all(('recording_component' not in r) or r.get('recording_component') == r.get('agent_id') for r in records))
    if self_recorded:
        not_checked.append('omissions — the agent recorded its own actions (§5.1); a record never written leaves no trace inside the trail. '
                           "A sealed pack committing this trail's terminal hash (SPEC §2.2) is what fixes it in time from the outside")
    else:
        component = gd.get('recording_component_id') or next((r.get('recording_component') for r in records if r.get('recording_component')), 'a component other than the agent')
        not_checked.append("the recorder's independence — records say they were written by %s; that it was independent is the deployment's claim, not a property of the bytes" % component)
    not_checked.append("the agent's identity — agent_id %s is a URI the producer asserts; at trust level %s nothing in this document binds it to a key%s" % (
        g.get('agent_id'), g.get('trust_level'), ' beyond the signatures checked above' if (signed and key_loaded) else ''))
    not_checked.append("the clock — every timestamp is the recorder's own; external_timestamp receipts, where present, are not related to any record "
                       'by this tool because the draft does not state what their imprint covers')

    seal_ok = not broken and not link_problems and backdated is None and not phase_problems and dup_nonce is None and close_ok and not genesis_problems
    if key_unreadable is not None and seal_ok and integrity_ok:
        n = len(signed)
        return {'ok': False, 'checks': checks, 'notChecked': not_checked,
                'summary': 'INCOMPLETE — the chain is intact, but the public key supplied could not be read (%s), so the %d signature%s present %s not checked. '
                           'An inability, not a finding: supply the key as PEM (SPKI) or JWK.' % (key_unreadable, n, '' if n == 1 else 's', 'was' if n == 1 else 'were')}
    ok = seal_ok and sig_ok and integrity_ok
    if not ok:
        failing = [c['name'] for c in checks if not c['ok']]
        return fail('FAILED — %s%s' % (', '.join(failing),
                    ". A broken link means the trail was altered after it was written, or is not the trail it claims to be — the draft's word is tampered" if broken else ''))
    tail = (' %d record%s %s tombstoned: %s content is gone and only %s place in the chain survives.' % (
        tombstones, '' if tombstones == 1 else 's', 'is' if tombstones == 1 else 'are', 'its' if tombstones == 1 else 'their', 'its' if tombstones == 1 else 'their')) if tombstones else ''
    if closed:
        summary = ('VERIFIED — %d records chain from genesis to a close record whose session_hash covers all of them; this is the trail that was written, in this order.%s'
                   ' It does not mean the agent acted well.' % (len(records), tail))
    else:
        summary = ('VERIFIED (OPEN SESSION — no close record) — %d records chain from genesis to the last record present; nothing inside the trail establishes that this is where the session ended.%s'
                   ' It does not mean the agent acted well.' % (len(records), tail))
    return {'ok': True, 'checks': checks, 'notChecked': not_checked, 'summary': summary}


# ── Reading a document: wrapper, native array, or JSONL ─────────────────

def parse_trail(text: str):
    text = text.lstrip('﻿')
    try:
        return json.loads(text)
    except json.JSONDecodeError:
        lines = [ln for ln in text.splitlines() if ln.strip()]
        if not lines:
            raise
        return [json.loads(ln) for ln in lines]


# ── The producer ────────────────────────────────────────────────────────

def _now_iso() -> str:
    return _dt.datetime.now(_dt.timezone.utc).strftime('%Y-%m-%dT%H:%M:%S.%f') + 'Z'


def produce(sign_key_path=None, fmt='wrapper', agent_id='urn:seal1618:python-producer', agent_version='0.1.0'):
    """A five-record session — genesis, tool call, tool response, decision, close — written from §3.3."""
    session_id = str(uuid.uuid4())
    records = []

    def emit(action_type, detail, outcome, phase, timestamp=None):
        prev = records[-1] if records else None
        rec = {
            'record_id': str(uuid.uuid4()),
            'timestamp': timestamp or _now_iso(),
            'agent_id': agent_id,
            'agent_version': agent_version,
            'session_id': session_id,
            'action_type': action_type,
            'action_detail': detail,
            'outcome': outcome,
            'trust_level': 'L0',
            'parent_record_id': prev['record_id'] if prev else None,
            'prev_hash': record_hash(prev) if prev else None,   # over the previous record AS STORED, signature included
            'record_phase': phase,
            'nonce': secrets.token_hex(16),
        }
        if sign_key_path:
            rec['signature'] = b64url_encode(sign_message(sign_key_path, jcs(rec).encode('utf-8')))  # over the record without its signature
        records.append(rec)
        return rec

    emit('lifecycle', {'event': 'session_start', 'recording_mode': 'self', 'producer': 'txra_agenttrail.py'}, 'success', 'concurrent')
    params = {'question': 'is the chain the draft describes implementable from the text?'}
    call = emit('tool_call', {'tool_name': 'read_specification', 'parameters_hash': sha256_hex(jcs(params).encode('utf-8'))}, 'success', 'pre_execution')
    response = {'answer': 'yes — this record is the evidence'}
    emit('tool_response', {'tool_name': 'read_specification', 'response_hash': sha256_hex(jcs(response).encode('utf-8')), 'parent_call_id': call['record_id']}, 'success', 'post_execution')
    emit('decision', {'decision_type': 'proceed', 'rationale_hash': sha256_hex(b'the specification was sufficient')}, 'success', 'post_execution')
    # the close: its session_hash covers prev_hash(1) … prev_hash(N) INCLUDING its own, so it is computed
    # from the close record's own prev_hash before the record is finished and signed.
    prev = records[-1]
    close_prev_hash = record_hash(prev)
    interim = records + [{'prev_hash': close_prev_hash}]
    emit('lifecycle', {'event': 'session_end', 'session_hash': session_hash(interim), 'record_count': len(records) + 1}, 'success', 'post_execution')
    assert records[-1]['prev_hash'] == close_prev_hash

    if fmt == 'array':
        return json.dumps(records, indent=2, ensure_ascii=False) + '\n'
    if fmt == 'jsonl':
        return ''.join(json.dumps(r, ensure_ascii=False, separators=(',', ':')) + '\n' for r in records)
    doc = {
        'schema': SCHEMA,
        'profile': PROFILE,
        'records': records,
        'integrity': {'terminalHash': record_hash(records[-1]), 'recordCount': len(records)},
        'issuer': {'platform': 'txra_agenttrail.py', 'recordedBy': None, 'generatedAt': _now_iso()},
    }
    return json.dumps(doc, indent=2, ensure_ascii=False) + '\n'


# ── Conformance vectors ─────────────────────────────────────────────────

def run_vectors(path: str) -> int:
    with open(path, encoding='utf-8') as f:
        file = json.load(f)
    failures = 0
    for v in file['vectors']:
        e = v['expect']
        r = verify(v['input']['document'], v['input'].get('publicKey'))
        ok_expected = e['verdict'] in ('VERIFIED', 'VERIFIED_OPEN')
        problems = []
        if r['ok'] != ok_expected:
            problems.append('ok=%s, expected %s' % (r['ok'], ok_expected))
        if not r['summary'].startswith(e['summaryStartsWith']):
            problems.append('summary %r does not start with %r' % (r['summary'][:80], e['summaryStartsWith']))
        failing = [c['name'] for c in r['checks'] if not c['ok']]
        if 'failingChecks' in e and failing != e['failingChecks']:
            problems.append('failing checks %s, expected %s' % (failing, e['failingChecks']))
        if 'failingChecks' not in e and ok_expected and failing:
            problems.append('unexpected failing checks %s' % failing)
        status = 'agree' if not problems else 'DISAGREE'
        failures += bool(problems)
        print('  %-8s %-34s %s%s' % (status, v['id'], e['verdict'], ('  — ' + '; '.join(problems)) if problems else ''))
    total = len(file['vectors'])
    print('\n%d of %d vectors: this implementation reaches the stated verdict, failing checks and sentence.' % (total - failures, total))
    return 1 if failures else 0


# ── CLI ─────────────────────────────────────────────────────────────────

def _read_key_arg(arg: str):
    if os.path.exists(arg):
        with open(arg, encoding='utf-8') as f:
            return f.read()
    return arg


def _print_result(r: dict, as_json: bool) -> None:
    if as_json:
        print(json.dumps(r, indent=2, ensure_ascii=False))
        return
    for c in r['checks']:
        print('  %s %-11s %s' % ('✓' if c['ok'] else '✗', c['name'], c['detail']))
    if r.get('notChecked'):
        print('\n  not checked:')
        for line in r['notChecked']:
            print('    · ' + line)
    print('\n' + r['summary'])


def main(argv) -> int:
    if len(argv) < 2 or argv[1] in ('-h', '--help'):
        print(__doc__.strip())
        return 2
    cmd = argv[1]
    if cmd == 'verify':
        if len(argv) < 3:
            print('usage: verify <file> [--key <pem|jwk>] [--json]', file=sys.stderr)
            return 2
        key = None
        as_json = '--json' in argv
        if '--key' in argv:
            i = argv.index('--key')
            if i + 1 >= len(argv):
                print('--key needs a value', file=sys.stderr)
                return 2
            key = _read_key_arg(argv[i + 1])
        with open(argv[2], encoding='utf-8') as f:
            doc = parse_trail(f.read())
        r = verify(doc, key)
        _print_result(r, as_json)
        return 0 if r['ok'] else 1
    if cmd == 'vectors':
        if len(argv) < 3:
            print('usage: vectors <agenttrail-vectors.json>', file=sys.stderr)
            return 2
        return run_vectors(argv[2])
    if cmd == 'produce':
        sign = argv[argv.index('--sign') + 1] if '--sign' in argv else None
        fmt = argv[argv.index('--format') + 1] if '--format' in argv else 'wrapper'
        if fmt not in ('wrapper', 'array', 'jsonl'):
            print('--format must be wrapper, array or jsonl', file=sys.stderr)
            return 2
        sys.stdout.write(produce(sign, fmt))
        return 0
    if cmd == 'keygen':
        if len(argv) < 4:
            print('usage: keygen <private.pem> <public.pem>', file=sys.stderr)
            return 2
        keygen(argv[2], argv[3])
        return 0
    if cmd == 'jcs':
        text = open(argv[2], encoding='utf-8').read() if len(argv) > 2 else sys.stdin.read()
        sys.stdout.write(jcs(json.loads(text.lstrip('﻿'))))
        return 0
    print('unknown command: ' + cmd, file=sys.stderr)
    return 2


if __name__ == '__main__':
    sys.exit(main(sys.argv))
