# Finite individual-building assessment with independently frozen inputs and retained failures.
from pathlib import Path
import hashlib
import json
import time
from hearth import Scene, Box, ContractError
from hearth.kernel import SearchExhausted
from hearth.randomness import Scope
from hearth.environment import Terrain, gradient, wave
from hearth.assessment import adaptation_expression, contact_metrics
from hearth.components.building import adapted
from hearth.lodges import hillside_home, gabled_hall, high_house, lodge_scene
from hearth.persistence import export_scene, load_scene, semantic_digest, encode

CLIENTS = {'home': hillside_home, 'hall': gabled_hall, 'high': high_house}
DOMAIN = Box((-10, -4, -20), (40, 72, 40))


def fingerprint(value):
    return hashlib.sha256(json.dumps(value, sort_keys=True, default=list).encode()).hexdigest()


def principal_signature(scene):
    """Measure current architecture independently of palette, contact and garden noise.

    Args:
        scene: A validated scene with automatic component records.

    Returns:
        A digest and its auditable normalized structural measurements.
    """
    data = []
    for path, node in sorted(scene.nodes.items()):
        if node.type != 'building.rooms':
            continue
        floor = node.contract.decisions['floor']
        rows = []
        for part in scene.nodes.values():
            if not part.path.startswith(path + '/'):
                continue
            relative = node.frame.inverse(part.frame.origin)
            location = (relative[0], relative[1] - floor, relative[2])
            if part.type.startswith('room.'):
                floor_cells = scene.cells(part.path + '/floor')
                footprint = sorted((part.frame.inverse(p)[0], part.frame.inverse(p)[2]) for p in floor_cells)
                rows.append(('room', location, part.type, part.contract.decisions['doors'], part.contract.decisions['stair_up'], fingerprint(footprint)))
            elif part.type in ('roof.profile', 'roof.gable', 'roof.dormer'):
                coordinates = sorted(part.frame.inverse(p) for p in scene.cells(part.path, descendants=True))
                decisions = {k: v for k, v in part.contract.decisions.items() if k in ('axis', 'span', 'rise', 'steep', 'tops')}
                rows.append((part.type, location, decisions, fingerprint(coordinates)))
            elif part.type in ('attachment.porch', 'masonry.chimney'):
                rows.append((part.type, location, part.contract.decisions))
        data.append(sorted(rows, key=lambda v: json.dumps(v, sort_keys=True)))
    return fingerprint(data), data


def audit_pair(scene, path):
    """Verify complete structural records, actual cells, states, inventories and queries."""
    restored = load_scene(path)
    for attr in ('nodes', 'relations', 'dependencies', 'operations', 'port_uses', 'displaced', 'reverse'):
        if encode(getattr(restored, attr)) != encode(getattr(scene, attr)):
            raise AssertionError('Roundtrip record mismatch: ' + attr)
    if semantic_digest(restored) != semantic_digest(scene):
        raise AssertionError('Geometry digest mismatch')
    queries = 0
    for node in scene.nodes.values():
        cells = scene.cells(node.path)
        if cells != restored.cells(node.path):
            raise AssertionError('Current-cell query mismatch')
        if cells:
            p = min(cells)
            if scene.inspect(p) != restored.inspect(p):
                raise AssertionError('Structural query mismatch')
            queries += 1
    inventories = {p: c.nbt for p, c in scene.blocks.items() if c.nbt}
    if inventories != {p: c.nbt for p, c in restored.blocks.items() if c.nbt}:
        raise AssertionError('Inventory mismatch')
    return {'queries': queries, 'inventories': len(inventories), 'nodes': len(scene.nodes), 'digest': semantic_digest(restored)}


def evaluate(label, inputs, recipe, artifact=None):
    start = time.perf_counter()
    row = {'label': label, 'inputs': inputs, 'attempts': 1, 'accepted_candidates': 0, 'retries': 0}
    try:
        scene = recipe()
        scene.finalize()
        events = [e for e in scene.events if e['kind'] == 'candidate-selection']
        digest, architecture = principal_signature(scene)
        contact = contact_metrics(scene)
        contact['bearing_fill'] = sum(n.contract.decisions['fill'] for n in scene.nodes.values() if n.type == 'adapter.bearing-course')
        contact['fill'] += contact['bearing_fill']
        row.update(status='validated', attempts=sum(e['attempts'] for e in events) or 1, accepted_candidates=sum(e['accepted'] for e in events) if events else 1, digest=semantic_digest(scene), principal_signature=digest, architecture=architecture, contact=contact, blocks=len(scene.blocks), nodes=len(scene.nodes), candidates=events, optional_omissions=[{'component': n.path, 'dormers': n.contract.decisions['omitted_optional_dormers']} for n in scene.nodes.values() if n.contract.decisions.get('omitted_optional_dormers')])
        if artifact is not None:
            export_scene(scene, artifact)
            row.update(artifact=str(artifact), audit=audit_pair(scene, artifact))
    except (ContractError, ValueError, AssertionError) as error:
        known = {'site-relief', 'support-height', 'fill-volume', 'contact-strategy', 'approach-detour', 'substrate-depth'}
        status = 'budget-exhausted' if isinstance(error, SearchExhausted) else 'incompatible' if isinstance(error, ContractError) and error.diagnostic.rule in known else 'defect'
        row.update(status=status, diagnostic=str(error), attempts=getattr(error, 'attempts', 1), candidates=getattr(error, 'candidates', []))
    row['seconds'] = round(time.perf_counter() - start, 4)
    return row


