# Habitable roof volumes assembled through public stair and roof interior contracts.
from dataclasses import dataclass
from hearth import Box, Frame, ContractError
from hearth.kernel import Capability, Contract, Domain, Rule, Port, Plan, Child, Binding
from hearth.blocks import name_of, state_of, log
from hearth.navigation import floor_path
from .furnishing import FurnitureGroup, GROUPS
from .roofcraft import RoofDesign

LOFT_PURPOSES = ('bedroom', 'library', 'storage')


@dataclass(frozen=True)
class RoofCollars:
    """Span measured roof faces above player headroom with timber collar ties."""
    width: int
    depth: int
    axis: str
    level: int = 4
    spacing: int = 6

    def capability(self):
        return Capability('timber.roof-collars', ('timber-frame', 'decoration'), inputs={'level': Domain(4, 8), 'spacing': Domain(5, 12)}, guarantees=('actual end bearings', 'preserved circulation'))

    def negotiate(self, ctx, parameters):
        span, length = (self.width, self.depth) if self.axis == 'z' else (self.depth, self.width)

        def point(cross, along):
            return (cross, self.level, along) if self.axis == 'z' else (along, self.level, cross)

        members, anchors, omitted = [], [], []
        for along in range(3, length - 2, self.spacing):
            row = [point(cross, along) for cross in range(span)]
            solid = [i for i, q in enumerate(row) if ctx.view.supports(ctx.world(q))]
            if len(solid) < 2:
                omitted.append(along)
                continue
            a, b = min(solid), max(solid)
            points = row[a + 1:b]
            if not points or any(name_of(ctx.state(q)) != 'air' or ctx.view.protected(ctx.frame.box(Box(q, q))) for q in points):
                omitted.append(along)
                continue
            members.extend(points)
            anchors.extend((row[a], row[b]))
        return Contract(Box((0, self.level, 0), (self.width - 1, self.level, self.depth - 1)), rules=(Rule('support', tuple(anchors)), Rule('expected', tuple(members), data={'names': ['spruce_log']})), decisions={'members': members, 'omitted_optional_ties': omitted})

    def realize(self, ctx, contract):
        p = Plan()
        for q in contract.decisions['members']:
            p.block(q, log('spruce', 'x' if self.axis == 'z' else 'z'), 'collar-tie')
        return p


@dataclass(frozen=True)
class ApertureGuard:
    width: int
    depth: int
    aperture: tuple

    def capability(self):
        return Capability('circulation.aperture-guard', ('railing',), guarantees=('supported guard beside stair opening', 'preserved landing and routes'))

    def negotiate(self, ctx, parameters):
        holes = set(tuple(q) for q in self.aperture)
        candidates = {(x + dx, 0, z + dz) for x, _, z in holes for dx, dz in ((1, 0), (-1, 0), (0, 1), (0, -1))} - holes
        rails = []
        for x, _, z in sorted(candidates):
            q = (x, 1, z)
            if 0 < x < self.width - 1 and 0 < z < self.depth - 1 and ctx.view.supports(ctx.world((x, 0, z))) and name_of(ctx.state(q)) == 'air' and not ctx.view.protected(ctx.frame.box(Box(q, (x, 2, z)))):
                rails.append(q)
        return Contract(Box((0, 1, 0), (self.width - 1, 1, self.depth - 1)), rules=(Rule('support', tuple((x, 0, z) for x, _, z in rails)), Rule('expected', tuple(rails), data={'names': ['dark_oak_fence']})), decisions={'rails': rails})

    def realize(self, ctx, contract):
        p = Plan()
        rails = set(tuple(q) for q in contract.decisions['rails'])
        neighbors = {'north': (0, -1), 'south': (0, 1), 'east': (1, 0), 'west': (-1, 0)}
        for x, y, z in sorted(rails):
            links = {side: str((x + dx, y, z + dz) in rails or ctx.view.supports(ctx.world((x + dx, y, z + dz)))).lower() for side, (dx, dz) in neighbors.items()}
            p.block((x, y, z), state_of('dark_oak_fence', waterlogged='false', **links), 'guard')
        return p


