#!/usr/bin/env python3
"""Audit normalized Agent traces. No LLM, network or external dependencies.

IMPORTANT: authorized/verified must be populated by trusted external adapters,
not by model-generated text. This tool audits structure, not real authorization.
"""
import argparse
import json
from pathlib import Path
import unittest


def audit(events, max_actions=20):
    errors = []
    actions = {}
    observed = set()
    effect_keys = set()
    finished = False
    prev_seq = -1
    run_ids = set()
    for e in events:
        run_ids.add(e.get('run_id'))
        seq = e.get('seq')
        if not isinstance(seq, int) or seq <= prev_seq:
            errors.append('sequence_not_increasing')
        else:
            prev_seq = seq
        typ = e.get('event')
        aid = e.get('action_id')
        if typ == 'action':
            if finished:
                errors.append('action_after_finish')
            if not isinstance(aid, str) or not aid:
                errors.append('missing_action_id')
                continue
            if aid in actions:
                errors.append('duplicate_action_id:' + aid)
            actions[aid] = e
            if e.get('authorized') is not True:
                errors.append('authorization_not_confirmed:' + aid)
            if e.get('side_effect'):
                key = e.get('idempotency_key')
                if not key:
                    errors.append('missing_idempotency_key:' + aid)
                elif key in effect_keys:
                    errors.append('duplicate_side_effect_key:' + key)
                else:
                    effect_keys.add(key)
        elif typ == 'observation':
            if aid not in actions:
                errors.append('orphan_observation:' + str(aid))
            if aid in observed:
                errors.append('duplicate_observation:' + str(aid))
            observed.add(aid)
        elif typ == 'finish':
            if finished:
                errors.append('duplicate_finish')
            finished = True
            if e.get('status') == 'completed' and e.get('verified') is not True:
                errors.append('completion_not_verified')
        else:
            errors.append('unknown_event:' + str(typ))
    if len(run_ids) != 1 or None in run_ids:
        errors.append('invalid_run_ids')
    if len(actions) > max_actions:
        errors.append('action_budget_exceeded')
    for aid in actions:
        if aid not in observed:
            errors.append('missing_observation:' + aid)
    if not finished:
        errors.append('missing_finish')
    return {'passed': not errors, 'errors': errors,
            'actions': len(actions), 'observations': len(observed)}


class AuditTests(unittest.TestCase):
    def setUp(self):
        self.good = [
            {'run_id': 'r1', 'seq': 1, 'event': 'action', 'action_id': 'a1', 'authorized': True, 'side_effect': False},
            {'run_id': 'r1', 'seq': 2, 'event': 'observation', 'action_id': 'a1', 'ok': True},
            {'run_id': 'r1', 'seq': 3, 'event': 'finish', 'status': 'completed', 'verified': True},
        ]
    def test_valid(self): self.assertTrue(audit(self.good)['passed'])
    def test_unverified(self):
        x = [dict(e) for e in self.good]; x[-1]['verified'] = False
        self.assertIn('completion_not_verified', audit(x)['errors'])
    def test_unauthorized(self):
        x = [dict(e) for e in self.good]; x[0]['authorized'] = False
        self.assertIn('authorization_not_confirmed:a1', audit(x)['errors'])
    def test_orphan(self):
        x = [dict(e) for e in self.good]; x[1]['action_id'] = 'missing'
        self.assertIn('orphan_observation:missing', audit(x)['errors'])
    def test_budget(self):
        self.assertIn('action_budget_exceeded', audit(self.good, max_actions=0)['errors'])
    def test_missing_observation(self):
        self.assertIn('missing_observation:a1', audit([self.good[0], self.good[2]])['errors'])
    def test_duplicate_effect(self):
        x = [
            {'run_id':'r1','seq':1,'event':'action','action_id':'a1','authorized':True,'side_effect':True,'idempotency_key':'k'},
            {'run_id':'r1','seq':2,'event':'observation','action_id':'a1'},
            {'run_id':'r1','seq':3,'event':'action','action_id':'a2','authorized':True,'side_effect':True,'idempotency_key':'k'},
            {'run_id':'r1','seq':4,'event':'observation','action_id':'a2'},
            {'run_id':'r1','seq':5,'event':'finish','status':'failed','verified':False},
        ]
        self.assertIn('duplicate_side_effect_key:k', audit(x)['errors'])
    def test_sequence(self):
        x = [dict(e) for e in self.good]; x[1]['seq'] = 1
        self.assertIn('sequence_not_increasing', audit(x)['errors'])
    def test_multiple_runs(self):
        x = [dict(e) for e in self.good]; x[1]['run_id'] = 'r2'
        self.assertIn('invalid_run_ids', audit(x)['errors'])
    def test_missing_finish(self):
        self.assertIn('missing_finish', audit(self.good[:-1])['errors'])


if __name__ == '__main__':
    p = argparse.ArgumentParser()
    p.add_argument('trace', nargs='?', help='normalized JSONL trace')
    p.add_argument('--max-actions', type=int, default=20)
    p.add_argument('--test', action='store_true')
    args = p.parse_args()
    if args.test:
        unittest.main(argv=['audit'], verbosity=2)
    elif args.trace:
        rows = [json.loads(s) for s in Path(args.trace).read_text(encoding='utf-8').splitlines() if s.strip()]
        print(json.dumps(audit(rows, args.max_actions), indent=2, ensure_ascii=False))
    else:
        p.error('provide a JSONL trace or --test')
