# Room-graph building composition and shared facade/roof planning.
from dataclasses import dataclass
from hearth.space import Box, Frame
from hearth.kernel import Capability, Contract, Domain, Port, Plan, Child, Choice, Binding, Rule, ContractError
from hearth.adapters.site import survey, Foundation, Approach, SiteLimits
from .interior import Room
from .roofs import Roof, Porch


@dataclass(frozen=True)
class RoomSpec:
    x: int
    z: int
    level: int
    purpose: str


@dataclass(frozen=True)
class Blueprint:
    rooms: tuple[RoomSpec, ...]
    bay: int = 11
    porch: bool = True
    roof_axis: str = 'auto'
    roof_material: str = 'deepslate_tile'
    steep: bool = True
    window_component: object = None
    light_component: object = None
    roof_design: object = None
    porch_depth: int = 3
    balcony: bool = False
    chimney: bool = False
    facade_design: object = None
    rail_material: str = 'spruce'
    attic_purpose: str | None = None
    porch_bays: int = 1

    def normalized(self):
        return tuple(sorted(self.rooms, key=lambda r: (r.level, r.z, r.x, r.purpose)))

    @property
    def footprint(self):
        return tuple(sorted((r.x * self.bay + x, r.z * self.bay + z) for r in self.rooms if r.level == 0 for x in range(self.bay) for z in range(self.bay)))

    @property
    def chimney_anchor(self):
        back = max(r.z for r in self.rooms if r.level == 0)
        column = min(r.x for r in self.rooms if r.level == 0 and r.z == back)
        return (column * self.bay + self.bay // 2 - 1, (back + 1) * self.bay)

    @property
    def contact_footprint(self):
        cells = set(self.footprint)
        if self.porch:
            cells |= {(x, z) for x in range(self.bay * self.porch_bays) for z in range(-self.porch_depth, 0)}
        if self.chimney:
            cx, cz = self.chimney_anchor
            cells |= {(cx + x, cz + z) for x in range(3) for z in range(2)}
        return tuple(sorted(cells))

    def validate(self):
        cells = {(r.x, r.z, r.level) for r in self.rooms}
        if len(cells) != len(self.rooms) or (0, 0, 0) not in cells or min(min(r.x, r.z, r.level) for r in self.rooms) < 0:
            raise ValueError('Unique nonnegative rooms and entrance room (0,0,0) required')
        if self.bay not in (9, 11, 13):
            raise ValueError('Supported room bays: 9, 11, 13')
        if not 3 <= self.porch_depth <= 5:
            raise ValueError('Porch depth must be 3..5')
        if self.porch_bays < 1 or not isinstance(self.porch_bays, int):
            raise ValueError('Porch bays must be a positive integer')
        if self.porch and any((x, 0, 0) not in cells for x in range(self.porch_bays)):
            raise ValueError('Porch bays require contiguous front rooms')
        if self.rail_material not in ('spruce', 'dark_oak'):
            raise ValueError('Rail material must be spruce or dark_oak')
        if self.attic_purpose not in (None, 'bedroom', 'library', 'storage'):
            raise ValueError('Attic purpose must be bedroom, library, storage or None')
        if self.attic_purpose is not None and self.roof_design is None:
            raise ValueError('Habitable attics require an explicit roof policy with roof-interior capability')
        if self.balcony and (not self.porch or (0, 0, 1) not in cells):
            raise ValueError('Balcony requires porch and front upper room')
        if self.balcony and any((x, 0, 1) not in cells for x in range(self.porch_bays)):
            raise ValueError('Balcony requires an upper room behind each porch bay')
        for x, z, level in cells:
            if level and (x, z, level - 1) not in cells:
                raise ValueError('Upper rooms require a supporting lower room')
        reached = {(0, 0, 0)}
        while True:
            more = {p for p in cells if any(sum(abs(a - b) for a, b in zip(p, q)) == 1 for q in reached)}
            if more <= reached:
                break
            reached |= more
        if reached != cells:
            raise ValueError('Room adjacency graph disconnected')


def roof_runs(rooms, preferred_axis='auto'):
    """Partition exposed room tops into contiguous runs with explicit ridge axes."""
    cells = {(r.x, r.z, r.level) for r in rooms}
    top = {q for q in cells if (q[0], q[1], q[2] + 1) not in cells}
    groups = []
    while top:
        x, z, level = min(top, key=lambda v: (v[2], v[1], v[0]))
        axis = preferred_axis
        if axis == 'auto':
            axis = 'x' if (x + 1, z, level) in top else 'z'
        run = [(x, z, level)]
        while (run[-1][0] + (axis == 'x'), run[-1][1] + (axis == 'z'), level) in top:
            run.append((run[-1][0] + (axis == 'x'), run[-1][1] + (axis == 'z'), level))
        top -= set(run)
        groups.append((x, z, level, axis, len(run)))
    return tuple(groups)


@dataclass(frozen=True)
class Building:
    design: Blueprint
    terrain: str
    site_limits: SiteLimits = SiteLimits()
    support_strategy: str | None = None

    def capability(self):
        return Capability('building.rooms', ('building', 'habitable', 'composite'), offers=('entrance', 'street', 'attachment', 'footprint-boundary'), assumptions=('known ground', 'bounded relief'), adaptations=('foundation', 'approach', 'roof junction'), guarantees=('locked room graph', 'internal reachability', 'furnishing', 'lighting'), inputs={'design.bay': Domain(choices=(9, 11, 13)), 'room_graph': 'connected integer grid with supported levels', 'relief_limit': self.site_limits.relief, 'support_height_limit': self.site_limits.support_height, 'frame.turn': Domain(choices=(0, 1, 2, 3))}, locked=('design.rooms', 'design.bay', 'design.roof_axis', 'design.porch', 'design.attic_purpose', 'design.porch_bays', 'frame.x', 'frame.z'), version='3')

    def negotiate(self, ctx, parameters):
        self.design.validate()
        b = self.design.bay
        rooms = self.design.normalized()
        measured = survey(ctx, self.design.contact_footprint, self.site_limits)
        if self.support_strategy is not None:
            if self.support_strategy not in measured['feasible_strategies']:
                raise ContractError('contact-strategy', ctx.path, ctx.frame.origin, 'Requested strategy incompatible with measured water/relief')
            measured['strategy'] = self.support_strategy
        floor = measured['floor']
        height = max(r.level for r in rooms) * 6
        width = (max(r.x for r in rooms) + 1) * b
        depth = (max(r.z for r in rooms) + 1) * b
        minimum = min(v[0] for v in measured['samples'].values())
        front = self.site_limits.approach_length + (self.design.porch_depth if self.design.porch else 0)
        projection = 2 if self.design.facade_design and self.design.facade_design.planters else 1
        box = Box((-projection, min(minimum - self.site_limits.bed_thickness + 1, 0), -front), (width - 1 + projection, floor + height + b + 16, depth + (3 if self.design.chimney else projection - 1)))
        points = tuple((r.x * b + b // 2, floor + r.level * 6 + 1, r.z * b + b // 2) for r in rooms)
        if self.design.attic_purpose is not None:
            points += tuple((x * b + b * (length if axis == 'x' else 1) // 2, floor + level * 6 + 7, z * b + b * (length if axis == 'z' else 1) // 2) for x, z, level, axis, length in roof_runs(rooms, self.design.roof_axis))
        entrance = (b // 2, floor + 1, 0)
        route = Rule('route', (entrance,) + points, box, phase='complete')
        port = Port('entrance', 'access', Frame(entrance), Box(entrance, (entrance[0], entrance[1] + 1, entrance[2])), 10)
        decisions = {'floor': floor, 'relief': measured['relief'], 'wet': measured['wet'], 'foundation': measured['strategy'], 'rooms': [(r.x, r.z, r.level, r.purpose) for r in rooms], 'bay': b, 'roof_axis': self.design.roof_axis, 'porch': self.design.porch, 'roof_material': self.design.roof_material, 'steep': self.design.steep, 'balcony': self.design.balcony, 'porch_depth': self.design.porch_depth, 'chimney': self.design.chimney, 'attic_purpose': self.design.attic_purpose, 'porch_bays': self.design.porch_bays}
        approach = Approach(b // 2, floor, self.design.porch_depth if self.design.porch else 0, self.site_limits).negotiate(ctx, {})
        access = approach.ports[0]
        route = Rule('route', (entrance,) + points + (access.frame.origin,), box, phase='complete')
        street = Port('street', access.kind, access.frame, access.region, access.capacity, facts={'external_connection_required': True}, delegate=('approach', 'street'))
        footprint = set(self.design.contact_footprint)
        edge = tuple((x, 0, z) for x, z in sorted(footprint) if any((x + dx, z + dz) not in footprint for dx, dz in ((1, 0), (-1, 0), (0, 1), (0, -1))))
        boundary = Port('garden-edge', 'footprint-boundary', Frame(), Box.enclosing(edge), 100, facts={'outline': edge, 'coordinate_space': 'port-local', 'planting_authority': 'requires terrain planting binding'})
        return Contract(box, ports=(port, street, boundary), rules=(route,), decisions=decisions, reads=(Box((0, minimum, 0), (width - 1, floor, depth - 1)),))

    def realize(self, ctx, contract):
        p = Plan()
        b = self.design.bay
        floor = contract.decisions['floor']
        rooms = self.design.normalized()
        cells = {(r.x, r.z, r.level) for r in rooms}
        groups = roof_runs(rooms, self.design.roof_axis)
        roof_axes = {(x + i * (axis == 'x'), z + i * (axis == 'z'), level): axis for x, z, level, axis, length in groups for i in range(length)}
        peak = max(self.design.roof_design.heights(b)) if self.design.roof_design else ((b + 1) // 2 if self.design.steep else ((b + 1) // 2 + 1) // 2)
        p.children.append(Child('foundation', Foundation(self.design.contact_footprint, floor, contract.decisions['foundation'], self.site_limits), bindings=(Binding(self.terrain, 'construction', 'fill', ctx.frame.box(contract.envelope)),)))
        # A spanning tree of vertical access: one flight per upper horizontal component.
        stair_columns = set()
        attic_columns = {(x, z, level) for x, z, level, _, _ in groups} if self.design.attic_purpose else set()
        for level in sorted({r.level for r in rooms if r.level}):
            remaining = {(x, z) for x, z, l in cells if l == level}
            while remaining:
                seed = min(remaining)
                connected = {seed}
                while True:
                    expanded = {v for v in remaining if any(abs(v[0] - q[0]) + abs(v[1] - q[1]) == 1 for q in connected)}
                    if expanded <= connected:
                        break
                    connected |= expanded
                root = min(connected)
                stair_columns.add((root[0], root[1], level - 1))
                remaining -= connected
        stair_columns |= attic_columns
        stair_axes = {(x, z): axis for x, z, _, axis, _ in groups} if self.design.attic_purpose else {}
        for r in rooms:
            adjacent = {'north': (r.x, r.z - 1, r.level), 'south': (r.x, r.z + 1, r.level), 'west': (r.x - 1, r.z, r.level), 'east': (r.x + 1, r.z, r.level)}
            doors = tuple(side for side, q in adjacent.items() if q in cells or (side == 'north' and (r.x, r.z, r.level) in ({(0, 0, 0), (0, 0, 1)} if self.design.balcony else {(0, 0, 0)})))
            # A steep roof can reach more than one storey of its neighbor. Use
            # every lower roof's public height profile, not a level-1 shortcut.
            contacts = {side: [(lower, axis) for (rx, rz, lower), axis in roof_axes.items() if (rx, rz) == (x, z) and lower < r.level and lower * 6 + 7 + peak >= r.level * 6 + 1] for side, (x, z, _) in adjacent.items()}
            abutments = {side for side, roofs in contacts.items() if any(axis == ('z' if side in ('north', 'south') else 'x') for _, axis in roofs)}
            windows = tuple(side for side, q in adjacent.items() if q not in cells and side not in abutments)
            exclusions = ()
            if self.design.chimney:
                cx, cz = self.design.chimney_anchor
                exclusions = (Box((cx - r.x * b, 0, cz - r.z * b - 1), (cx - r.x * b + 2, 9, cz - r.z * b + 1)),)
            key = f'room-{r.x}-{r.z}-{r.level}'
            flush = tuple(side for side, roofs in contacts.items() if roofs)
            if self.design.porch and r.x < self.design.porch_bays and (r.z, r.level) == (0, 1):
                flush = tuple(sorted(set(flush) | {'north'}))
            room = Room(r.purpose, b, b, doors=doors, windows=windows, upper_open=(r.x, r.z, r.level + 1) in cells or self.design.attic_purpose is not None and (r.x, r.z, r.level) in roof_axes, stair_up=(r.x, r.z, r.level) in stair_columns, stair_down=(r.x, r.z, r.level - 1) in stair_columns, window_component=self.design.window_component, light_component=self.design.light_component, sheltered_sides=('north',) if self.design.porch and r.x < self.design.porch_bays and r.z == 0 and r.level <= 1 else (), flush_sides=flush, facade_exclusions=exclusions, facade_design=self.design.facade_design, occluded_sides=tuple(sorted(abutments)), stair_axis=stair_axes.get((r.x, r.z), 'z'))
            p.children.append(Child(key, room, frame=Frame((r.x * b, floor + r.level * 6, r.z * b))))
        from .ceiling import CeilingTimbers
        ceiling_children = []
        for r in rooms:
            key = f'room-{r.x}-{r.z}-{r.level}'
            ceiling_children.append(Child('ceiling-' + key, CeilingTimbers(), frame=Frame((r.x * b, floor + r.level * 6 + 5, r.z * b)), bindings=(Binding(ctx.path + '/' + key, 'ceiling'),)))
        if not self.design.attic_purpose:
            p.children.extend(ceiling_children)
        if self.design.porch:
            p.children.append(Child('porch', Porch(b * self.design.porch_bays, self.design.porch_depth, self.design.balcony, self.design.rail_material, b // 2), frame=Frame((0, floor, 0)), bindings=(Binding(ctx.path + '/room-0-0-0', 'north', 'connect'),)))
        p.children.append(Child('approach', Approach(b // 2, floor, self.design.porch_depth if self.design.porch else 0, self.site_limits), bindings=(Binding(self.terrain, 'construction', 'surface', ctx.frame.box(contract.envelope)),)))
        if self.design.chimney:
            from .masonry import Chimney
            from hearth.adapters.bearing import BearingCourse
            cx, cz = self.design.chimney_anchor
            level = max(r.level for r in rooms if (r.x * b + b // 2 - 1, (r.z + 1) * b) == (cx, cz))
            peak = max(self.design.roof_design.heights(b)) if self.design.roof_design else (b + 1) // 2
            # The cap and flues finish three cells above the measured ridge.
            stack_height = level * 6 + 7 + peak + 1
            p.children.append(Child('chimney-bearing', BearingCourse(3, 2), frame=Frame((cx, floor - 1, cz)), bindings=(Binding(self.terrain, 'construction', 'fill'),)))
            p.children.append(Child('chimney', Chimney(stack_height), frame=Frame((cx, floor, cz))))
        # Roof and facade planners use the same public run partition.
        if self.design.attic_purpose:
            from .lofts import RoofPlatform
            for i, (x, z, l, axis, length) in enumerate(groups):
                w = b * (length if axis == 'x' else 1)
                d = b * (length if axis == 'z' else 1)
                p.children.append(Child(f'roof-floor-{i}', RoofPlatform(w, d, axis), frame=Frame((x * b, floor + l * 6 + 6, z * b)), bindings=(Binding(ctx.path + f'/room-{x}-{z}-{l}', 'upper-floor', 'connect'),)))
        roof_order = list(enumerate(groups))
        if self.design.attic_purpose:
            # Establish the taller structural gables before lower ornamental ends.
            # The lower covering can then negotiate against their actual solids.
            roof_order.sort(key=lambda item: (-item[1][2], item[0]))
        for i, (x, z, l, axis, length) in roof_order:
            w = b * (length if axis == 'x' else 1)
            d = b * (length if axis == 'z' else 1)
            if self.design.attic_purpose:
                from .lofts import InhabitedRoof
                cover = InhabitedRoof(w, d, axis, self.design.roof_material, self.design.roof_design, self.design.attic_purpose)
                p.children.append(Child(f'roof-{i}', cover, frame=Frame((x * b, floor + l * 6 + 6, z * b)), bindings=(Binding(ctx.path + f'/roof-floor-{i}', 'seat', 'connect'),)))
            else:
                cover = self.design.roof_design.component(w, d, axis, self.design.roof_material) if self.design.roof_design else Roof(w, d, axis, self.design.roof_material, self.design.steep)
                p.children.append(Child(f'roof-{i}', cover, frame=Frame((x * b, floor + l * 6 + 7, z * b))))
        if self.design.attic_purpose:
            p.children.extend(ceiling_children)
        return p


def contact_quality(view, path):
    """Score actual candidate sitework plus exposed-support proportions."""
    facts = view.contract(path).decisions
    cost = view.cost(path, 'siteworks')
    exposure = 0.4 if facts['foundation'] == 'piers' and not facts['wet'] and facts['relief'] < 2 else 0
    return cost['occupied'] / 100 + cost['replaced'] / 50 + exposure


def adapted(key, design, terrain, frame=Frame(), limits=SiteLimits()):
    """Compare complete bounded support alternatives with the principal design locked."""
    candidates = tuple(Child(key, Building(design, terrain, limits, strategy), frame=frame) for strategy in ('stepped', 'piers'))
    return Choice(key, candidates, contact_quality)
