# Compose broader architectural candidates from reference-informed declarations.
from dataclasses import MISSING, fields, is_dataclass, replace
from collections import Counter
from importlib import import_module
import json
from pathlib import Path
import random

from hearthwright import DesignError, Join, Level, Roof, Volume, generate
from hearthwright.api import Choice, Span

MAX_ATTEMPTS = 16
ESSENTIAL_FUNCTIONS = {'living', 'kitchen', 'sleeping'}


def concretize(value, rng):
    """Resolve declaration domains before composing structural alternatives."""
    if isinstance(value, Choice):
        return concretize(rng.choice(value.values), rng)
    if isinstance(value, Span):
        return rng.choice(tuple(range(value.low, value.high + 1, value.step)))
    if is_dataclass(value):
        return replace(value, **{f.name: concretize(getattr(value, f.name), rng) for f in fields(value)})
    if isinstance(value, tuple):
        return tuple(concretize(v, rng) for v in value)
    return value


def vary_design(prior, rng):
    """Vary structure within bounded reference-scale and compatibility rules."""
    design = concretize(prior, rng)
    joins = Counter((v.join.to, v.join.side) for v in design.volumes if v.join)
    paired = {key for key, count in joins.items() if count > 1}
    volumes = []
    for volume in design.volumes:
        width = max(9, min(25, volume.width + rng.choice((-2, 0, 2))))
        depth = max(9, min(23, volume.depth + rng.choice((-2, 0, 2))))
        # Opposing bays on one facade must retain their central court clearance.
        for parent, side in paired:
            if volume.name == parent:
                if side in ('north', 'south'):
                    width = max(width, volume.width)
                else:
                    depth = max(depth, volume.depth)
            if volume.join and (volume.join.to, volume.join.side) == (parent, side):
                if side in ('north', 'south'):
                    width = volume.width
                else:
                    depth = volume.depth
        levels = list(volume.levels)
        maximum = 2 if volume.envelope == 'roots' else 4
        if len(levels) < maximum and rng.random() < .3:
            position = len(levels) - 1 if levels[-1].attic else len(levels)
            levels.insert(position, Level(('study', 'storage')))
        for index, level in enumerate(levels):
            layouts = ['open']
            if volume.envelope != 'roots' and len(level.rooms) >= 2:
                if width - 2 * level.inset >= 15:
                    layouts.append('cross')
                if depth - 2 * level.inset >= 15:
                    layouts.append('long')
            levels[index] = replace(level, layout=rng.choice(layouts))
        roof = volume.roof
        if roof is not None:
            pitches = (1.0, 1.5) if levels[-1].attic or roof.pitch >= 1 else (.5, 1.0)
            pitch = rng.choice(pitches)
            form = rng.choice(('gable', 'hip')) if not roof.dormers else 'gable'
            dormers = rng.choice((0, 1, 2)) if form == 'gable' and pitch >= 1 else 0
            roof = replace(roof, form=form, ridge=rng.choice(('x', 'z')), pitch=pitch, dormers=dormers)
        join = volume.join
        if join is not None and (join.to, join.side) not in paired:
            join = replace(join, align=rng.choice(('front', 'center', 'back')))
        volumes.append(replace(volume, width=width, depth=depth, levels=tuple(levels), roof=roof, join=join, circulation=rng.choice(('stairs', 'ladder')), root_spread=rng.randint(3, 6), root_crown=rng.randint(3, 7)))
    # Remove only leaf wings whose domestic program remains represented.
    if len(volumes) > 1 and rng.random() < .3:
        leaf = volumes[-1]
        required = {v.join.to for v in volumes if v.join} | {a.to for a in design.attachments}
        if design.entrance:
            required.add(design.entrance.volume)
        remaining_functions = {f for v in volumes[:-1] for level in v.levels for f in level.rooms}
        lost = {f for level in leaf.levels for f in level.rooms} & ESSENTIAL_FUNCTIONS
        if leaf.name not in required and lost <= remaining_functions:
            volumes.pop()
    # Add a real joined work wing without using absolute voxel coordinates.
    main = volumes[0]
    occupied = {v.join.side for v in volumes if v.join and v.join.to == main.name}
    occupied |= {a.side for a in design.attachments if a.to == main.name}
    entrance_side = design.entrance.side if design.entrance and design.entrance.volume == main.name else 'south'
    occupied.add(entrance_side)
    available = sorted({'east', 'west', 'north', 'south'} - occupied)
    if len(volumes) < 4 and available and rng.random() < .4:
        name = 'exploration_wing'
        while name in {v.name for v in volumes}:
            name += '_new'
        volumes.append(Volume(name, width=rng.choice((9, 11)), depth=rng.choice((9, 11, 13)), levels=(Level(rng.choice((('workshop', 'storage'), ('study', 'brewing')))),), join=Join(main.name, rng.choice(available), rng.choice(('front', 'back'))), roof=Roof(form=rng.choice(('gable', 'hip')), pitch=.5), supports=main.supports))
    site = design.site
    if site.terrain == 'slope':
        site = replace(site, rise=rng.randint(2, 7))
    if site.terrain == 'water':
        depth = rng.randint(1, 3)
        site = replace(site, water_depth=depth)
        volumes[0] = replace(volumes[0], raised=max(volumes[0].raised, depth + 1))
    attachments = tuple(replace(a, depth=rng.choice((3, 4, 5))) for a in design.attachments)
    features = tuple(replace(f, scale=rng.choice((9, 11) if f.kind == 'windmill' else (5, 7, 9))) for f in design.features)
    return replace(design, name=design.name + ' / exploration', volumes=tuple(volumes), site=site, attachments=attachments, features=features)


