"""Small, offline teaching controls. No production credentials or remote writes."""
from copy import deepcopy
from datetime import date, datetime, timezone
from decimal import Decimal, InvalidOperation
import hashlib
import json
import math
from pathlib import Path
import tempfile
import os


def normalize_unit(value, unit, dimension):
    units = {'mL': ('volume', Decimal('.001'), 'L'), 'L': ('volume', Decimal(1), 'L'),
             'W': ('power', Decimal('.001'), 'kW'), 'kW': ('power', Decimal(1), 'kW'),
             'mm': ('length', Decimal('.001'), 'm'), 'm': ('length', Decimal(1), 'm')}
    if unit not in units or units[unit][0] != dimension:
        raise ValueError('Unknown unit or incompatible dimension')
    try:
        number = Decimal(value)
    except InvalidOperation as error:
        raise ValueError('Ambiguous numeric transcription') from error
    if not number.is_finite() or number < 0:
        raise ValueError('Invalid measured quantity')
    _, factor, canonical = units[unit]
    return {'original_value': value, 'original_unit': unit,
            'normalized_value': str(number * factor), 'normalized_unit': canonical}


def constraint_diff(original, variant):
    return [key for key, value in original.items() if variant.get(key) != value]


def citation_scores(claims, labels):
    edges = {(c['id'], source) for c in claims for source in c['citations']}
    allowed = {'supported', 'partial', 'conflict', 'irrelevant', 'unknown'}
    values = [labels.get(f'{claim}:{source}', 'unknown') for claim, source in edges]
    if any(v not in allowed for v in values):
        raise ValueError('Unknown support label')
    necessary = [c for c in claims if c['necessary']]
    covered = sum(any(labels.get(f"{c['id']}:{s}") == 'supported' for s in set(c['citations'])) for c in necessary)
    # Unknowns stay in the strict denominator, but remain separately visible.
    return {'links': len(edges), 'supported': values.count('supported'),
            'unknown': values.count('unknown'),
            'strict_precision': values.count('supported') / len(edges) if edges else None,
            'necessary_claims': len(necessary), 'covered_claims': covered,
            'coverage': covered / len(necessary) if necessary else None}


def transition(state, action, request_id):
    previous = next((e for e in state['events'] if e['request_id'] == request_id), None)
    if previous:
        if previous['action'] != action or previous['version'] != state['version']:
            raise ValueError('Idempotency key reused for different work')
        return state
    expected = {'validate': ('collected', 'validated'), 'approve': ('validated', 'approved'),
                'publish': ('approved', 'published'), 'verify': ('published', 'verified')}
    if action not in expected or state['status'] != expected[action][0]:
        raise ValueError('Invalid transition')
    if action == 'publish' and state.get('approved_version') != state['version']:
        raise ValueError('Approval does not cover this version')
    if action == 'approve':
        state['approved_version'] = state['version']
    state['status'] = expected[action][1]
    state['events'].append({'request_id': request_id, 'action': action, 'version': state['version']})
    return state


def authorize_tool(tool, origin, scopes):
    return origin == 'operator' and tool == 'read_evidence' and 'read_evidence' in scopes


def public_record(record, today):
    if not record.get('public') or record.get('withdrawn') or record.get('license') != 'CC0-1.0':
        return None
    if date.fromisoformat(record['expires']) < date.fromisoformat(today):
        return None
    return {key: record[key] for key in ('id', 'claim', 'license', 'expires')}


def translation_ready(record):
    return record['version'] == record['en_source_version'] and record.get('approved') is True


def trace_gaps(events, run_id):
    found = {e['stage'] for e in events if e['run_id'] == run_id}
    return [s for s in ['fetch', 'validate', 'publish', 'verify'] if s not in found]


def agreement(a, b):
    if len(a) != len(b) or not a:
        raise ValueError('Two non-empty aligned label sequences required')
    labels = set(a + b)
    observed = sum(x == y for x, y in zip(a, b)) / len(a)
    expected = sum(a.count(x) * b.count(x) for x in labels) / len(a) ** 2
    return {'agreement': observed, 'kappa': (observed - expected) / (1 - expected) if expected < 1 else None}


def atomic_demo():
    with tempfile.TemporaryDirectory(prefix='geo-publication-demo-') as folder:
        root = Path(folder)
        for version in ['v1', 'v2']:
            release = root / version
            release.mkdir()
            (release / 'index.html').write_text(version)
            (release / 'asset.css').write_text(version)
        current = root / 'current'
        current.symlink_to('v1', target_is_directory=True)
        before = (current / 'index.html').read_text()
        pending = root / 'pending'
        pending.symlink_to('v2', target_is_directory=True)
        os.replace(pending, current)
        after = (current / 'index.html').read_text()
        # Simulate a failed post-publication probe, then switch the pointer back.
        pending.symlink_to('v1', target_is_directory=True)
        os.replace(pending, current)
        return {'before': before, 'after_switch': after, 'injected_probe_failure': True,
                'after_rollback': (current / 'index.html').read_text(),
                'scope': 'local filesystem pointer, not CDN/browser cache transaction'}


