# 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
from .roofshape import RoofSurface


@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'
    end_hips: tuple[int, int] = (0, 0)

    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)

    def surface(self, span, length):
        return RoofSurface(self.heights(span), length, self.end_hips)


@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')), 'design.end_hips': 'two depths 0..3, four-cell residual gable and nonzero ridge'}, guarantees=('continuous weather cover', 'sealed attached dormers'), version='3')

    def negotiate(self, ctx, parameters):
        span = self.width if self.axis == 'z' else self.depth
        levels = self.design.heights(span)
        self.design.surface(span, self.depth if self.axis == 'z' else self.width)
        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, 'end_hips': self.design.end_hips})

    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', 'full-height finial bases'), version='2')

    def negotiate(self, ctx, parameters):
        surface = self.design.surface(self.span, self.length)
        levels = surface.levels
        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)
                    footprint = [frame.point((x, 0, z)) for x in range(5) for z in range(3)]
                    if any(surface.height(x, z) < levels[x + 1] for x, _, z in footprint):
                        rejected.append((side, center, 'hip transition within mounting footprint'))
                        continue
                    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, surface.height(x, z), z) for x in range(self.span) for z in range(self.length))
        rule = Rule('vertical-cover', columns, box, phase='complete')
        mid = self.span // 2
        finials = tuple(z for z in sorted(set(surface.ridge)) if all(name_of(ctx.state((mid, peak + dy, z))) == 'air' for dy in (1, 2)))
        bases = Rule('expected', tuple((mid, peak + 1, z) for z in finials), data={'names': ['dark_oak_planks']})
        ornaments = Rule('expected', tuple((mid, peak + 2, z) for z in finials), data={'names': ['dark_oak_fence']})
        return Contract(box, ports, grants, (rule, bases, ornaments), decisions={'levels': levels, 'mounts': [(key, f.origin, f.turn) for key, f, _ in mounts], 'omitted_optional_dormers': rejected, 'peak': peak, 'end_hips': self.design.end_hips, 'finials': finials})

    def realize(self, ctx, contract):
        p = Plan()
        levels = contract.decisions['levels']
        span, length = self.span, self.length
        surface = self.design.surface(span, length)
        for x in range(-1, span + 1):
            for z in range(-1, length + 1):
                y = surface.height(x, z)
                previous = surface.previous(x, z)
                material = self.design.trim if z in (-1, length) else self.material
                facing = surface.facing(x, z)
                # 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(surface.ridge[0], surface.ridge[1] + 1):
            if name_of(ctx.state((mid, max(levels) + 1, z))) == 'air':
                cap = state_of('dark_oak_planks') if z in contract.decisions['finials'] else slab(self.design.trim)
                p.block((mid, max(levels) + 1, z), cap, 'ridge-cap')
        for z in contract.decisions['finials']:
            p.block((mid, max(levels) + 2, z), state_of('dark_oak_fence', north='false', south='false', east='false', west='false', waterlogged='false'), 'ridge-finial')
        front = tuple(surface.height(x, 0) for x in range(-1, span + 1))
        rear = tuple(surface.height(x, length - 1) for x in range(-1, span + 1))
        p.children.append(Child('gable-front', ProfileGable(span, front, not bool(self.design.end_hips[0]))))
        p.children.append(Child('gable-rear', ProfileGable(span, rear, not bool(self.design.end_hips[1])), 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, ...]
    projecting_windows: bool = True

    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:
                # Clipped gables keep glazing within the structural face at roof junctions.
                window = Window(3, height) if projecting and self.projecting_windows 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
