# Compile a resolved architectural plan into furnished, reserved block geometry.
from __future__ import annotations

from collections import deque
from dataclasses import asdict
import hashlib
import json
from pathlib import Path

from .api import Design, DesignError
from .blocks import Grid, is_air, passable, name, full_support, split_state
from .materials import WALLS, DIRECTIONS, OPPOSITE, log
from .plan import resolve, edge_point
from . import site, roofs
from .navigation import flood, path_to, walkable


class Compiler:
    """Share construction state and spatial reservations across reusable components."""

    def __init__(self, plan):
        self.plan = plan
        self.g = Grid(plan.seed)
        self.ground = {}
        self.interior = set()
        self.inner = {}
        self.rooms = []
        self.start = None
        self.roof_maps = {}
        for mass in plan.masses:
            for s in mass.levels:
                self.inner[s.id] = {(x, z) for x, z in s.cells if all((x + dx, z + dz) in s.cells for dx, dz in DIRECTIONS.values())}

    def opening_route(self, p, side, owner):
        x, y, z = p
        dx, dz = DIRECTIONS[side]
        points = [(x + dx * d, y, z + dz * d) for d in (-1, 0, 1)]
        self.g.route(points, owner)

    def construct(self):
        """Run geometry passes in dependency order, reserving circulation before furniture."""
        site.terrain(self)
        for m in self.plan.masses:
            if m.envelope == 'roots':
                roofs.root_envelope(self, m)
        for m in self.plan.masses:
            self.envelope(m)
        roofs.build_roofs(self)
        for m in self.plan.masses:
            self.partitions(m)
            self.circulation(m)
        self.openings()
        for a in self.plan.attachments:
            site.attachment(self, a)
        site.approach(self)
        for f in self.plan.features:
            site.feature(self, f)
        site.connect_features(self)
        site.approach_lighting(self)
        self.reserve_rooms()
        structural_geometry = self.geometry_signature()
        from .furnish import furnish
        furnish(self)
        site.planting(self)
        self.connect_fences()
        self.g.metadata['interior'] = sorted(self.interior)
        self.g.metadata['resolved'] = self.plan.summary()
        self.g.metadata['structural_signature'] = self.plan.signature()
        self.g.metadata['geometry_signature'] = structural_geometry
        self.g.metadata['limits'] = asdict(self.plan.design.limits)
        self.g.metadata['seed'] = self.plan.seed
        self.g.metadata['name'] = self.plan.design.name
        self.check_limits()
        return self.g

    def envelope(self, mass):
        """Excavate, frame and seal arbitrary stacked footprints on real foundations."""
        g, mat = self.g, self.plan.design.materials
        base = mass.levels[0]
        if mass.supports == 'foundation':
            for x, z in sorted(base.cells):
                g.box((x, 1, z, x, base.floor - 1, z), 'stone_bricks')
        else:
            r = base.rect
            posts = {(r.x0, r.z0), (r.x1, r.z0), (r.x0, r.z1), (r.x1, r.z1)}
            for x in range(r.x0, r.x1 + 1, 5):
                posts |= {(x, r.z0), (x, r.z1)}
            for z in range(r.z0, r.z1 + 1, 5):
                posts |= {(r.x0, z), (r.x1, z)}
            for x, z in sorted(posts):
                site.column(self, x, z, base.floor - 1, mass.name)
            for z in (r.z0, r.z1):
                g.box((r.x0, base.floor - 1, z, r.x1, base.floor - 1, z), log(mat.frame), axis='x')
        for i, s in enumerate(mass.levels):
            inner = self.inner[s.id]
            for x, z in sorted(s.cells):
                g.put(x, s.floor, z, mat.floor + '_planks' if (x, z) in inner else 'stone_bricks' if i == 0 else log(mat.frame))
                if (x, z) in inner:
                    g.box((x, s.floor + 1, z, x, s.floor + s.height - 1, z), 'air')
                    self.interior.update((x, y, z) for y in range(s.floor + 1, s.floor + s.height))
                else:
                    for y in range(s.floor + 1, s.floor + s.height):
                        # Root envelopes use a continuous irregular stone lining.
                        frame = mass.envelope == 'framed' and ((x in (s.rect.x0, s.rect.x1) and (z - s.rect.z0) % 5 == 0) or (z in (s.rect.z0, s.rect.z1) and (x - s.rect.x0) % 5 == 0))
                        g.put(x, y, z, log(mat.frame) if frame else g.rng.choice(WALLS[s.wall]), **({'axis': 'y'} if frame else {}))
                if not s.attic:
                    ceiling = s.floor + s.height
                    if mass.envelope == 'roots' and i == len(mass.levels) - 1:
                        depth = min(x - s.rect.x0, s.rect.x1 - x, z - s.rect.z0, s.rect.z1 - z)
                        # A dark organic cap buries the ceiling into the trunk shoulder.
                        cap = ceiling + 1 + min(3, depth)
                        for y in range(ceiling, cap):
                            if is_air(g.get(x, y, z)) or y == ceiling:
                                g.put(x, y, z, 'dark_oak_log', axis='y')
                        if depth < 2 and (x + 2 * z) % 5 <= 1 and is_air(g.get(x, cap, z)):
                            g.put(x, cap, z, 'moss_block')
                    else:
                        g.put(x, ceiling, z, mat.floor + '_planks')
            # Full perimeter sill and top beam make the frame readable from every side.
            if mass.envelope == 'framed':
                for x, z in sorted(s.cells - inner):
                    g.put(x, s.floor + s.height - 1, z, log(mat.frame), axis='x' if z in (s.rect.z0, s.rect.z1) else 'z')
            if i and s.cells - mass.levels[i - 1].cells:
                below = mass.levels[i - 1]
                # An upper overhang is carried by full posts and high knee braces.
                r = s.rect
                points = {(r.x0, r.z0), (r.x1, r.z0), (r.x0, r.z1), (r.x1, r.z1), (r.x0 + r.width // 3, r.z0), (r.x0 + r.width // 3, r.z1), (r.x1 - r.width // 3, r.z0), (r.x1 - r.width // 3, r.z1)}
                points.update((x, z) for x in (r.x0, r.x1) for z in (r.z0 + r.depth // 3, r.z1 - r.depth // 3))
                for x, z in sorted(points):
                    if (x, z) in below.cells:
                        continue
                    site.column(self, x, z, s.floor - 1, s.id)
                    for dx, dz in DIRECTIONS.values():
                        if (x + dx, z + dz) in s.cells and (x + dx, z + dz) not in below.cells:
                            g.stair(x + dx, s.floor - 1, z + dz, mat.floor, next(k for k, delta in DIRECTIONS.items() if delta == (-dx, -dz)), 'top')
                            g.stair(x + dx, s.floor - 2, z + dz, mat.floor, next(k for k, delta in DIRECTIONS.items() if delta == (-dx, -dz)), 'top')
                for z in (r.z0, r.z1):
                    g.box((r.x0, s.floor - 1, z, r.x1, s.floor - 1, z), log(mat.frame), axis='x')
                for x in (r.x0, r.x1):
                    g.box((x, s.floor - 1, r.z0, x, s.floor - 1, r.z1), log(mat.frame), axis='z')

    def partitions(self, mass):
        """Subdivide selected levels into functional rooms with real door connections."""
        g = self.g
        for s in mass.levels:
            inner = self.inner[s.id]
            groups = [inner]
            programs = [s.functions]
            if s.layout != 'open':
                axis = 0 if s.layout == 'cross' else 1
                middle = s.rect.center[axis]
                wall = {(x, z) for x, z in inner if (x, z)[axis] == middle}
                for x, z in sorted(wall):
                    ceiling = self.roof_maps.get(mass.name, {}).get((x, z), {}).get('height', s.floor + s.height) if s.attic else s.floor + s.height
                    g.box((x, s.floor + 1, z, x, ceiling - 1, z), 'smooth_sandstone' if s.wall in ('plaster', 'white') else 'oak_planks')
                cx, cz = s.rect.center
                g.door(cx, s.floor, cz, 'east' if axis == 0 else 'south', s.id)
                self.opening_route((cx, s.floor + 1, cz), 'east' if axis == 0 else 'south', s.id)
                groups = [{p for p in inner if p[axis] < middle}, {p for p in inner if p[axis] > middle}]
                split = (len(s.functions) + 1) // 2
                programs = [s.functions[:split], s.functions[split:]]
            for index, (cells, functions) in enumerate(zip(groups, programs)):
                self.rooms.append({'id': f'{s.id}/room{index}', 'level': s.id, 'floor': s.floor, 'height': s.height, 'cells': cells, 'functions': functions})

    def circulation(self, mass):
        """Build complete stairwells or backed ladders between consecutive floor masks."""
        g = self.g
        for flight, (low, high) in enumerate(zip(mass.levels, mass.levels[1:])):
            common = self.inner[low.id] & self.inner[high.id]
            owner = f'{low.id}->{high.id}'
            delta = high.floor - low.floor
            protected = set()
            openings = [m.join for m in self.plan.masses if m.join]
            openings.append(self.plan.entrance)
            openings += [{'point': a['door'], 'floor': a['floor'], 'side': a['side']} for a in self.plan.attachments]
            for opening in openings:
                if opening['floor'] not in (low.floor, high.floor):
                    continue
                px, pz = opening['point']
                dx, dz = DIRECTIONS[opening['side']]
                protected.update((px + dx * d, pz + dz * d) for d in (-1, 0, 1))
            if mass.circulation == 'ladder':
                candidates = [(x, z) for x, z in sorted(common) if (x, z) not in protected and all((x, yy, z) in self.interior for yy in (low.floor + 1, high.floor + 1)) and (x - 1, z) not in common]
                if not candidates:
                    raise DesignError(f'{owner}: no shared ladder shaft')
                x, z = min(candidates, key=lambda p: abs(p[1] - low.rect.center[1]))
                g.box((x - 1, low.floor + 1, z, x - 1, high.floor + 2, z), 'spruce_planks')
                for y in range(low.floor + 1, high.floor + 2):
                    g.put(x, y, z, 'ladder', facing='east')
                g.put(x, high.floor + 2, z, 'air')
                self.interior.update((x, y, z) for y in range(low.floor + 1, high.floor + 3))
                g.route([(x, y, z) for y in range(low.floor + 1, high.floor + 2)], owner)
                continue
            candidates = [(x, z) for x, z in sorted(common) if all((xx, zz) in common and (xx, zz) not in protected for xx in (x, x + 1) for zz in range(z - 1, z + delta + 1))]
            if not candidates:
                raise DesignError(f'{owner}: no shared stair rectangle with both landings')
            x, z0 = min(candidates, key=lambda q: (-q[0] if flight % 2 else q[0], q[1]))
            points = []
            for i in range(delta):
                z = z0 + i
                y = low.floor + i + 1
                for xx in (x, x + 1):
                    if (xx, z) not in common:
                        raise DesignError(f'{owner}: staircase exceeds the shared floor, use ladder or enlarge depth')
                    g.box((xx, low.floor + 1, z, xx, y - 1, z), 'spruce_planks')
                    g.stair(xx, y, z, 'spruce', 'south')
                    g.box((xx, y + 1, z, xx, y + 3, z), 'air')
                    self.interior.update((xx, yy, z) for yy in range(y, y + 4))
                    points.append((xx, y + 1, z))
                    g.metadata['stairs'].append({'owner': owner, 'point': (xx, y, z), 'facing': 'south'})
            for yy, z in ((low.floor + 1, z0 - 1), (high.floor + 1, z0 + delta)):
                for xx in (x, x + 1):
                    g.box((xx, yy, z, xx, yy + 2, z), 'air')
                    points.append((xx, yy, z))
            # Guard the upper opening without blocking the top or bottom landing.
            for z in range(z0 + max(1, delta - 3), z0 + delta):
                if is_air(g.get(x + 2, high.floor + 1, z)):
                    g.put(x + 2, high.floor + 1, z, 'spruce_fence')
            g.route(points, owner)

    def openings(self):
        """Align glazed bays with all free facades, then cut registered doors."""
        g, p, mat = self.g, self.plan, self.plan.design.materials
        doors = []
        for m in p.masses:
            if m.join:
                j = m.join
                doors.append((j['point'], j['floor'], j['side'], m.name + '/join'))
        e = p.entrance
        doors.append((e['point'], e['floor'], e['side'], e['volume'] + '/entrance'))
        for a in p.attachments:
            doors.append((a['door'], a['floor'], a['side'], a['id']))
        for m in p.masses:
            for s in m.levels:
                r = s.rect
                for side in DIRECTIONS:
                    dx, dz = DIRECTIONS[side]
                    span = r.width if dz else r.depth
                    offsets = [span // 2] if span <= 11 else [span // 3, 2 * span // 3]
                    for offset in offsets:
                        x, z = ((r.x0 + offset, r.z1 if side == 'south' else r.z0) if dz else (r.x1 if side == 'east' else r.x0, r.z0 + offset))
                        if (x, z) not in s.cells or (x - dx, z - dz) not in self.inner[s.id]:
                            continue
                        if any(abs(x - q[0]) + abs(z - q[1]) < 3 and floor == s.floor for q, floor, _, _ in doors):
                            continue
                        if any(other.name != m.name and any(l.rect.contains(x + dx, z + dz) and l.floor <= s.floor + 2 <= l.floor + l.height for l in other.levels) for other in p.masses):
                            continue
                        yy = s.floor + 2
                        points = [(x, yy, z), (x, yy + 1, z)] if s.height >= 5 else [(x, yy, z)]
                        if any(not passable(g.get(x - dx, py, z - dz)) for _, py, _ in points):
                            continue
                        for pt in points:
                            g.put(*pt, 'light_blue_stained_glass' if mat.wall == 'white' else 'glass')
                        g.metadata['openings'].append({'kind': 'window', 'owner': s.id, 'cells': points, 'side': side})
                        # Daylight wells through thick hills preserve the original sealed window.
                        distance = m.roots['spread'] + 2 if m.envelope == 'roots' else 2 if p.design.site.terrain == 'slope' else 1
                        for d in range(1, distance + 1):
                            px, pz = x + dx * d, z + dz * d
                            g.box((px, yy, pz, px, yy + len(points) - 1, pz), 'air')
                        g.reserve([(x - dx, y, z - dz) for _, y, _ in points], s.id, 'window')
                        if mat.shutters and m.envelope != 'roots' and s.height >= 5:
                            tx, tz = (1, 0) if dz else (0, 1)
                            for sign in (-1, 1):
                                for y in range(yy, yy + len(points)):
                                    px, pz = x + dx + tx * sign, z + dz + tz * sign
                                    if is_air(g.get(px, y, pz)):
                                        g.put(px, y, pz, ('warped' if mat.wall == 'white' else mat.frame) + '_trapdoor', facing=side, open='true')
                        # Small flower box attaches to the wall under the sill.
                        if m.envelope != 'roots' and all(is_air(g.get(x + d * dx, s.floor, z + d * dz)) for d in (1, 2)) and not any(a['floor'] <= s.floor <= a['floor'] + (6 if a['cover'] != 'open' else 1) and a['rect'].contains(x + dx, z + dz) for a in p.attachments):
                            g.put(x + dx, s.floor, z + dz, 'dirt')
                            g.put(x + dx, s.floor + 1, z + dz, 'cornflower' if mat.accent in ('blue', 'cyan') else 'poppy')
                            g.put(x + 2 * dx, s.floor, z + 2 * dz, mat.frame + '_trapdoor', facing=side, open='true')
                            g.stair(x + dx, s.floor - 1, z + dz, mat.floor, OPPOSITE[side], 'top')
        for (x, z), floor, side, owner in doors:
            g.door(x, floor, z, side, owner, 'warped' if mat.wall == 'white' else 'spruce')
            dx, dz = DIRECTIONS[side]
            for distance in (-1, 1):
                xx, zz = x + dx * distance, z + dz * distance
                g.box((xx, floor + 1, zz, xx, floor + 2, zz), 'air')
                if is_air(g.get(xx, floor, zz)) or name(g.get(xx, floor, zz)) == 'water':
                    g.put(xx, floor, zz, mat.floor + '_planks')
            self.opening_route((x, floor + 1, z), side, owner)
        # Recessed entrance surround projects one block and stays three blocks high.
        x, z = e['point']
        y = e['floor']
        dx, dz = DIRECTIONS[e['side']]
        for w in (-2, 2):
            xx, zz = x + dx + (w if dz else 0), z + dz + (w if dx else 0)
            g.box((xx, y, zz, xx, y + 3, zz), log(mat.frame) if mat.wall != 'white' else 'smooth_sandstone')
        for w in range(-2, 3):
            xx, zz = x + dx + (w if dz else 0), z + dz + (w if dx else 0)
            g.put(xx, y + 3, zz, mat.floor + '_slab', type='top')
        # Lamp beside the doorway is supported by a real sill.
        xx, zz = x + dx + (2 if dz else 0), z + dz + (2 if dx else 0)
        g.lantern(xx, y + 4, zz, 'entrance')

    def reserve_rooms(self):
        """Derive protected paths through the actual empty structure before decoration."""
        g = self.g
        parents = flood(g, self.start)
        if tuple(g.metadata['entrance']) not in parents:
            raise DesignError('approach does not reach entrance in actual geometry')
        for room in self.rooms:
            cx = sum(x for x, z in room['cells']) / len(room['cells'])
            cz = sum(z for x, z in room['cells']) / len(room['cells'])
            candidates = [(x, room['floor'] + 1, z) for x, z in room['cells'] if (x, room['floor'] + 1, z) in parents]
            if not candidates:
                raise DesignError(f'{room["id"]}: no reachable floor; review its circulation or join')
            anchor = min(candidates, key=lambda p: (abs(p[0] - cx) + abs(p[2] - cz), p))
            room['anchor'] = anchor
            g.route(path_to(parents, anchor), room['id'])
            g.metadata['rooms'].append({**room, 'cells': sorted(room['cells'])})
        for f in g.metadata['features']:
            if tuple(f['target']) not in parents:
                raise DesignError(f'{f["owner"]}: outdoor target is not reachable')
            g.route(path_to(parents, f['target']), f['owner'])

    def connect_fences(self):
        """Resolve explicit fence connections so exports render and paste consistently."""
        g = self.g
        for (x, y, z), state in list(g.blocks.items()):
            if name(state).endswith('_fence'):
                properties = {}
                for face, (dx, dz) in DIRECTIONS.items():
                    n = name(g.get(x + dx, y, z + dz))
                    properties[face] = str(n.endswith(('_fence', '_log', '_planks', '_wood'))).lower()
                g.put(x, y, z, name(state), **properties)
        # Connection properties are finalized after all neighboring furniture exists.
        for group in g.metadata['furniture']:
            for i, expected in enumerate(group['states']):
                if name(expected).endswith('_fence'):
                    group['states'][i] = g.get(*group['cells'][i])

    def geometry_signature(self):
        """Digest actual structural cells, excluding texture, plants and cosmetic materials."""
        # Snapshot before furnishing and planting; normalize all structural material names.
        selected = []
        for mass in self.plan.masses:
            r = mass.rect.inset(-2)
            bottom = mass.levels[0].floor
            for (x, y, z), state in sorted(self.g.blocks.items()):
                if not r.contains(x, z) or y < bottom:
                    continue
                n = name(state)
                role = None
                if n.endswith('_door') or n.endswith('_stairs') or n == 'ladder':
                    properties = split_state(state)[1]
                    role = ('door' if n.endswith('_door') else 'stair' if n.endswith('_stairs') else 'ladder') + ':' + properties.get('facing', '') + ':' + properties.get('half', '')
                elif 'glass' in n:
                    role = 'glass'
                elif n.endswith('_slab'):
                    role = 'slab:' + split_state(state)[1].get('type', '')
                elif full_support(state):
                    role = 'solid'
                if role:
                    selected.append((x, y, z, role))
        return hashlib.sha256(json.dumps(selected, separators=(',', ':')).encode()).hexdigest()

    def check_limits(self):
        b = self.g.bounds
        size = [b[i + 3] - b[i] + 3 for i in range(3)]
        limits = self.plan.design.limits
        if max(size[0], size[2]) > limits.horizontal or size[1] > limits.vertical or size[0] * size[1] * size[2] > limits.volume or len(self.g.blocks) > limits.blocks:
            raise DesignError(f'final geometry size {size} / {len(self.g.blocks)} blocks exceeds Limits; increase resource limits or reduce the composition')


class Building:
    """Expose a generated artifact, design metadata, validation and deterministic export."""

    def __init__(self, grid, plan=None):
        self.grid = grid
        self.plan = plan

    @property
    def metadata(self):
        return self.grid.metadata

    def validate(self):
        from .validate import validate
        return validate(self)

    def export(self, path: Path):
        self.validate().require_valid()
        return self.grid.export(Path(path), self.metadata['name'])

    def verify(self, path: Path):
        return self.grid.verify(Path(path))

    @classmethod
    def load(cls, path: Path, metadata=None):
        return cls(Grid.load(Path(path), metadata))


def generate(design: Design, seed: int, *, validate=True) -> Building:
    """Resolve, construct and optionally validate one user-composed property."""
    plan = resolve(design, seed)
    artifact = Building(Compiler(plan).construct(), plan)
    if validate:
        artifact.validate().require_valid()
    return artifact