def run():
    original = {'model': 'AX-220', 'region': 'EU', 'voltage': '220 V', 'excluded': ['outdoor']}
    variants = [deepcopy(original), {**original, 'model': 'AX-110'}, {**original, 'excluded': []}]
    fact = 'AX-220 uses 220 V indoors only. Do not use outdoors.'
    distractor = 'BX-110 uses 110 V indoors. '
    contexts = {'first': fact + ' ' + distractor * 8, 'middle': distractor * 4 + fact + ' ' + distractor * 4,
                'last': distractor * 8 + fact, 'compressed_bad': 'AX-220 works everywhere.', 'compressed_good': fact}
    context = {name: {'text': text, 'first_160_chars': text[:160],
                      'model_and_restriction_in_first_160': all(t in text[:160] for t in ['AX-220', 'Do not use outdoors'])}
               for name, text in contexts.items()}
    claims = [{'id': 'c1', 'necessary': True, 'citations': ['s1', 's1']},
              {'id': 'c2', 'necessary': True, 'citations': ['s2']},
              {'id': 'c3', 'necessary': True, 'citations': ['s3']},
              {'id': 'c4', 'necessary': True, 'citations': []}]
    labels = {'c1:s1': 'supported', 'c2:s2': 'conflict', 'c3:s3': 'unknown'}
    state = {'status': 'collected', 'version': 'v1', 'approved_version': None, 'events': []}
    for action in ['validate', 'approve', 'publish', 'verify']:
        transition(state, action, f'demo-{action}')
    transition(state, 'verify', 'demo-verify')
    sample = [{'half_width': e, 'p': p, 'n_independent_approx': math.ceil(1.96**2*p*(1-p)/(e**2))}
              for p in [.1, .5] for e in [.05, .1]]
    traces = [{'run_id': 'demo-001', 'stage': s, 'artifact_version': 'synthetic-v1'} for s in ['fetch', 'validate', 'publish', 'verify']]
    record = {'id': 'f1', 'claim': 'synthetic record', 'license': 'CC0-1.0', 'public': True,
              'expires': '2026-12-31', 'withdrawn': False, 'email': 'not-a-person@example.invalid'}
    result = {'data_kind': 'synthetic-teaching', 'external_ai_tested': False,
              'independent_human_review': False, 'generated_at': datetime.now(timezone.utc).isoformat(),
              'context': {'method': 'literal first-160-character budget, not LLM inference or token measurement', 'variants': context},
              'query_constraints': [{'variant': v, 'changed_fields': constraint_diff(original, v)} for v in variants],
              'citation_metrics': {'claims': claims, 'author_labels': labels, 'scores': citation_scores(claims, labels)},
              'sampling': {'method': 'normal-approximation planning only; not a power analysis', 'rows': sample,
                           'design_effect_example': {'m': 3, 'rho': .5, 'design_effect': 2}},
              'annotation_demo': {'kind': 'synthetic disagreement, not actual independent raters',
                                  'a': ['v','v','c','c','s','s'], 'b': ['v','v','c','s','s','s'],
                                  'scores': agreement(['v','v','c','c','s','s'], ['v','v','c','s','s','s'])},
              'workflow': state,
              'security': [{'tool': t, 'origin': o, 'allowed': authorize_tool(t,o,{'read_evidence'})}
                           for t,o in [('read_evidence','operator'),('read_evidence','document'),('publish','document'),('publish','operator')]],
              'publication': atomic_demo(),
              'privacy': {'public': public_record(record,'2026-10-02'),
                          'withdrawn': public_record({**record,'withdrawn':True},'2026-10-02'),
                          'expired': public_record(record,'2027-01-01')},
              'translation': {'current': translation_ready({'version':2,'en_source_version':2,'approved':True}),
                              'stale': translation_ready({'version':2,'en_source_version':1,'approved':True})},
              'traces': {'events': traces, 'complete_gaps': trace_gaps(traces,'demo-001'),
                         'missing_verification': trace_gaps(traces[:-1],'demo-001')},
              'units': [normalize_unit('500','mL','volume'),normalize_unit('1500','W','power')]}
    target = Path(__file__).with_name('results.json')
    target.write_text(json.dumps(result, ensure_ascii=False, indent=2) + '\n', encoding='utf8')
    print(json.dumps({'result': str(target), 'external_ai_tested':False, 'sections':len(result)}))


if __name__ == '__main__': run()
