# Resolve relationships, constrained domains, room masks and site reservations.
from __future__ import annotations

from dataclasses import asdict, dataclass, field, replace
import hashlib
import json
import random

from .api import Choice, Design, DesignError, choose, document, domain
from .components import SITE_COMPONENTS, BUILTIN_FEATURES
from .materials import WALLS, ROOFS, WOODS, COLORS, FUNCTIONS, DIRECTIONS


@dataclass
class Rect:
    """Hold inclusive horizontal bounds, independent of schematic coordinates."""
    x0: int
    z0: int
    x1: int
    z1: int

    @property
    def width(self):
        return self.x1 - self.x0 + 1

    @property
    def depth(self):
        return self.z1 - self.z0 + 1

    @property
    def center(self):
        return ((self.x0 + self.x1) // 2, (self.z0 + self.z1) // 2)

    def inset(self, n):
        return Rect(self.x0 + n, self.z0 + n, self.x1 - n, self.z1 - n)

    def cells(self):
        return {(x, z) for x in range(self.x0, self.x1 + 1) for z in range(self.z0, self.z1 + 1)}

    def contains(self, x, z):
        return self.x0 <= x <= self.x1 and self.z0 <= z <= self.z1


@dataclass
class Storey:
    """Record a resolved floor and its buildable room program."""
    id: str
    rect: Rect
    floor: int
    height: int
    functions: tuple
    layout: str
    wall: str
    attic: bool
    cells: set = field(default_factory=set)


@dataclass
class Mass:
    """Record a connected envelope with a roof and support requirements."""
    name: str
    rect: Rect
    levels: list[Storey]
    roof: dict | None
    envelope: str
    circulation: str
    supports: str
    roots: dict
    join: dict | None = None


@dataclass
class Plan:
    """Retain sampled decisions and spatial relationships for reproduction."""
    design: Design
    seed: int
    masses: list[Mass]
    attachments: list[dict]
    features: list[dict]
    entrance: dict
    site: Rect

    def summary(self):
        masses = []
        for m in self.masses:
            v = asdict(m)
            for level in v['levels']:
                level['cells'] = sorted(level['cells'])
            masses.append(v)
        return {'schema': 1, 'seed': self.seed, 'design': document(self.design), 'masses': masses, 'attachments': [{**a, 'rect': asdict(a['rect'])} for a in self.attachments], 'features': [{**a, 'rect': asdict(a['rect'])} for a in self.features], 'entrance': self.entrance, 'site': asdict(self.site)}

    def signature(self):
        """Hash geometry and room decisions, excluding labels, materials and planting."""
        payload = []
        for m in self.masses:
            payload.append([asdict(m.rect), [(asdict(s.rect), s.floor, s.height, s.layout, s.functions, s.attic) for s in m.levels], m.roof, m.envelope, m.circulation, m.supports, m.roots if m.envelope == 'roots' else None])
        payload += [[a['kind'], asdict(a['rect']), a['floor'], a['cover']] for a in self.attachments]
        payload += [[a['kind'], asdict(a['rect'])] for a in self.features]
        payload.append([self.entrance['side'], self.entrance['floor'], self.entrance['point']])
        return hashlib.sha256(json.dumps(payload, sort_keys=True).encode()).hexdigest()


def edge_point(rect, side, bay='center'):
    """Return a facade point selected by a relative bay."""
    fraction = {'left': .3, 'center': .5, 'right': .7}[bay]
    if side in ('south', 'north'):
        return (rect.x0 + round((rect.width - 1) * fraction), rect.z1 if side == 'south' else rect.z0)
    return (rect.x1 if side == 'east' else rect.x0, rect.z0 + round((rect.depth - 1) * fraction))


def outline(rect, organic=False):
    """Chamfer organic floor corners, retaining ample domestic room area."""
    cells = rect.cells()
    if organic:
        cells = {(x, z) for x, z in cells if min(x - rect.x0, rect.x1 - x) + min(z - rect.z0, rect.z1 - z) >= 2}
    return cells


def _resolve(design: Design, seed: int, rng) -> Plan:
    """Compile a declaration into compatible, bounded spatial decisions.

    Args:
        design: Public composition.
        seed: Any signed 64-bit integer.

    Returns:
        Plan: Resolved architecture, independent of block placement.
    """
    if type(seed) is not int or not -(2**63) <= seed < 2**63:
        raise DesignError('seed must be a signed 64-bit integer')
    if not design.volumes:
        raise DesignError('a dwelling needs at least one volume')
    mat = design.materials
    for value, collection, role in ((mat.wall, WALLS, 'wall'), (mat.roof, ROOFS, 'roof'), (mat.frame, WOODS, 'frame'), (mat.trim, (*WOODS, 'sandstone'), 'trim'), (mat.floor, WOODS, 'floor'), (mat.accent, COLORS, 'accent')):
        if value not in collection:
            raise DesignError(f'unknown {role} role {value!r}')
    if design.site.terrain not in ('meadow', 'slope', 'water', 'hill'):
        raise DesignError('terrain must be meadow, slope, water or hill')
    if design.site.terrain == 'hill' and not any(v.envelope == 'roots' for v in design.volumes):
        raise DesignError('hill terrain needs a root envelope to define its mound; use slope for a framed earth-sheltered building')
    if not 3 <= design.site.margin <= 12 or not 1 <= design.site.water_depth <= 3 or not 2 <= design.site.rise <= 7:
        raise DesignError('site margin 3..12, water_depth 1..3, rise 2..7 required')
    if design.site.paving not in ('garden', 'courtyard', 'yard'):
        raise DesignError('site paving must be garden, courtyard or yard')
    if design.site.terrain == 'water' and design.site.paving != 'garden':
        raise DesignError('water sites require garden paving; use supported deck attachments for hard surfaces')
    if design.site.planting not in ('temperate', 'mediterranean', 'conifer', 'wetland', 'sparse'):
        raise DesignError('unknown planting policy')
    masses, by_name = [], {}
    for v in design.volumes:
        if isinstance(v.levels, Choice):
            selected = choose(v.levels, rng, lambda levels: isinstance(levels, tuple) and 1 <= len(levels) <= (2 if v.envelope == 'roots' else 4), f'{v.name}.levels')
            v = replace(v, levels=selected)
        if v.name in by_name:
            raise DesignError(f'duplicate volume {v.name!r}')
        if not 1 <= len(v.levels) <= 4:
            raise DesignError(f'{v.name}: provide 1..4 occupied levels')
        if v.envelope not in ('framed', 'roots') or v.supports not in ('foundation', 'posts'):
            raise DesignError(f'{v.name}: unknown envelope or supports')
        if v.envelope == 'roots' and (v.roof is not None or len(v.levels) > 2):
            raise DesignError(f'{v.name}: root envelopes need roof=None and at most two levels')
        if v.envelope != 'roots' and v.roof is None:
            raise DesignError(f'{v.name}: framed envelopes require a Roof')
        if not 0 <= v.raised <= 7:
            raise DesignError(f'{v.name}: raised must be 0..7')
        if design.site.terrain == 'water' and (v.supports != 'posts' or not v.join and v.raised < design.site.water_depth + 1):
            raise DesignError(f'{v.name}: water buildings need posts and raised >= water_depth + 1')
        max_inset = max(s.inset for s in v.levels)
        min_width = 9 + max_inset * 2
        min_depth = 9 + max_inset * 2
        stairs_required = len(v.levels) > 1 and domain(v.circulation) == ('stairs',)
        if stairs_required:
            min_depth = max(min_depth, max(s.height for s in v.levels[:-1]) + 6 + max_inset * 2)
            min_width = max(min_width, 11 + max_inset * 2)
        w = choose(v.width, rng, lambda n: type(n) is int and min_width <= n <= 25, f'{v.name}.width (minimum {min_width}, maximum 25)')
        d = choose(v.depth, rng, lambda n: type(n) is int and min_depth <= n <= 23, f'{v.name}.depth (minimum {min_depth}, maximum 23)')
        rect = Rect(0, 0, w - 1, d - 1)
        floor = 2 + v.raised
        join = None
        if v.join:
            j = v.join
            if j.to not in by_name:
                raise DesignError(f'{v.name}: join target must be an earlier volume')
            parent = by_name[j.to]
            if not 0 <= j.level < len(parent.levels):
                raise DesignError(f'{v.name}: join level does not exist')
            if v.raised:
                raise DesignError(f'{v.name}: joined floor inherits its target level; omit raised')
            target = parent.levels[j.level]
            side = choose(j.side, rng, lambda s: s in DIRECTIONS, f'{v.name}.join.side')
            align = choose(j.align, rng, lambda s: s in ('front', 'center', 'back'), f'{v.name}.join.align')
            fraction = {'front': 0, 'center': .5, 'back': 1}[align]
            p = target.rect
            inset = v.levels[0].inset
            if side in ('east', 'west'):
                z0 = p.z0 + round((p.depth - (d - 2 * inset)) * fraction) - inset
                x0 = p.x1 - inset if side == 'east' else p.x0 - w + 1 + inset
            else:
                x0 = p.x0 + round((p.width - (w - 2 * inset)) * fraction) - inset
                z0 = p.z1 - inset if side == 'south' else p.z0 - d + 1 + inset
            rect = Rect(x0, z0, x0 + w - 1, z0 + d - 1)
            floor = target.floor
            q = rect.inset(inset)
            if side in ('east', 'west'):
                point = (p.x1 if side == 'east' else p.x0, (max(p.z0, q.z0) + min(p.z1, q.z1)) // 2)
            else:
                point = ((max(p.x0, q.x0) + min(p.x1, q.x1)) // 2, p.z1 if side == 'south' else p.z0)
            join = {'to': j.to, 'level': j.level, 'side': side, 'point': point, 'floor': floor}
        elif masses:
            raise DesignError(f'{v.name}: every additional volume needs a Join')
        levels = []
        for i, s in enumerate(v.levels):
            if not 0 <= s.inset <= 3 or s.wall is not None and s.wall not in WALLS:
                raise DesignError(f'{v.name}/{i}: inset must be 0..3 and wall a material role')
            if not 5 <= s.height <= 7:
                raise DesignError(f'{v.name}/{i}: storey height must be 5..7')
            if s.attic and (i != len(v.levels) - 1 or v.roof is None):
                raise DesignError(f'{v.name}/{i}: attic must be the last level under a roof')
            if not s.rooms or any(r not in FUNCTIONS for r in s.rooms):
                raise DesignError(f'{v.name}/{i}: invalid room function')
            r = rect.inset(s.inset)
            options = ('open',) if v.envelope == 'roots' else tuple(['open'] + (['cross'] if r.width >= 15 and len(s.rooms) > 1 else []) + (['long'] if r.depth >= 15 and len(s.rooms) > 1 else []))
            layout = choose(s.layout, rng, lambda p: p in options, f'{v.name}/{i}.layout; feasible {options}')
            height = 3 if s.attic else s.height
            levels.append(Storey(f'{v.name}/{i}', r, floor, height, s.rooms, layout, s.wall or mat.wall, s.attic, outline(r, v.envelope == 'roots')))
            floor += s.height
        can_stair = all(min(a.rect.depth, b.rect.depth) >= a.height + 6 and min(a.rect.width, b.rect.width) >= 11 for a, b in zip(levels, levels[1:]))
        circulation = choose(v.circulation, rng, lambda c: c == 'ladder' or c == 'stairs' and can_stair, f'{v.name}.circulation')
        roof = None
        if v.roof:
            r = v.roof
            form = choose(r.form, rng, lambda t: t in ('gable', 'hip'), f'{v.name}.roof.form')
            ridge = choose(r.ridge, rng, lambda t: t in ('x', 'z', 'long', 'short'), f'{v.name}.roof.ridge')
            if ridge in ('long', 'short'):
                ridge = ('x' if w >= d else 'z') if ridge == 'long' else ('z' if w >= d else 'x')
            pitch = choose(r.pitch, rng, lambda n: n in (.5, 1.0, 1.5) and (not levels[-1].attic or n >= 1), f'{v.name}.roof.pitch')
            if r.eaves not in (1, 2):
                raise DesignError('roof eaves must be 1 or 2')
            length = levels[-1].rect.width if ridge == 'x' else levels[-1].rect.depth
            dormers = choose(r.dormers, rng, lambda n: type(n) is int and 0 <= n <= (2 if length >= 17 else 1) and (n == 0 or form == 'gable' and pitch >= 1), f'{v.name}.roof.dormers')
            cupola = choose(r.cupola, rng, lambda b: type(b) is bool, f'{v.name}.roof.cupola')
            roof = dict(form=form, ridge=ridge, pitch=pitch, eaves=r.eaves, dormers=dormers, cupola=cupola)
        roots = dict(spread=choose(v.root_spread, rng, lambda n: type(n) is int and 3 <= n <= 6, 'root_spread'), crown=choose(v.root_crown, rng, lambda n: type(n) is int and 3 <= n <= 7, 'root_crown'))
        mass = Mass(v.name, rect, levels, roof, v.envelope, circulation, v.supports, roots, join)
        # Shared boundary is legal; overlapping occupied interiors are not.
        for other in masses:
            for a in mass.levels:
                for b in other.levels:
                    if max(a.floor, b.floor) < min(a.floor + a.height + 1, b.floor + b.height + 1):
                        if a.rect.inset(1).cells() & b.rect.inset(1).cells():
                            raise DesignError(f'{v.name} intersects occupied space in {other.name}; change join side/alignment or insets')
        masses.append(mass)
        by_name[v.name] = mass
    entry = design.entrance
    main = by_name.get(entry.volume if entry else masses[0].name)
    if main is None or entry and not 0 <= entry.level < len(main.levels):
        raise DesignError('entrance volume/level does not exist')
    side = entry.side if entry else 'south'
    if side not in DIRECTIONS:
        raise DesignError('entrance side must be a cardinal direction')
    level = main.levels[entry.level if entry else 0]
    bay = choose(entry.bay if entry else 'center', rng, lambda b: b in ('left', 'center', 'right'), 'entrance.bay')
    entrance = dict(volume=main.name, level=entry.level if entry else 0, side=side, point=edge_point(level.rect, side, bay), floor=level.floor)
    attachments = []
    occupied = [m.rect.inset(-2) for m in masses]
    for index, a in enumerate(design.attachments):
        if a.to not in by_name or not 0 <= a.level < len(by_name[a.to].levels):
            raise DesignError('attachment target/level does not exist')
        if a.kind not in ('porch', 'deck', 'terrace', 'balcony', 'market') or a.cover not in ('open', 'pergola', 'canopy'):
            raise DesignError('invalid attachment kind or cover')
        s = by_name[a.to].levels[a.level]
        side_a = choose(a.side, rng, lambda t: t in DIRECTIONS, 'attachment.side')
        depth = choose(a.depth, rng, lambda n: type(n) is int and 3 <= n <= 7, 'attachment.depth')
        span = s.rect.width if side_a in ('north', 'south') else s.rect.depth
        width = choose(a.width, rng, lambda n: n == 'full' or type(n) is int and 5 <= n <= span, 'attachment.width')
        width = span if width == 'full' else width
        cx, cz = edge_point(s.rect, side_a)
        if side_a in ('north', 'south'):
            x0 = cx - width // 2
            z0 = cz + 1 if side_a == 'south' else cz - depth
            ar = Rect(x0, z0, x0 + width - 1, z0 + depth - 1)
        else:
            z0 = cz - width // 2
            x0 = cx + 1 if side_a == 'east' else cx - depth
            ar = Rect(x0, z0, x0 + depth - 1, z0 + width - 1)
        for m in masses:
            for l in m.levels:
                if l.floor <= s.floor < l.floor + l.height and ar.cells() & l.rect.cells():
                    raise DesignError(f'attachment {index} overlaps {m.name}; use a free facade')
        for previous in attachments:
            if abs(previous['floor'] - s.floor) < 4 and ar.cells() & previous['rect'].cells():
                raise DesignError(f'attachment {index} overlaps another outdoor floor')
        attachments.append(dict(id=f'attachment/{index}', kind=a.kind, to=a.to, level=a.level, side=side_a, rect=ar, floor=s.floor, cover=a.cover, door=(cx, cz)))
        occupied.append(ar)
    # Lay site plots beside the complete massing, never at reference-specific coordinates.
    minx = min(r.x0 for r in occupied)
    maxx = max(r.x1 for r in occupied)
    minz = min(r.z0 for r in occupied)
    maxz = max(r.z1 for r in occupied)
    features = []
    cursors = dict(east=minz, west=minz, north=minx, south=minx)
    for index, f in enumerate(design.features):
        if f.kind not in (*BUILTIN_FEATURES, *SITE_COMPONENTS):
            raise DesignError(f'unknown site component {f.kind!r}')
        side_f = choose(f.side, rng, lambda t: t in DIRECTIONS, 'feature.side')
        scale_domain = SITE_COMPONENTS[f.kind].scale if f.kind in SITE_COMPONENTS else (5, 11)
        if f.kind == 'windmill':
            scale_domain = (9, 11)
        size = choose(f.scale, rng, lambda n: type(n) is int and scale_domain[0] <= n <= scale_domain[1], f'{f.kind}.scale; feasible {scale_domain[0]}..{scale_domain[1]}')
        start = cursors[side_f]
        if side_f in ('east', 'west'):
            x0 = maxx + 4 if side_f == 'east' else minx - 3 - size
            z0 = start
        else:
            x0 = start
            z0 = maxz + 4 if side_f == 'south' else minz - 3 - size
        fr = Rect(x0, z0, x0 + size - 1, z0 + size - 1)
        for previous in features:
            if fr.inset(-1).cells() & previous['rect'].cells():
                raise DesignError(f'feature {index} overlaps {previous["id"]}; change its side or scale')
        cursors[side_f] += size + 3
        features.append(dict(id=f'feature/{index}', kind=f.kind, side=side_f, rect=fr, version=SITE_COMPONENTS[f.kind].version if f.kind in SITE_COMPONENTS else '1'))
        occupied.append(fr)
    # Match the actual terrain at the outer landing, including uphill entrances.
    dx, dz = DIRECTIONS[side]
    ex, ez = entrance['point']
    extension = max([a['rect'].depth if side in ('north', 'south') else a['rect'].width for a in attachments if a['to'] == entrance['volume'] and a['level'] == entrance['level'] and a['side'] == side] + [0])
    first_tread = extension + 1
    landing = design.site.water_depth + 1 if design.site.terrain == 'water' else 1
    minimum_length = main.roots['spread'] + 3 + (level.rect.x0 - main.rect.x0) if main.envelope == 'roots' else 0
    for _ in range(16):
        length = max(minimum_length, abs(entrance['floor'] - landing) + first_tread + 3)
        actual = 1 + min(design.site.rise, max(0, masses[0].rect.z1 - (ez + dz * length) + 2) // 2) if design.site.terrain == 'slope' else landing
        if actual == landing:
            break
        landing = actual
    else:
        raise DesignError('entrance approach cannot meet this slope; change its wall or level')
    entrance.update(first_tread=first_tread, landing_floor=landing, approach_length=length)
    occupied.append(Rect(ex + dx * length - 2, ez + dz * length - 2, ex + dx * length + 2, ez + dz * length + 2))
    extra = max([m.roots['spread'] + 3 for m in masses if m.envelope == 'roots'] + [design.site.margin])
    site = Rect(min(r.x0 for r in occupied) - extra, min(r.z0 for r in occupied) - extra, max(r.x1 for r in occupied) + extra, max(r.z1 for r in occupied) + extra)
    if max(site.width, site.depth) + 2 > design.limits.horizontal:
        raise DesignError('site exceeds horizontal resource limit; reduce components or increase Limits')
    plan = Plan(design, seed, masses, attachments, features, entrance, site)
    return plan


class _DecisionRandom(random.Random):
    """Retain finite decisions so later constraints can backtrack within declared domains."""

    def __init__(self, seed, tape):
        super().__init__(seed)
        self.tape = tape
        self.visited = []

    def pick(self, candidates):
        if len(candidates) == 1:
            return candidates[0]
        first = self.randrange(len(candidates))
        index = self.tape[len(self.visited)] if len(self.visited) < len(self.tape) else 0
        index = min(index, len(candidates) - 1)
        self.visited.append((index, len(candidates)))
        return candidates[(first + index) % len(candidates)]


def resolve(design: Design, seed: int, *, max_candidates=128) -> Plan:
    """Search bounded declared choices, retaining explicit decisions without fallback presets.

    Args:
        design: Architectural composition, with concrete values or bounded domains.
        seed: Signed 64-bit integer controlling all choice ordering.
        max_candidates: Maximum complete or partial plans examined before a diagnostic.

    Returns:
        Plan: Feasible resolved architecture with no geometric mutations to user intent.
    """
    if type(seed) is not int or not -(2**63) <= seed < 2**63:
        raise DesignError('seed must be a signed 64-bit integer')
    tape = []
    error = None
    for attempt in range(max_candidates):
        rng = _DecisionRandom(seed, tape)
        try:
            plan = _resolve(design, seed, rng)
            return plan
        except DesignError as exc:
            error = exc
            visited = rng.visited
            for i in range(len(visited) - 1, -1, -1):
                branch, count = visited[i]
                if branch + 1 < count:
                    tape = [v[0] for v in visited[:i]] + [branch + 1]
                    break
            else:
                raise DesignError(f'contradictory specification: {exc}') from exc
    raise DesignError(f'planning budget ({max_candidates} candidates) exhausted: {error}; narrow the conflicting domains')
