# 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'

    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) 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.rail_material not in ('spruce', 'dark_oak'):
            raise ValueError('Rail material must be spruce or dark_oak')
        if self.balcony and (not self.porch or (0, 0, 1) not in cells):
            raise ValueError('Balcony requires porch and front upper room')
        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', 'frame.x', 'frame.z'), version='2')

    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)
        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}
        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()
        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
        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, r.z, r.level) == (0, 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,
                        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 == 0 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)))
            p.children.append(Child(key, room, frame=Frame((r.x * b, floor + r.level * 6, r.z * b))))
        from .ceiling import CeilingTimbers
        for r in rooms:
            key = f'room-{r.x}-{r.z}-{r.level}'
            p.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 self.design.porch:
            p.children.append(Child('porch', Porch(b, self.design.porch_depth, self.design.balcony, self.design.rail_material), 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
            stack_height = level * 6 + 7 + peak + 4
            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.
        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)
            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))))
        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)
