# 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)
    ridge_component: object = None
    ridge_required: bool = False
    ridge_min_run: int = 0

    def vertical_allowance(self):
        """Return the negotiated space above the profile for roof attachments."""
        if self.ridge_required and self.ridge_component is None:
            raise ValueError('A required ridge attachment needs a component')
        if type(self.ridge_min_run) is not int or not 0 <= self.ridge_min_run <= 128:
            raise ValueError('Minimum ridge run must be an integer in 0..128')
        return max(4, self.ridge_component.mounting().upper if self.ridge_component else 4)

    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', 'bounded ridge attachments'), version='4')

    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))
        height = max(levels) + self.design.vertical_allowance()
        box = Box((-1, -1, -1), (self.width, height, 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': height + 1, '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', 'ridge-installation'), adaptations=('measured solid abutments', 'scoped dormer cuts', 'fitted ridge mounts'), guarantees=('actual projected weather skin', 'bounded opening authority', 'full-height finial bases'), version='3')

    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 + self.design.vertical_allowance(), self.length))
        ridge, ridge_rejected = self.fit_ridge(ctx, surface)
        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
                    # A bounded dormer cap follows the measured throat height,
                    # rather than reserving air all the way above the main ridge.
                    limit = max(4, max(surface.height(x, z) for x, _, z in footprint)) + 2
                    region = frame.box(Box((0, 0, 0), (4, limit, 2)))
                    ridge_claims = tuple(ridge.frame.box(b) for b in self.design.ridge_component.mounting().occupied_envelopes) if ridge else ()
                    if any(region.intersection(claim) for claim in ridge_claims):
                        rejected.append((side, center, 'reserved ridge attachment'))
                        continue
                    # 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, 'height_limit': region.hi[1] - frame.origin[1]}) for key, frame, region in mounts)
        grants = tuple(Grant(key, region, ('install',), ('dormer',), region.volume) for key, frame, region in mounts)
        if ridge:
            ports += (ridge,)
            grants += (Grant('ridge', ridge.region, ('install',), ('ridge-attachment',), ridge.facts['edit_limit']),)
        # 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, 'ridge_mount': ridge.frame.origin if ridge else None, 'ridge_candidates': ridge_rejected, 'omitted_optional_ridge': bool(self.design.ridge_component and not ridge)})

    def fit_ridge(self, ctx, surface):
        """Fit a public mounting description before choosing optional dormer bays."""
        component = self.design.ridge_component
        if component is None:
            return None, []
        if self.length < self.design.ridge_min_run:
            if self.design.ridge_required:
                raise ContractError('ridge-fit', ctx.path, conditions='Run shorter than the explicitly required ridge minimum')
            return None, [{'reason': 'below declared minimum ridge run', 'length': self.length}]
        spec = component.mounting()
        if 'ridge-attachment' not in component.capability().tags:
            raise ContractError('ridge-capability', ctx.path)
        mid, peak = self.span // 2, surface.peak
        candidates, rejected = [], []
        for start in range(2, self.length - spec.length - 1):
            frame = Frame((mid, peak, start))
            region = frame.box(spec.envelope)
            footprint = [(x, surface.height(mid + x, start + z) - peak, z) for x, _, z in spec.footprint.cells()]
            reason = None
            if min(y for _, y, _ in footprint) < spec.lower:
                reason = 'roof below accepted bearing depth'
            elif any(region.contains((mid, peak + 1, end)) for end in surface.ridge):
                reason = 'ridge end framing'
            elif ctx.view.protected(ctx.frame.box(region)):
                reason = 'protected circulation'
            elif any(name_of(ctx.state(q)) != 'air' for q in region.cells()):
                reason = 'existing structural volume'
            if reason:
                rejected.append({'start': start, 'reason': reason})
                continue
            candidates.append((abs(start + (spec.length - 1) / 2 - (self.length - 1) / 2), start, frame, region, footprint))
        if not candidates:
            if self.design.ridge_required:
                raise ContractError('ridge-fit', ctx.path, ctx.frame.origin, 'No compatible bounded ridge mount: ' + str(rejected or ['insufficient run length']))
            return None, rejected or [{'reason': 'insufficient run length'}]
        best = min(c[0] for c in candidates)
        feasible = sorted((c for c in candidates if c[0] <= best + 2), key=lambda c: c[1])
        _, start, frame, region, footprint = ctx.rng('ridge-layout').choice(feasible)
        # Four retained roof cells meet the frame along the ridge direction.
        # They remain clear of lower dormer throats beside the lantern body.
        anchors = tuple((x, surface.height(mid + x, start + z) - peak, z) for x in (-2, 2) for z in (-1, spec.length))
        facts = {'length': spec.length, 'surface': footprint, 'anchors': anchors, 'coordinate_space': 'port-local', 'edit_limit': 5 * spec.length * (1 - spec.lower) + spec.length, 'feasible_starts': [c[1] for c in candidates]}
        return Port('ridge', 'ridge-installation', frame, region, 1, 'ridge', facts=facts), rejected

    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),)))
        if self.populate and contract.decisions['ridge_mount']:
            p.children.append(Child('ridge-light', self.design.ridge_component, frame=Frame(tuple(contract.decisions['ridge_mount'])), bindings=(Binding(ctx.path, 'ridge'),)))
        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.get('height_limit', 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