def summary(rows):
    valid = [r for r in rows if r['status'] == 'validated']
    return {'inputs': len(rows), 'validated': len(valid), 'incompatible': sum(r['status'] == 'incompatible' for r in rows), 'defects': sum(r['status'] == 'defect' for r in rows), 'budget_exhausted': sum(r['status'] == 'budget-exhausted' for r in rows), 'candidate_attempts': sum(r['attempts'] for r in rows), 'accepted_candidates': sum(r['accepted_candidates'] for r in rows), 'seconds': round(sum(r['seconds'] for r in rows), 3), 'principal_signatures': len({r['principal_signature'] for r in valid}), 'contact_signatures': len({r['contact']['signature'] for r in valid}), 'fill_range': [min((r['contact']['fill'] for r in valid), default=0), max((r['contact']['fill'] for r in valid), default=0)]}


def run(output, artifacts, seed=0):
    """Run the recorded finite recipe once per input without success-only resampling."""
    output, artifacts = Path(output), Path(artifacts)
    output.mkdir(parents=True, exist_ok=True)
    artifacts.mkdir(parents=True, exist_ok=True)
    rows, frozen = [], {}
    specifications = []
    for index in range(16):
        es = seed * 100 + index
        field, water = adaptation_expression(es)
        environment = Scene(es, domain=DOMAIN)
        environment.place('land', Terrain(DOMAIN, field, water))
        environment.finalize()
        frozen[index] = environment
        specifications.append({'environment_seed': es, 'expression': field.expression(), 'water': water, 'digest': semantic_digest(environment), 'held_out_composition': index >= 8})
    (output / 'frozen-inputs.json').write_text(json.dumps(specifications, indent=2))

    def retain(row):
        rows.append(row)
        (output / 'results.json').write_text(json.dumps(rows, indent=2))
        print(row['label'], row['status'], flush=True)

    for name, client in CLIENTS.items():
        for bs in range(8):
            label = f'{name}-{bs}'
            artifact = artifacts / (label + '.litematic') if bs in (0, 1, 6) else None
            retain(evaluate(label, {'client': name, 'structure_seed': bs, 'environment_seed': bs + seed * 100}, lambda c=client, b=bs: lodge_scene(c(b), b, b + seed * 100), artifact))
    structures = ((hillside_home, 0), (hillside_home, 1), (gabled_hall, 2), (high_house, 3))
    for es, environment in frozen.items():
        for index, (client, bs) in enumerate(structures):
            number = es * 4 + index
            label = f'adaptation-{number:02d}'
            inputs = {**specifications[es], 'client': client.__name__, 'structure_seed': bs}

            def place(e=environment, c=client, b=bs):
                scene = e.fork(seed=b)
                scene.compose_choice(adapted('building', c(b), '/land'))
                return scene.finalize()

            artifact = artifacts / (label + '.litematic') if number in (0, 8, 35, 60) else None
            retain(evaluate(label, inputs, place, artifact))
            if semantic_digest(environment) != specifications[es]['digest']:
                raise AssertionError('Frozen environment changed')
    for index in range(16):
        es = 2000 + seed * 100 + index
        rng = Scope(es).stream('stress')
        field = (gradient(rng.choice((0, .12, .28, .45)), rng.choice((0, -.15, .2)), 4) + wave(rng.choice((0, 2, 5)), .13, .17, rng.random() * 6)).clamp(-2, 20)
        water = rng.choice((None, 5, 12))
        environment = Scene(es, domain=DOMAIN)
        environment.place('land', Terrain(DOMAIN, field, water))
        environment.finalize()
        bs = index % 8

        def stress(e=environment, b=bs):
            s = e.fork(seed=b)
            s.compose_choice(adapted('building', high_house(b), '/land'))
            return s.finalize()

        retain(evaluate(f'stress-{index:02d}', {'environment_seed': es, 'structure_seed': bs, 'expression': field.expression(), 'water': water, 'frozen_digest': semantic_digest(environment)}, stress))
    results = {'clients': summary(rows[:24]), 'adaptation': summary(rows[24:88]), 'stress': summary(rows[88:]), 'per_client': {name: summary([r for r in rows[:24] if r['inputs']['client'] == name]) for name in CLIENTS}}
    (output / 'summary.json').write_text(json.dumps(results, indent=2))
    (artifacts / 'manifest.json').write_text(json.dumps([r for r in rows if 'artifact' in r], indent=2))
    return results