@dataclass(frozen=True)
class LoftDeck:
    width: int
    depth: int
    aperture: tuple

    def capability(self):
        return Capability('surface.loft-deck', ('floor', 'support'), guarantees=('bounded stair aperture', 'perimeter bearing'))

    def negotiate(self, ctx, parameters):
        holes = set(self.aperture)
        floor = tuple((x, 0, z) for x in range(self.width) for z in range(self.depth) if (x, 0, z) not in holes)
        bearings = tuple((x, -1, z) for x in (0, self.width - 1) for z in (0, self.depth - 1))
        return Contract(Box((0, 0, 0), (self.width - 1, 0, self.depth - 1)), rules=(Rule('support', bearings), Rule('sealed', floor)), decisions={'floor': floor, 'aperture': self.aperture})

    def realize(self, ctx, contract):
        p = Plan()
        for x, y, z in contract.decisions['floor']:
            p.block((x, y, z), state_of('dark_oak_planks' if x in (1, self.width - 2) or z in (1, self.depth - 2) else 'spruce_planks'), 'deck')
        return p


@dataclass(frozen=True)
class KneeWalls:
    width: int
    depth: int
    axis: str

    def capability(self):
        return Capability('wall.roof-knees', ('wall', 'enclosure'), guarantees=('supported closed eaves',))

    def negotiate(self, ctx, parameters):
        cells = tuple((x, 1, z) for x in range(self.width) for z in range(self.depth) if (self.axis == 'z' and x in (0, self.width - 1) and 0 < z < self.depth - 1) or (self.axis == 'x' and z in (0, self.depth - 1) and 0 < x < self.width - 1))
        return Contract(Box((0, 1, 0), (self.width - 1, 1, self.depth - 1)), rules=(Rule('support', tuple((x, 0, z) for x, _, z in cells)), Rule('sealed', cells)), decisions={'members': cells})

    def realize(self, ctx, contract):
        p = Plan()
        for q in contract.decisions['members']:
            p.block(q, log('spruce', self.axis), 'knee-beam')
        return p


