"""P06 freeze/evaluate workflow for bounded, locally prepared image batches.
Prepared pixels/provenance are caller supplied. Exact overlap checks and a local
exposure ledger do not prove licensing, transformation correctness or independence.
"""
import argparse
import hashlib
import importlib.util
import json
import math
from pathlib import Path

spec = importlib.util.spec_from_file_location('conv', Path(__file__).with_name('perception-convolution.py'))
c = importlib.util.module_from_spec(spec)
spec.loader.exec_module(c)
b = c.b
VERSION = 'p06-evaluation-v1'


def digest(value):
    return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(',', ':'), allow_nan=False).encode()).hexdigest()


def read(path, limit=1000000):
    with Path(path).open('rb') as stream:
        raw = stream.read(limit+1)
    if len(raw) > limit:
        raise ValueError('JSON file exceeds byte limit.')
    def reject(value):
        raise ValueError('Nonfinite JSON number: '+value)
    return json.loads(raw, parse_constant=reject)


def text(value, limit=1200):
    if not isinstance(value, str) or not value.strip() or len(value) > limit:
        raise ValueError('Missing or oversized text field.')
    return value


def fraction(value):
    if type(value) not in (int, float) or not math.isfinite(value) or not 0 < value < 1:
        raise ValueError('Expected a fraction strictly between zero and one.')
    return value


def sha(value):
    if not isinstance(value, str) or len(value) != 64 or any(v not in '0123456789abcdef' for v in value):
        raise ValueError('Expected a lowercase SHA-256 digest.')
    return value


def pixel_identity(record):
    return digest({k: record[k] for k in ('width', 'height', 'rgb')})


def freeze(candidate, known, reason, minimum_iou):
    if candidate.get('sourceHash') != b.SOURCE_HASH:
        raise ValueError('Candidate must identify the supplied training/development source.')
    threshold = fraction(candidate['threshold'])
    if candidate.get('version') == b.VERSION:
        predictor = {'kind': 'pixel', 'model': b.validate_model(candidate['model'])}
    elif candidate.get('version') == c.VERSION:
        predictor = {'kind': 'convolution', 'weights': c.validate(candidate['weights'])}
    else:
        raise ValueError('Unsupported candidate version.')
    body = {'version': VERSION, 'predictor': predictor, 'threshold': threshold,
            'minimumImageIoU': fraction(minimum_iou), 'reason': text(reason),
            'sourceHash': b.SOURCE_HASH,
            'excluded': [{'id': r['id'], 'sourceHash': r['sourceImage']['sha256'], 'pixels': pixel_identity(r)}
                         for r in known['records']],
            'preprocessing': 'RGB normalized to [-1,1]; same aspect-preserving BOX/NEAREST preparation as supplied subset; caller must verify preparation',
            'gate': 'Every source image has defined pet IoU at or above the declared floor.'}
    # Round-trip detaches mutable caller parameter arrays.
    body = json.loads(json.dumps(body, allow_nan=False))
    return {'protocol': body, 'digest': digest(body)}


def validate_protocol(value, known):
    body = value['protocol']
    if value.get('digest') != digest(body) or body.get('version') != VERSION:
        raise ValueError('Protocol content does not match its digest/version.')
    predictor = body['predictor']
    if predictor.get('kind') == 'pixel':
        candidate = {'version': b.VERSION, 'model': predictor['model']}
    elif predictor.get('kind') == 'convolution':
        candidate = {'version': c.VERSION, 'weights': predictor['weights']}
    else:
        raise ValueError('Unknown predictor.')
    expected = freeze({**candidate, 'sourceHash': body['sourceHash'], 'threshold': body['threshold']},
                      known, body['reason'], body['minimumImageIoU'])
    if value != expected:
        raise ValueError('Protocol contract/exclusions were changed.')
    return body


def validate_batch(value, protocol):
    if value.get('version') != 'p06-prepared-evaluation-v1':
        raise ValueError('Unknown prepared image format.')
    for key in ('attribution', 'license', 'source', 'preparation', 'sourceGroupingReview'):
        text(value.get(key))
    records = value.get('records')
    if not isinstance(records, list) or not 1 <= len(records) <= 16:
        raise ValueError('Use 1–16 source images.')
    ids, hashes, pixels = set(), set(), set()
    excluded = protocol['excluded']
    for r in records:
        identity, source_hash = text(r.get('id'), 160), sha(r.get('sourceHash'))
        group = text(r.get('sourceGroup'), 160)
        # One prepared image per source group: crops/augmentations cannot count as new subjects.
        if group != identity:
            raise ValueError('Use one full prepared image per source group, with id equal to sourceGroup.')
        width, height = r.get('width'), r.get('height')
        if type(width) is not int or type(height) is not int or not 1 <= width <= 48 or not 1 <= height <= 48:
            raise ValueError('Image dimensions must be integers from 1 to 48.')
        for key, length, allowed in [('rgb', 3*width*height, range(256)), ('trimap', width*height, (1, 2, 3))]:
            values = r.get(key)
            if not isinstance(values, list) or len(values) != length or any(type(v) is not int or v not in allowed for v in values):
                raise ValueError('Invalid pixel or annotation array.')
        if all(v == 3 for v in r['trimap']):
            raise ValueError('Each image needs at least one classified target.')
        image_hash = pixel_identity(r)
        if identity in ids or source_hash in hashes or image_hash in pixels:
            raise ValueError('Duplicate source identity, claimed hash or prepared pixels.')
        if any(identity == e['id'] or source_hash == e['sourceHash'] or image_hash == e['pixels'] for e in excluded):
            raise ValueError('Known training/development source overlaps evaluation.')
        ids.add(identity); hashes.add(source_hash); pixels.add(image_hash)
    return records


