# Reproducible corpus inputs, structural measurements and retained diagnostics.
import hashlib
import json
import time
from pathlib import Path
from hearth.randomness import Scope
from hearth.programs import PROGRAMS, building_scene, settlement_scene
from hearth import Scene, Box
from hearth.environment import Terrain
from hearth.components.building import adapted
from hearth.environment import gradient, wave, radial, constant
from hearth.kernel import ContractError, SearchExhausted
from hearth.persistence import semantic_digest, export_scene
from hearth.blocks import name_of


def adaptation_expression(environment_seed):
    rng = Scope(environment_seed).stream('frozen-environment')
    # Degenerate coefficients deliberately include exact flat ground.
    sx = rng.choice((-.045, 0, .045))
    sz = rng.choice((-.035, 0, .035))
    amplitude = rng.choice((0, .4, .8))
    expression = gradient(sx, sz, 4) + wave(amplitude, .13, .11, rng.random() * 6)
    if environment_seed >= 8:
        mask = radial(8, 9, 28, .7).transformed(dx=2, dz=-1, scale=1.2)
        expression = expression.blend(gradient(-sz, sx, 4) + wave(.6, .07, .19), mask)
    else:
        expression = expression + radial(7, 8, 22, rng.choice((0, .4, .7)))
    expression = expression.clamp(2, 7)
    water = rng.choice((None, 5, 6))
    return expression, water


def freeze_environment(seed):
    expression, water = adaptation_expression(seed)
    scene = Scene(seed, domain=Box((-8, -4, -18), (34, 64, 34)))
    scene.place('land', Terrain(scene.domain, expression, water))
    return scene.finalize()


def adapt_frozen(environment, design, building_seed):
    scene = environment.fork(seed=building_seed)
    scene.compose_choice(adapted('building', design, '/land'))
    return scene.finalize()


def principal_signature(scene):
    buildings = []
    for path, node in sorted(scene.nodes.items()):
        if node.type != 'building.rooms':
            continue
        decision = node.contract.decisions
        # Realized node topology plus occupied envelope per room, normalized to building.
        rooms = []
        roofs = []
        for child, part in sorted(scene.nodes.items()):
            if not child.startswith(path + '/'):
                continue
            if part.type.startswith('room.'):
                origin = node.frame.inverse(part.frame.origin)
                occupied = scene.cells(child, True)
                relative = [part.frame.inverse(p) for p in occupied]
                bounds = [min(p[i] for p in relative) for i in range(3)] + [max(p[i] for p in relative) for i in range(3)]
                rooms.append([origin[0], origin[1] - decision['floor'], origin[2], part.type, part.contract.decisions['doors'], part.contract.decisions['stair_up'], bounds])
            if part.type == 'roof.gable':
                roofs.append([part.contract.decisions['axis'], part.contract.decisions['span'], part.contract.decisions['steep'], len(scene.cells(child, True))])
        buildings.append({'rooms': rooms, 'roofs': roofs, 'porch': decision['porch'], 'bay': decision['bay']})
    layout = []
    for node in scene.nodes.values():
        if node.type == 'landscape.paths':
            pts = node.contract.decisions['waypoints']
            layout.append({'points': [(p[0], p[-1]) for p in pts], 'links': node.contract.decisions['links']})
    data = {'buildings': buildings, 'organization': layout}
    return hashlib.sha256(json.dumps(data, sort_keys=True, default=list).encode()).hexdigest(), data


def contact_metrics(scene):
    foundations = [n for n in scene.nodes.values() if n.type == 'adapter.foundation']
    data = [n.contract.decisions for n in foundations]
    edit_paths = {key for key, n in scene.nodes.items() if n.capability and set(n.capability.tags).intersection(('siteworks', 'path'))}
    occupied = sum(len(scene.cells(key)) for key in edit_paths)
    replacements = sum(len(scene.displaced.get(key, {})) for key in edit_paths)
    from hearth.blocks import solid, AIR
    excavated = sum(solid(old.state) and not solid(scene.blocks[p].state if p in scene.blocks else AIR) for key in edit_paths for p, old in scene.displaced.get(key, {}).items())
    return {'fill': sum(d['fill'] for d in data), 'excavation': excavated, 'sitework_occupied_cells': occupied, 'replaced_cells': replacements, 'new_sitework_cells': occupied - replacements, 'strategies': [d['strategy'] for d in data], 'signature': hashlib.sha256(json.dumps(data, sort_keys=True).encode()).hexdigest()}