def declaration(value, indent=0):
    """Serialize dataclass composition to editable public Python API calls."""
    pad = ' ' * indent
    inner = ' ' * (indent + 4)
    if is_dataclass(value):
        args = []
        for field in fields(value):
            actual = getattr(value, field.name)
            if field.default is not MISSING and actual == field.default:
                continue
            if field.default_factory is not MISSING and actual == field.default_factory():
                continue
            args.append(inner + field.name + '=' + declaration(actual, indent + 4))
        if not args:
            return type(value).__name__ + '()'
        return type(value).__name__ + '(\n' + ',\n'.join(args) + ',\n' + pad + ')'
    if isinstance(value, tuple):
        if all(isinstance(v, (str, int, float, bool)) for v in value):
            return repr(value)
        return '(\n' + ',\n'.join(inner + declaration(v, indent + 4) for v in value) + ',\n' + pad + ')'
    return repr(value)


def explore(seed):
    """Select a prior and validate bounded structural proposals deterministically."""
    if type(seed) is not int or not -(2**63) <= seed < 2**63:
        raise ValueError('Seed must be a signed 64-bit integer.')
    names = json.loads(Path(__file__).with_name('exploration_priors.json').read_text())
    rng = random.Random(seed)
    name = rng.choice(names)
    prior = import_module('examples.' + name).DESIGN
    diagnostics = []
    for attempt in range(1, MAX_ATTEMPTS + 1):
        candidate = vary_design(prior, rng)
        try:
            building = generate(candidate, seed=seed)
        except ValueError as error:
            diagnostics.append(str(error).splitlines()[0])
            continue
        source = ('# Distribution explorer proposal. All geometry comes from Hearthwright.\n'
                  'from hearthwright import Design, Volume, Level, Join, Roof, Site, Materials, Attachment, Feature, Entrance, Limits\n\n'
                  'DESIGN = ' + declaration(candidate) + '\n')
        return building, source, {'prior': name, 'attempts': attempt, 'rejected_candidates': diagnostics, 'sampler': 'reference-composition-v1'}
    raise DesignError(f'No validated proposal for seed {seed} after {MAX_ATTEMPTS} candidates from {name}. '
                      f'Try another seed. Last diagnostic: {diagnostics[-1]}')