def evaluate(protocol, records):
    images = []
    for r in records:
        predictor = protocol['predictor']
        if predictor['kind'] == 'pixel':
            model = predictor['model']
            probability = lambda i: b.sigmoid(sum(w*x for w, x in zip(model['weights'], b.features(r, i, model['mode']))))
        else:
            probability = lambda i: c.forward(predictor['weights'], c.patch(r, i))[2]
        predictions = [int(probability(i) >= protocol['threshold']) for i in range(r['width']*r['height'])]
        metrics = b.score(r['trimap'], predictions)
        images.append({'id': r['id'], 'metrics': metrics, 'mask': predictions,
                       'passesFloor': metrics['petIoU'] is not None and metrics['petIoU'] >= protocol['minimumImageIoU']})
    return {'images': images, 'passesDeclaredGate': all(i['passesFloor'] for i in images)}


def run_evaluation(frozen, batch, known, ledger_directory, output, source_manifest=None):
    protocol = validate_protocol(frozen, known)
    records = validate_batch(batch, protocol)
    verification = {'status': 'original source files not verified'}
    if source_manifest is not None:
        prepare_spec = importlib.util.spec_from_file_location('preparation', Path(__file__).with_name('perception-prepare.py'))
        preparation = importlib.util.module_from_spec(prepare_spec)
        prepare_spec.loader.exec_module(preparation)
        verification = preparation.verify(source_manifest, batch)
    output = Path(output)
    if output.exists():
        raise ValueError('Output already exists.')
    ledger = Path(ledger_directory)
    ledger.mkdir(parents=True, exist_ok=True)
    lock = ledger/'.evaluation-lock'
    # Fail rather than race concurrent evaluations of the same local history.
    with lock.open('x'):
        pass
    try:
        previous = []
        for path in sorted(ledger.glob('exposure-*.json')):
            if len(previous) >= 256:
                raise ValueError('Ledger has reached its 256-event bound.')
            event = read(path, 32000)
            if event.get('version') != VERSION or not isinstance(event.get('sources'), list) or not 1 <= len(event['sources']) <= 16:
                raise ValueError('Invalid exposure event; do not discard history.')
            sha(event.get('protocolDigest')); sha(event.get('batchDigest'))
            for source in event['sources']:
                text(source.get('id'), 160); sha(source.get('hash')); sha(source.get('pixels'))
            previous.append(event)
        if len(previous) >= 256:
            raise ValueError('Ledger has reached its 256-event bound.')
        sources = [{'id': r['id'], 'hash': r['sourceHash'], 'pixels': pixel_identity(r)} for r in records]
        repeated = [s['id'] for s in sources if any(s['id'] == old['id'] or s['hash'] == old['hash'] or s['pixels'] == old['pixels']
                    for event in previous for old in event['sources'])]
        event = {'version': VERSION, 'protocolDigest': frozen['digest'], 'batchDigest': digest(batch), 'sources': sources}
        event_path = ledger/f'exposure-{len(previous):04d}.json'
        # Record exposure BEFORE computing or emitting outcomes; failures keep exposure.
        with event_path.open('x') as stream:
            json.dump(event, stream, allow_nan=False)
        result = {'version': VERSION, 'protocolDigest': frozen['digest'], 'batchDigest': digest(batch),
                  'localExposure': 'repeat practice' if repeated else 'first recorded use in this local ledger',
                  'repeatedSourceIds': repeated, 'sourcePreparation': verification, **evaluate(protocol, records),
                  'limitations': 'Caller-supplied pixels, hashes, source grouping and license claims are not authenticated. First local use does not establish untouched data. Near-duplicate/source and preparation review required.',
                  'independentReview': 'pending'}
        with output.open('x') as stream:
            json.dump(result, stream, indent=2, allow_nan=False)
        return result
    finally:
        lock.unlink()


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--known-data', type=Path, default=Path(__file__).with_name('subset.json'))
    sub = parser.add_subparsers(dest='action', required=True)
    freeze_parser = sub.add_parser('freeze')
    freeze_parser.add_argument('--candidate', type=Path, required=True)
    freeze_parser.add_argument('--reason', required=True)
    freeze_parser.add_argument('--minimum-iou', type=float, required=True)
    freeze_parser.add_argument('--out', type=Path, required=True)
    evaluation_parser = sub.add_parser('evaluate')
    evaluation_parser.add_argument('--source-manifest', type=Path, help='Replay original-byte preparation with Pillow 12.3.0 before scoring.')
    for name in ('protocol', 'batch', 'ledger', 'out'):
        evaluation_parser.add_argument('--'+name, type=Path, required=True)
    args = parser.parse_args()
    known = b.load_data(args.known_data)
    if args.action == 'freeze':
        result = freeze(read(args.candidate, 500000), known, args.reason, args.minimum_iou)
        with args.out.open('x') as stream:
            json.dump(result, stream, indent=2, allow_nan=False)
    else:
        run_evaluation(read(args.protocol, 32000), read(args.batch), known, args.ledger, args.out, args.source_manifest)
    print('Wrote', args.out, '— provenance and independent review remain pending.')


if __name__ == '__main__':
    main()