def measured(label, recipe, inputs, output=None):
    start = time.perf_counter()
    result = {'label': label, 'inputs': inputs, 'attempts': 1, 'retries': 0}
    try:
        scene = recipe()
        scene.finalize()
        signature, structure = principal_signature(scene)
        choices = [e for e in scene.events if e['kind'] == 'candidate-selection']
        if choices:
            result['attempts'] = sum(e['attempts'] for e in choices)
        result.update(status='validated', blocks=len(scene.blocks), components=len(scene.nodes), digest=semantic_digest(scene), principal_signature=signature, structure=structure, contact=contact_metrics(scene), accepted_candidates=sum(e['accepted'] for e in choices) if choices else 1, candidates=choices)
        # Hash independently frozen terrain before placement using persisted field input and current unchanged bed cells.
        if output:
            export_scene(scene, output)
            result['artifact'] = str(output)
    except SearchExhausted as exc:
        result.update(status='budget-exhausted', diagnostic=str(exc), accepted_candidates=0, attempts=getattr(exc, 'attempts', 1), candidates=getattr(exc, 'candidates', []))
    except (ContractError, ValueError) as exc:
        result.update(status='incompatible' if isinstance(exc, ContractError) and exc.diagnostic.rule in ('site-relief', 'support-height', 'fill-volume', 'approach-detour') else 'defect', diagnostic=str(exc), accepted_candidates=0, attempts=getattr(exc, 'attempts', 1), candidates=getattr(exc, 'candidates', []))
    result['seconds'] = round(time.perf_counter() - start, 4)
    return result


def run_corpus(output: Path, seed=0, artifacts=False):
    output.mkdir(parents=True, exist_ok=True)
    rows = []
    specs = []
    environments = {}
    for env in range(16):
        expression, water = adaptation_expression(env + seed * 100)
        frozen = freeze_environment(env + seed * 100)
        environments[env + seed * 100] = frozen
        digest = semantic_digest(frozen)
        for build in range(4):
            specs.append({'environment_seed': env + seed * 100, 'building_seed': build, 'expression': expression.expression(), 'water': water, 'frozen_environment_digest': digest, 'environment_domain': [list(frozen.domain.lo), list(frozen.domain.hi)], 'held_out': env >= 8})
    (output / 'adaptation-inputs.json').write_text(json.dumps(specs, indent=2))
    for i, spec in enumerate(specs):
        expression, water = adaptation_expression(spec['environment_seed'])
        bs = spec['building_seed']
        recipe = lambda e=environments[spec['environment_seed']], b=bs: adapt_frozen(e, PROGRAMS['dwelling'](b), b)
        artifact = Path('samples') / f'adaptation-{i:02d}.litematic' if artifacts and i in (0, 8, 35, 60) else None
        row = measured(f'adaptation-{i:02d}', recipe, spec, artifact)
        if semantic_digest(environments[spec['environment_seed']]) != spec['frozen_environment_digest']:
            raise AssertionError('Frozen environment mutated')
        rows.append(row)
        print(row['label'], row['status'], flush=True)
    for name, program in PROGRAMS.items():
        for bs in range(8):
            artifact = Path('samples') / f'{name}-{bs}.litematic' if artifacts and bs in (0, 1, 6) else None
            row = measured(f'{name}-{bs}', lambda p=program, b=bs: building_scene(p(b), b), {'program': name, 'seed': bs}, artifact)
            rows.append(row)
            print(row['label'], row['status'], flush=True)
    for bs, count in [(s, None) for s in range(8)] + [(0, 3), (0, 6)]:
        label = f'settlement-{bs}-{count or "seeded"}'
        artifact = Path('samples') / (label + '.litematic') if artifacts and ((bs == 1 and count is None) or count in (3, 6)) else None
        row = measured(label, lambda b=bs, c=count: settlement_scene(b, c), {'program': 'settlement', 'seed': bs, 'count': count}, artifact)
        rows.append(row)
        print(label, row['status'], flush=True)
    for i in range(32):
        es = 1000 + seed * 100 + i
        rng = Scope(es).stream('stress')
        expression = (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))
        bs = i % 8
        row = measured(f'stress-{i:02d}', lambda e=expression, w=water, b=bs: building_scene(PROGRAMS['dwelling'](b), b, e, w), {'environment_seed': es, 'building_seed': bs, 'expression': expression.expression(), 'water': water})
        rows.append(row)
        print(row['label'], row['status'], flush=True)
    (output / 'results.json').write_text(json.dumps(rows, indent=2))
    summary = {}
    for name, subset in [('adaptation', rows[:64]), ('buildings', rows[64:88]), ('settlements', rows[88:98]), ('stress', rows[98:])]:
        good = [r for r in subset if r['status'] == 'validated']
        summary[name] = {'inputs': len(subset), 'validated': len(good), 'incompatible': sum(r['status'] == 'incompatible' for r in subset), 'defects': sum(r['status'] == 'defect' for r in subset), 'budget_exhausted': sum(r['status'] == 'budget-exhausted' for r in subset), 'candidate_attempts': sum(r['attempts'] for r in subset), 'accepted_candidates': sum(r['accepted_candidates'] for r in subset), 'retries': sum(r['retries'] for r in subset), 'seconds': round(sum(r['seconds'] for r in subset), 3), 'observed_principal_signatures': len({r['principal_signature'] for r in good}), 'observed_contact_signatures': len({r['contact']['signature'] for r in good}), 'fill_range': [min((r['contact']['fill'] for r in good), default=0), max((r['contact']['fill'] for r in good), default=0)]}
    (output / 'summary.json').write_text(json.dumps(summary, indent=2))
    manifest = [{'artifact': r['artifact'], 'inputs': r['inputs'], 'digest': r['digest'], 'status': r['status']} for r in rows if 'artifact' in r]
    (Path('samples') / 'replay-manifest.json').write_text(json.dumps(manifest, indent=2))
    return summary
