# Profile-driven roof assemblies with scoped, replaceable dormer openings.
from dataclasses import dataclass
from hearth import Box, Frame, ContractError
from hearth.kernel import Capability, Contract, Domain, Port, Grant, Rule, Plan, Child, Binding
from hearth.blocks import AIR, name_of, solid, state_of, stairs, slab, log
from .primitives import Window


@dataclass(frozen=True)
class RoofDesign:
    """Specify rising courses, spacing of dormers, and coherent material roles."""
    rise: tuple[int, ...] = (1, 2)
    dormer_spacing: int = 8
    dormer_sides: tuple[str, ...] = ('left', 'right')
    trim: str = 'dark_oak'

    def heights(self, span):
        if not self.rise or any(v not in (1, 2) for v in self.rise):
            raise ValueError('Roof rise sequence must contain only 1 or 2')
        if self.dormer_spacing and not 7 <= self.dormer_spacing <= 16:
            raise ValueError('Dormer spacing is zero or 7..16')
        if len(set(self.dormer_sides)) != len(self.dormer_sides) or set(self.dormer_sides) - {'left', 'right'}:
            raise ValueError('Dormer sides must be distinct left/right')
        levels = [0]
        for i in range((span + 1) // 2):
            levels.append(levels[-1] + self.rise[i % len(self.rise)])
        return tuple(levels[min(x + 1, span - x)] for x in range(-1, span + 1))

    def component(self, width, depth, axis, material):
        return ProfileRoof(width, depth, axis, material, self)


@dataclass(frozen=True)
class ProfileRoof:
    width: int
    depth: int
    axis: str = 'z'
    material: str = 'deepslate_tile'
    design: RoofDesign = RoofDesign()

    def capability(self):
        return Capability('roof.profile', ('roof', 'enclosure', 'composite'), offers=('roof-interior',), inputs={'width': Domain(7, 64), 'depth': Domain(7, 128), 'axis': Domain(choices=('x', 'z'))}, guarantees=('continuous weather cover', 'sealed attached dormers'), version='2')

    def negotiate(self, ctx, parameters):
        span = self.width if self.axis == 'z' else self.depth
        levels = self.design.heights(span)
        bearings = tuple((x, -1, z) for x in (0, self.width - 1) for z in range(0, self.depth, 4)) + tuple((x, -1, z) for z in (0, self.depth - 1) for x in range(0, self.width, 4))
        box = Box((-1, -1, -1), (self.width, max(levels) + 4, self.depth))
        interior = Port('interior', 'roof-interior', Frame((0, -1, 0)), box, 1, 'interior-install', facts={'width': self.width, 'depth': self.depth, 'axis': self.axis, 'height': max(levels) + 5, 'coordinate_space': 'port-local', 'requires_floor_and_knee_walls': True, 'replace_limit': 0})
        return Contract(box, ports=(interior,), grants=(Grant('interior-install', box, ('install',), ('roof-room',), 0),), rules=(Rule('support', bearings),), decisions={'axis': self.axis, 'span': span, 'rise': self.design.rise, 'dormer_spacing': self.design.dormer_spacing, 'dormer_sides': self.design.dormer_sides})

    def realize(self, ctx, contract):
        span = self.width if self.axis == 'z' else self.depth
        length = self.depth if self.axis == 'z' else self.width
        frame = Frame() if self.axis == 'z' else Frame((0, 0, span - 1), 3)
        return Plan(children=[Child('cover', RoofCover(span, length, self.material, self.design), frame=frame)])


@dataclass(frozen=True)
class RoofCover:
    span: int
    length: int
    material: str
    design: RoofDesign
    populate: bool = True

    def capability(self):
        return Capability('roof.weather-cover', ('roof', 'enclosure', 'composite'), offers=('roof-installation',), adaptations=('measured solid abutments', 'scoped dormer cuts'), guarantees=('actual projected weather skin', 'bounded opening authority'))

    def negotiate(self, ctx, parameters):
        levels = self.design.heights(self.span)
        peak = max(levels)
        box = Box((-1, -1, -1), (self.span, peak + 4, self.length))
        mounts, rejected = [], []
        if self.design.dormer_spacing:
            centers = range(4, self.length - 3, self.design.dormer_spacing)
            for side in self.design.dormer_sides:
                for center in centers:
                    frame = Frame((self.span - 1, 1, center - 2), 1) if side == 'right' else Frame((0, 1, center + 2), 3)
                    region = frame.box(Box((0, 0, 0), (4, peak + 2, 2)))
                    # Avoid prospective dormer intersection with an existing upper room/wing.
                    if ctx.view.protected(ctx.frame.box(region)):
                        rejected.append((side, center, 'protected circulation'))
                    elif all(name_of(ctx.state(q)) == 'air' for q in region.cells()):
                        mounts.append((f'{side}-{center}', frame, region))
                    else:
                        rejected.append((side, center, 'existing upper volume'))
        ports = tuple(Port(key, 'roof-installation', frame, region, 1, key, facts={'width': 5, 'depth': 3, 'rise': self.design.rise, 'peak': peak}) for key, frame, region in mounts)
        grants = tuple(Grant(key, region, ('install',), ('dormer',), region.volume) for key, frame, region in mounts)
        # Every intended roof column must retain actual solid cover after modifications.
        columns = tuple((x, levels[x + 1], z) for x in range(self.span) for z in range(self.length))
        rule = Rule('vertical-cover', columns, box, phase='complete')
        return Contract(box, ports, grants, (rule,), decisions={'levels': levels, 'mounts': [(key, f.origin, f.turn) for key, f, _ in mounts], 'omitted_optional_dormers': rejected, 'peak': peak})

    def realize(self, ctx, contract):
        p = Plan()
        levels = contract.decisions['levels']
        span, length = self.span, self.length
        for x in range(-1, span + 1):
            y = levels[x + 1]
            previous = levels[max(0, x)] if x < span // 2 else levels[min(span + 1, x + 2)]
            for z in range(-1, length + 1):
                material = self.design.trim if z in (-1, length) else self.material
                facing = 'east' if x < span // 2 else 'west'
                # Two-rise courses have a full riser; stair treads alone leave a visible slit.
                for yy in range(previous + 1, y):
                    if name_of(ctx.state((x, yy, z))) == 'air':
                        p.block((x, yy, z), state_of(material + '_planks') if material in ('spruce', 'dark_oak') else state_of(material + 's'), 'riser')
                if name_of(ctx.state((x, y, z))) == 'air':
                    p.block((x, y, z), stairs(material, facing), 'weather-course')
                elif x not in (-1, span) and z not in (-1, length) and not solid(ctx.state((x, y, z))):
                    raise ContractError('roof-abutment', ctx.path, ctx.world((x, y, z)), 'Roof may abut actual solid boundaries only; found ' + ctx.state((x, y, z)) + ' owned by ' + str(ctx.view.owner(ctx.world((x, y, z)))))
        for x in (-1, span):
            for z in range(length):
                if name_of(ctx.state((x, -1, z))) == 'air':
                    p.block((x, -1, z), slab('spruce', 'top'), 'eave-fascia')
        mid = span // 2
        for z in range(-1, length + 1):
            if name_of(ctx.state((mid, max(levels) + 1, z))) == 'air':
                p.block((mid, max(levels) + 1, z), slab(self.design.trim), 'ridge-cap')
        for z in (-1, length):
            if name_of(ctx.state((mid, max(levels) + 2, z))) != 'air':
                continue
            p.block((mid, max(levels) + 2, z), state_of('dark_oak_fence', north='false', south='false', east='false', west='false', waterlogged='false'), 'ridge-finial')
        p.children.append(Child('gable-front', ProfileGable(span, levels)))
        p.children.append(Child('gable-rear', ProfileGable(span, levels), frame=Frame((span - 1, 0, length - 1), 2)))
        for key, origin, turn in contract.decisions['mounts'] if self.populate else ():
            p.children.append(Child('dormer-' + key, Dormer(self.material, self.design.trim), frame=Frame(tuple(origin), turn), bindings=(Binding(ctx.path, key),)))
        return p


@dataclass(frozen=True)
class ProfileGable:
    span: int
    levels: tuple[int, ...]

    def capability(self):
        return Capability('wall.profile-gable', ('wall', 'enclosure', 'composite'), offers=('wall-installation',), guarantees=('framed infill',))

    def negotiate(self, ctx, parameters):
        mid = self.span // 2
        height = min(self.levels[mid], self.levels[mid + 2])
        region = Box((mid - 1, 0, 0), (mid + 1, height - 1, 0))
        box = Box((0, -1, -1), (self.span - 1, max(self.levels), 0))
        return Contract(box, (Port('infill', 'wall-installation', Frame((mid - 1, 0, 0)), region, 1, 'install', facts={'thickness': 1}),), (Grant('install', region, ('install',), ('window',), region.volume),))

    def realize(self, ctx, contract):
        p = Plan()
        available = set()
        for x in range(self.span):
            for y in range(self.levels[x + 1]):
                if name_of(ctx.state((x, y, 0))) == 'air':
                    material = log('spruce') if x in (0, self.span // 2, self.span - 1) or y == 0 else state_of('smooth_sandstone')
                    p.block((x, y, 0), material, 'gable-frame')
                    available.add((x, y, 0))
        mid = self.span // 2
        height = min(3, self.levels[mid] - 2)
        if height >= 2 and all((x, y, 0) in available for x in range(mid - 1, mid + 2) for y in range(1, height + 1)):
            # A canopy may occupy the sill projection while leaving the glazing bay free.
            # Use the ordinary flush implementation instead of blanking that whole bay.
            projecting = all(name_of(ctx.state((x, y, -1))) == 'air' for x in range(mid - 2, mid + 3) for y in range(height + 1))
            clear_glazing = all(name_of(ctx.state((x, y, -1))) == 'air' for x in range(mid - 1, mid + 2) for y in range(1, height + 1))
            if projecting or clear_glazing:
                window = Window(3, height) if projecting else FlushWindow(3, height)
                p.children.append(Child('window', window, frame=Frame((mid - 1, 1, 0)), bindings=(Binding(ctx.path, 'infill'),)))
        return p


@dataclass(frozen=True)
class Dormer:
    material: str = 'deepslate_tile'
    trim: str = 'dark_oak'

    def capability(self):
        return Capability('roof.dormer', ('dormer', 'roof', 'composite'), offers=('daylight',), assumptions=('compatible roof-installation region',), guarantees=('sealed cheeks and glazing', 'opening to attic'))

    def negotiate(self, ctx, parameters):
        if len(ctx.bindings) != 1:
            raise ContractError('dormer-host', ctx.path)
        port = ctx.view.port(ctx.bindings[0].host, ctx.bindings[0].port)
        if port.kind != 'roof-installation' or port.frame != ctx.frame or port.facts.get('width') != 5 or port.facts.get('depth') != 3:
            raise ContractError('dormer-interface', ctx.path, ctx.frame.origin, 'Require aligned 5 by 3 roof mount')
        box = Box((0, 0, 0), (4, port.facts['peak'] + 2, 2))
        if not all(port.region.contains(ctx.world(q)) for q in box.corners()):
            raise ContractError('dormer-envelope', ctx.path)
        # The back roof rises into the main roof; cap each column above its measured intersection.
        tops = []
        for z in range(3):
            top = max([y for x in range(5) for y in range(box.hi[1] + 1) if solid(ctx.state((x, y, z)))] or [0])
            tops.append(max(4, top + 1))
        tops = [max(tops)] * 3
        if max(tops) + 2 > box.hi[1]:
            raise ContractError('dormer-height', ctx.path, conditions='Insufficient height within the negotiated roof mount')
        boundary = tuple((x, tops[z] + min(x, 4 - x), z) for x in range(5) for z in range(3))
        cheeks = tuple((x, y, z) for x in (0, 4) for z in range(3) for y in range(0, tops[z]))
        face = tuple((x, y, 0) for x in range(1, 4) for y in range(tops[0] + min(x, 4 - x)))
        return Contract(box, rules=(Rule('sealed', boundary + cheeks + face, phase='complete'),), decisions={'tops': tops})

    def realize(self, ctx, contract):
        p = Plan()
        tops = contract.decisions['tops']
        # Cut only the owned weather cover, leaving an open rear into the roof void.
        for x in range(1, 4):
            for z in range(1, 3):
                for y in range(tops[z]):
                    if name_of(ctx.state((x, y, z))) != 'air':
                        p.block((x, y, z), AIR, 'roof-opening')
        for x in (0, 4):
            for z in range(3):
                for y in range(tops[z]):
                    p.block((x, y, z), log('spruce') if z == 0 else state_of('spruce_planks'), 'cheek')
        for x in range(5):
            for z in range(3):
                roof = tops[z] + min(x, 4 - x)
                for y in range(tops[z], roof):
                    if z == 0 and 0 < x < 4:
                        continue
                    p.block((x, y, z), state_of('spruce_planks'), 'roof-riser')
                p.block((x, roof, z), stairs(self.trim if z == 0 else self.material, 'east' if x < 2 else 'west'), 'dormer-cap')
        for x in range(1, 4):
            for y in range(tops[0] + min(x, 4 - x)):
                if name_of(ctx.state((x, y, 0))) != 'air':
                    p.block((x, y, 0), AIR, 'face-reveal')
        # A semantic wall owns its glass attachment. Its bottom beam preserves the roof joint.
        p.children.append(Child('face', DormerFace(tops[0])))
        return p


@dataclass(frozen=True)
class DormerFace:
    height: int

    def capability(self):
        return Capability('wall.dormer-face', ('wall', 'enclosure', 'composite'), offers=('wall-installation',))

    def negotiate(self, ctx, parameters):
        region = Box((1, 1, 0), (3, self.height - 1, 0))
        return Contract(Box((1, 0, 0), (3, self.height + 1, 0)), ports=(Port('infill', 'wall-installation', Frame((1, 1, 0)), region, 1, 'install', facts={'thickness': 1}),), grants=(Grant('install', region, ('install',), ('window',), region.volume),))

    def realize(self, ctx, contract):
        p = Plan()
        for x in range(1, 4):
            for y in range(self.height + min(x, 4 - x)):
                p.block((x, y, 0), log('spruce', 'x') if y in (0, self.height - 1) else state_of('smooth_sandstone'), 'frame')
        # Glazing has no exterior shutter projection outside the accepted roof mount.
        p.children.append(Child('window', FlushWindow(3, 2), frame=Frame((1, 1, 0)), bindings=(Binding(ctx.path, 'infill'),)))
        return p


@dataclass(frozen=True)
class FlushWindow:
    width: int = 3
    height: int = 2

    def capability(self):
        return Capability('window.flush', ('window', 'sealed-opening'), guarantees=('sealed glazing within host',))

    def negotiate(self, ctx, parameters):
        if not ctx.bindings:
            raise ContractError('window-host', ctx.path)
        port = ctx.view.port(ctx.bindings[0].host, ctx.bindings[0].port)
        depth = port.facts['thickness']
        box = Box((0, 0, 0), (self.width - 1, self.height - 1, depth - 1))
        if not all(port.region.contains(ctx.world(q)) for q in box.corners()):
            raise ContractError('window-margins', ctx.path)
        return Contract(box, rules=(Rule('sealed', tuple(box.cells())),))

    def realize(self, ctx, contract):
        p = Plan()
        p.fill(contract.envelope, state_of('glass'), 'glazing')
        return p