@dataclass(frozen=True)
class LoftInterior:
    purpose: str
    aperture: tuple
    landing: tuple

    def capability(self):
        return Capability('room.loft.' + self.purpose, ('room', 'habitable', 'roof-room', 'composite'), inputs={'purpose': Domain(choices=LOFT_PURPOSES)}, offers=('access',), guarantees=('reachable furnished loft', 'actual lighting', 'closed weather envelope'), assumptions=('roof-interior mount with floor and bounded stair aperture',))

    def negotiate(self, ctx, parameters):
        if len(ctx.bindings) != 1:
            raise ContractError('loft-host', ctx.path)
        port = ctx.view.port(ctx.bindings[0].host, ctx.bindings[0].port)
        if port.kind != 'roof-interior' or port.frame != ctx.frame:
            raise ContractError('loft-interface', ctx.path, ctx.frame.origin)
        w, d, height = (port.facts[k] for k in ('width', 'depth', 'height'))
        box = Box((-1, 0, -1), (w, height, d))
        if not all(port.region.contains(ctx.world(q)) for q in box.corners()):
            raise ContractError('loft-envelope', ctx.path)
        center = (w // 2, 1, d // 2)
        floor = Box((1, 1, 1), (w - 2, 1, d - 2))
        main = floor_path(ctx.view, ctx.frame, self.landing, center, floor)
        if not main:
            raise ContractError('loft-landing', ctx.path, ctx.world(self.landing), 'No level route from the promised stair landing')
        clear = {p for x, y, z in main for p in ((x, y, z), (x, y + 1, z))}
        occupied, apron, target = FurnitureGroup.placement_geometry()
        candidates = []
        for x in range(1, w - 1):
            for z in range(1, d - 1):
                for turn in range(4):
                    frame = Frame((x, 1, z), turn)
                    body, use = frame.box(occupied), frame.box(apron)
                    if not all(floor.contains((q[0], 1, q[2])) for q in (*body.corners(), *use.corners())):
                        continue
                    if any(q in clear for q in body.cells()) or ctx.view.protected(ctx.frame.box(body)):
                        continue
                    if any(name_of(ctx.state(q)) != 'air' for q in (*body.cells(), *use.cells())):
                        continue
                    if not all(ctx.view.supports(ctx.world((q[0], 0, q[2]))) for q in (*body.cells(), *use.cells())):
                        continue
                    candidates.append((frame, body, use))
        # Spread useful groups toward both ends, with stable seeded tie breaks.
        jitter = ctx.rng('furniture-layout')
        scored = [(min(f.origin[0], w - 1 - f.origin[0], f.origin[2], d - 1 - f.origin[2]), jitter.random(), f, body, use) for f, body, use in candidates]
        selected, blocked, reserved = [], set(), set()
        desired = max(2, min(8, (w * d) // 38))
        for _, _, frame, body, use in sorted(scored, key=lambda r: (r[0], r[1])):
            cells = set(body.cells())
            if cells & (clear | reserved | blocked) or set(use.cells()) & blocked:
                continue
            approach = frame.point(target)
            path = floor_path(ctx.view, ctx.frame, center, approach, floor, blocked | cells)
            if not path:
                continue
            selected.append((frame.origin, frame.turn, approach))
            blocked |= cells
            reserved |= set(use.cells())
            clear |= {p for x, y, z in path for p in ((x, y, z), (x, y + 1, z))}
            if len(selected) == desired:
                break
        if len(selected) < 2:
            raise ContractError('loft-furnishing-space', ctx.path, ctx.world(center), 'Need two accessible complete furniture groups')
        # Sources sit above the route, keeping its full two-cell headroom.
        light_sites = []
        for x, _, z in sorted(clear):
            q = (x, 3, z)
            if q in light_sites or q in blocked or name_of(ctx.state(q)) != 'air' or ctx.view.protected(ctx.frame.box(Box(q, q))):
                continue
            if all(abs(x - a) + abs(z - c) >= 5 for a, _, c in light_sites):
                light_sites.append(q)
        if not light_sites:
            raise ContractError('loft-light-space', ctx.path, ctx.world(center))
        targets = (self.landing, center, *(r[2] for r in selected))
        floor_cells = tuple((x, 0, z) for x in range(w) for z in range(d) if (x, 0, z) not in self.aperture)
        rules = (Rule('sealed', floor_cells), Rule('clear', tuple(sorted(clear))), Rule('route', targets, box, phase='complete'), Rule('light', targets, box, {'minimum': 1}, 'complete'), Rule('furnishing', box=box, data={'groups': GROUPS[self.purpose]}, phase='complete'), Rule('enclosed-volume', (center,), box, {'apertures': self.aperture}, 'complete'))
        return Contract(box, ports=(Port('landing', 'access', Frame(self.landing), Box(self.landing, (self.landing[0], self.landing[1] + 1, self.landing[2])), 4),), rules=rules, decisions={'purpose': self.purpose, 'center': center, 'doors': ('landing',), 'stair_up': False, 'stair_down': True, 'furniture': selected, 'lights': light_sites, 'aperture': self.aperture, 'axis': port.facts['axis'], 'width': w, 'depth': d, 'omitted_optional_stations': desired - len(selected)})

    def realize(self, ctx, contract):
        p = Plan()
        w, d = contract.decisions['width'], contract.decisions['depth']
        p.children.append(Child('collars', RoofCollars(w, d, contract.decisions['axis'])))
        for i, (origin, turn, _) in enumerate(contract.decisions['furniture']):
            purpose = self.purpose if i % 2 == 0 else ('library' if self.purpose == 'bedroom' else 'storage')
            p.children.append(Child('station-' + str(i), FurnitureGroup(purpose), frame=Frame(tuple(origin), turn)))
        p.children.append(Child('guard', ApertureGuard(w, d, self.aperture)))
        for i, q in enumerate(contract.decisions['lights']):
            p.children.append(Child('light-' + str(i), LoftLight(), frame=Frame(tuple(q))))
        return p


@dataclass(frozen=True)
class LoftLight:
    maximum_hanger: int = 16

    def capability(self):
        return Capability('light.roof-hanger', ('light',), inputs={'maximum_hanger': Domain(1, 64)}, guarantees=('actual light hung from measured roof support',))

    def negotiate(self, ctx, parameters):
        top = next((y for y in range(1, self.maximum_hanger + 2) if ctx.view.supports(ctx.world((0, y, 0)))), None)
        if top is None:
            raise ContractError('loft-light-support', ctx.path, ctx.frame.origin)
        if any(name_of(ctx.state((0, y, 0))) != 'air' for y in range(top)):
            raise ContractError('loft-light-clearance', ctx.path, ctx.frame.origin)
        return Contract(Box((0, 0, 0), (0, top - 1, 0)), rules=(Rule('support', ((0, top, 0),)), Rule('expected', ((0, 0, 0),), data={'names': ['lantern']}), Rule('expected', tuple((0, y, 0) for y in range(1, top)), data={'names': ['chain']})), decisions={'top': top})

    def realize(self, ctx, contract):
        p = Plan()
        # The lantern is visibly hung, with a continuous chain to an actual roof cell.
        p.block((0, 0, 0), state_of('lantern', hanging='true', waterlogged='false'), 'lantern')
        for y in range(1, contract.decisions['top']):
            p.block((0, y, 0), state_of('chain', axis='y', waterlogged='false'), 'hanger')
        return p


@dataclass(frozen=True)
class RoofPlatform:
    """Prepare a bounded floor before neighboring weather skins are installed."""
    width: int
    depth: int
    axis: str

    def capability(self):
        return Capability('support.roof-platform', ('floor', 'support', 'composite'), inputs={'width': Domain(9, 64), 'depth': Domain(9, 128), 'axis': Domain(choices=('x', 'z'))}, offers=('roof-seat',), assumptions=('aligned upper-floor-access',), guarantees=('bounded open stair aperture', 'closed supported loft floor'))

    def negotiate(self, ctx, parameters):
        if len(ctx.bindings) != 1:
            raise ContractError('roof-platform-host', ctx.path)
        port = ctx.view.port(ctx.bindings[0].host, ctx.bindings[0].port)
        if port.kind != 'upper-floor-access' or port.frame != ctx.frame:
            raise ContractError('roof-platform-interface', ctx.path, ctx.frame.origin)
        region = Box.enclosing(ctx.local(q) for q in port.region.corners())
        box = Box((0, 0, 0), (self.width - 1, 2, self.depth - 1))
        if region.lo[1] != 0 or not all(box.contains(q) for q in region.corners()):
            raise ContractError('loft-stair-aperture', ctx.path)
        aperture = tuple((x, 0, z) for x in range(region.lo[0], region.hi[0] + 1) for z in range(region.lo[2], region.hi[2] + 1))
        landing = ctx.local(port.frame.point(tuple(port.facts['landing'])))
        facts = {'width': self.width, 'depth': self.depth, 'axis': self.axis, 'aperture': aperture, 'landing': landing, 'source': ctx.bindings[0].host, 'coordinate_space': 'port-local'}
        return Contract(box, ports=(Port('seat', 'roof-seat', Frame(), box, 1, facts=facts),), decisions=facts)

    def realize(self, ctx, contract):
        return Plan(children=[Child('floor', LoftDeck(self.width, self.depth, contract.decisions['aperture'])), Child('knees', KneeWalls(self.width, self.depth, self.axis))])


@dataclass(frozen=True)
class InhabitedRoof:
    width: int
    depth: int
    axis: str
    material: str
    design: object = RoofDesign()
    purpose: str = 'bedroom'

    def capability(self):
        return Capability('roof.inhabited', ('roof', 'habitable', 'composite'), inputs={'purpose': Domain(choices=LOFT_PURPOSES), 'width': Domain(9, 64), 'depth': Domain(9, 128), 'axis': Domain(choices=('x', 'z'))}, offers=('access',), assumptions=('aligned upper-floor-access or prepared roof-seat with clear stair aperture', 'roof policy with roof-interior offer'), guarantees=('furnished accessible roof room', 'locked purpose', 'closed weather boundary'))

    def negotiate(self, ctx, parameters):
        if len(ctx.bindings) != 1:
            raise ContractError('inhabited-roof-host', ctx.path)
        port = ctx.view.port(ctx.bindings[0].host, ctx.bindings[0].port)
        if port.kind not in ('upper-floor-access', 'roof-seat') or port.frame != ctx.frame:
            raise ContractError('inhabited-roof-interface', ctx.path, ctx.frame.origin)
        span = self.width if self.axis == 'z' else self.depth
        peak = max(self.design.heights(span))
        allowance = self.design.vertical_allowance() if hasattr(self.design, 'vertical_allowance') else 4
        box = Box((-1, 0, -1), (self.width, peak + allowance + 1, self.depth))
        region = Box.enclosing(ctx.local(q) for q in port.region.corners())
        if region.lo[1] != 0 or not all(box.contains(q) for q in region.corners()):
            raise ContractError('loft-stair-aperture', ctx.path)
        aperture = tuple((x, 0, z) for x in range(region.lo[0], region.hi[0] + 1) for z in range(region.lo[2], region.hi[2] + 1)) if port.kind == 'upper-floor-access' else tuple(tuple(q) for q in port.facts['aperture'])
        if port.kind == 'roof-seat' and any(port.facts[key] != getattr(self, key) for key in ('width', 'depth', 'axis')):
            raise ContractError('roof-seat-dimensions', ctx.path)
        landing = ctx.local(port.frame.point(tuple(port.facts['landing'])))
        access = Port('landing', 'access', Frame(landing), Box(landing, (landing[0], landing[1] + 1, landing[2])), 4, delegate=('room', 'landing'))
        return Contract(box, ports=(access,), decisions={'purpose': self.purpose, 'aperture': aperture, 'landing': landing, 'axis': self.axis, 'prepared_floor': port.kind == 'roof-seat', 'source': port.facts.get('source', ctx.bindings[0].host)})

    def realize(self, ctx, contract):
        roof = self.design.component(self.width, self.depth, self.axis, self.material)
        if 'roof-interior' not in roof.capability().offers:
            raise ContractError('roof-interior-offer', ctx.path, conditions='Requested habitable roof requires a public roof-interior offer')
        aperture, landing = contract.decisions['aperture'], contract.decisions['landing']
        p = Plan()
        if not contract.decisions['prepared_floor']:
            p.children.extend([Child('floor', LoftDeck(self.width, self.depth, aperture)), Child('knees', KneeWalls(self.width, self.depth, self.axis))])
        p.children.extend([Child('weather', roof, frame=Frame((0, 1, 0))), Child('room', LoftInterior(self.purpose, aperture, landing), bindings=(Binding(ctx.path + '/weather', 'interior'),))])
        floor_host = ctx.bindings[0].host if contract.decisions['prepared_floor'] else ctx.path + '/floor'
        p.relations.extend(((ctx.path + '/room', 'supported_by', floor_host), (ctx.path + '/room', 'connected_to', contract.decisions['source'])))
        return p
