# Domestic components, facade exclusions and planted boundaries through public contracts.
from dataclasses import dataclass, replace
import pytest
from hearth import Scene, Box, Frame, Binding, ContractError
from hearth.kernel import Capability, Contract, Plan, validate
from hearth.environment import Terrain, constant, gradient, wave
from hearth.components import Wall, Window, WindowBox, DomesticSuite, GardenPlant, BoundaryPlanting, Blueprint, RoomSpec, Building, FacadeDesign
from hearth.components.primitives import Volume
from hearth.lodges import lodge_scene, hillside_home
from hearth.blocks import name_of, state_of, parse
from hearth.persistence import export_scene, load_scene, encode
from test.test_building_quality import snapshot, damaged


@pytest.mark.parametrize('bay', (9, 11, 13))
def test_service_exclusion_retains_rear_daylight_and_locked_rooms(bay):
    design = Blueprint((RoomSpec(0, 0, 0, 'living'), RoomSpec(0, 0, 1, 'bedroom')),
                       bay=bay, chimney=True, balcony=True, facade_design=FacadeDesign(), rail_material='dark_oak')
    scene = lodge_scene(design, 71, garden=False)
    for level in (0, 1):
        room = f'/building/room-0-0-{level}'
        windows = [n for n in scene.nodes.values() if n.path.startswith(room + '/window-south') and n.type.startswith('window.')]
        assert len(windows) == 2
        glass = {p for n in windows for p in scene.cells(n.path) if name_of(scene.view.state(p)) == 'glass'}
        assert len(glass) == 12
        stack = scene.cells('/building/chimney')
        assert not glass.intersection(stack)
    assert scene.nodes['/building'].contract.decisions['rooms'] == [(r.x, r.z, r.level, r.purpose) for r in design.normalized()]
    assert not validate(scene, True)


@pytest.mark.parametrize('turn', range(4))
def test_external_window_box_attachment_roundtrip_and_regeneration(turn, work):
    frame = Frame((15, 0, -12), turn)
    scene = Scene(52, domain=frame.box(Box((-4, -2, -5), (16, 20, 8))))
    scene.place('land', Terrain(scene.domain, constant(0)))
    scene.place('wall', Wall(11), frame=frame.compose(Frame((0, 1, 0))))
    scene.place('window', Window(2, 3), frame=frame.compose(Frame((2, 2, 0))), bindings=(Binding('/wall', 'infill'),))
    mount = scene.view.offers('/window', 'window-garden')[0]
    scene.place('flowers', WindowBox(), frame=mount.frame, bindings=(Binding('/window', mount.key),))
    scene.finalize()
    point = next(p for p in scene.cells('/flowers') if name_of(scene.view.state(p)) == 'rooted_dirt')
    assert [n['id'] for n in scene.inspect(point)['chain']][:3] == ['/flowers', '/window', '/wall']
    assert scene.inspect(point)['instance'] == '/flowers'
    assert scene.cells('/flowers').isdisjoint(scene.cells('/window'))
    path = work / 'window garden.litematic'
    export_scene(scene, path)
    restored = load_scene(path)
    assert restored.inspect(point) == scene.inspect(point)
    assert encode(restored.nodes) == encode(scene.nodes)
    original = scene.cells('/flowers')
    scene.remove('/flowers')
    assert '/flowers' not in scene.nodes and '/flowers' not in scene.reverse
    assert all(scene.inspect(q).get('instance') != '/flowers' for q in original)
    scene.place('flowers', WindowBox(('allium',)), frame=mount.frame, bindings=(Binding('/window', mount.key),))
    assert scene.cells('/flowers') == original
    assert not validate(scene.finalize(), True)


def test_window_box_missing_anchor_bad_frame_and_overflow_are_rejected():
    scene = Scene(2, domain=Box((-4, -2, -5), (15, 20, 8)))
    scene.place('land', Terrain(scene.domain, constant(0)))
    scene.place('wall', Wall(11), frame=Frame((0, 1, 0)))
    scene.place('window', Window(2, 3), frame=Frame((2, 2, 0)), bindings=(Binding('/wall', 'infill'),))
    port = scene.view.port('/window', 'garden')
    before = snapshot(scene)
    with pytest.raises(ContractError, match='window-box-interface'):
        scene.place('misaligned', WindowBox(), frame=Frame(port.frame.origin, 1), bindings=(Binding('/window', 'garden'),))
    assert snapshot(scene) == before

    @dataclass(frozen=True)
    class Overflow:
        def capability(self):
            return Capability('test.planter-overflow', ('planter',))

        def negotiate(self, ctx, parameters):
            return Contract(Box((0, 0, 0), (2, 1, 1)))

        def realize(self, ctx, contract):
            p = Plan()
            p.fill(contract.envelope, state_of('dirt'))
            return p
    with pytest.raises(ContractError, match='write-authority|permission'):
        scene.place('overflow', Overflow(), frame=port.frame, bindings=(Binding('/window', 'garden'),))
    assert snapshot(scene) == before
    scene.place('flowers', WindowBox(), frame=port.frame, bindings=(Binding('/window', 'garden'),))
    scene.finalize()
    soil = next(p for p in scene.cells('/flowers') if name_of(scene.view.state(p)) == 'rooted_dirt')
    assert any(d.rule == 'support' and d.component == '/flowers' for d in validate(damaged(scene, soil), True))


@pytest.mark.parametrize('purpose', ('gathering', 'study', 'cooking', 'craft', 'cabinet'))
@pytest.mark.parametrize('width', (2, 3))
def test_domestic_station_function_apron_support_and_nbt(purpose, width, work):
    scene = Scene(6, domain=Box((-2, -2, -2), (8, 12, 8)))
    scene.place('land', Terrain(scene.domain, constant(0)))
    scene.place('station', DomesticSuite(purpose, width), frame=Frame((1, 1, 1)))
    scene.finalize()
    port = scene.view.offers('/station', 'interaction')[0]
    before = snapshot(scene)
    with pytest.raises(ContractError, match='clearance'):
        scene.place('obstruction', Volume(port.region, state_of('stone')))
    assert snapshot(scene) == before
    support = (1, 0, 1)
    assert 'support' in {d.rule for d in validate(damaged(scene, support), True)}
    required = next(r for r in scene.nodes['/station'].contract.rules if r.kind == 'furnishing').data['groups'][0]
    broken = scene
    for p in scene.cells('/station'):
        if name_of(scene.view.state(p)) in required:
            broken = damaged(broken, p)
    assert 'furnishing' in {d.rule for d in validate(broken, True)}
    path = work / 'station.litematic'
    export_scene(scene, path)
    restored = load_scene(path)
    assert {p: c.nbt for p, c in restored.blocks.items() if c.nbt} == {p: c.nbt for p, c in scene.blocks.items() if c.nbt}


def test_whole_tall_garden_plant_and_missing_substrate():
    scene = Scene(5, domain=Box((-2, -2, -2), (8, 12, 8)))
    scene.place('land', Terrain(scene.domain, constant(0)))
    scene.place('rose', GardenPlant('rose_bush'), frame=Frame((2, 1, 2)), bindings=(Binding('/land', 'planting', 'plant'),))
    scene.finalize()
    assert len(scene.cells('/rose')) == 2
    assert 'plant-pair' in {d.rule for d in validate(damaged(scene, (2, 2, 2)), True)}
    assert 'support' in {d.rule for d in validate(damaged(scene, (2, 0, 2)), True)}
    before = snapshot(scene)
    with pytest.raises(ContractError):
        scene.place('unsupported', GardenPlant(), frame=Frame((4, 3, 4)), bindings=(Binding('/land', 'planting', 'plant'),))
    assert snapshot(scene) == before


def test_border_uses_public_transformed_boundary_and_preserves_scene():
    design = replace(hillside_home(0), bay=11)
    scene = lodge_scene(design, 19, field=gradient(.06, -.02, 2) + wave(.35, .2, .1, 1), garden=False)
    before = dict(scene.blocks)
    scene.place('border', BoundaryPlanting('/building', '/land', density=1))
    scene.finalize()
    assert scene.cells('/border', True)
    assert all(scene.blocks[p] == cell for p, cell in before.items())
    assert scene.view.offers('/building', 'footprint-boundary')
    assert ('/border', 'landscapes', '/building') in scene.relations
    assert all(name_of(scene.view.state((p[0], p[1] - 1, p[2]))) != 'water' for n in scene.nodes.values() if n.type.startswith('plant.garden') for p in (n.frame.origin,))


def test_contrasting_balcony_guards_are_actual_and_damage_checked():
    scene = lodge_scene(hillside_home(0), 0, garden=False)
    guards = [r for r in scene.nodes['/building/porch'].contract.rules if r.kind == 'expected'][0]
    assert len(guards.cells) > 10
    assert all(name_of(scene.view.state(p)) == 'dark_oak_fence' for p in guards.cells)
    assert 'expected-geometry' in {d.rule for d in validate(damaged(scene, guards.cells[0]), True)}


@pytest.mark.parametrize('turn', range(4))
def test_ceiling_members_preserve_actual_stair_headroom_and_rotate(turn):
    from hearth.components import CeilingTimbers
    frame = Frame((20, 0, -15), turn)
    domain = frame.box(Box((-8, -3, -18), (35, 64, 35)))
    scene = Scene(13, domain=domain)
    scene.place('land', Terrain(domain, constant(2)))
    design = Blueprint((RoomSpec(0, 0, 0, 'living'), RoomSpec(0, 0, 1, 'bedroom')), chimney=True, facade_design=FacadeDesign())
    scene.place('house', Building(design, '/land'), frame=frame)
    scene.finalize()
    beams = [n for n in scene.nodes.values() if n.type == 'interior.ceiling-timbers']
    assert len(beams) == 2 and all(scene.cells(n.path) for n in beams)
    stairs = scene.nodes['/house/room-0-0-0/stairs']
    clear = {p for r in stairs.contract.rules if r.kind == 'clear' for p in r.cells}
    assert not clear.intersection(set().union(*(scene.cells(n.path) for n in beams)))
    point = min(scene.cells(beams[0].path))
    assert [n['id'] for n in scene.inspect(point)['chain']][1] == '/house/room-0-0-0'
    assert 'expected-geometry' in {d.rule for d in validate(damaged(scene, point), True)}
    anchor = next(r.cells[0] for r in beams[0].contract.rules if r.kind == 'support')
    assert 'support' in {d.rule for d in validate(damaged(scene, anchor), True)}
    before = snapshot(scene)
    mount = scene.view.port('/house/room-0-0-0', 'ceiling')
    with pytest.raises(ContractError, match='ceiling-interface'):
        scene.place('bad-mount', CeilingTimbers(), frame=Frame(mount.frame.origin, (turn + 1) % 4), bindings=(Binding('/house/room-0-0-0', 'ceiling', 'connect'),))
    assert snapshot(scene) == before


def test_optional_planting_does_not_change_unrelated_building_branch():
    a = lodge_scene(hillside_home(0), 8, garden=False)
    b = a.fork()
    # The border has no ground-edit authority. GardenCourt as a whole intentionally
    # replaces the building's street endpoint when it joins its path to the approach.
    b.place('border', BoundaryPlanting('/building', '/land'))
    b.finalize()
    cells = a.cells('/building', True)
    assert cells == b.cells('/building', True)
    keys = ('state', 'instance', 'chain', 'operation', 'nbt', 'shared')
    assert {p: {k: a.inspect(p)[k] for k in keys} for p in cells} == {p: {k: b.inspect(p)[k] for k in keys} for p in cells}
    # A new typed "landscapes" association is expected; principal structure is stable.
    assert encode({k: n for k, n in a.nodes.items() if k.startswith('/building')}) == encode({k: n for k, n in b.nodes.items() if k.startswith('/building')})


def test_boundary_and_facade_degenerate_domains_are_explicit():
    with pytest.raises(ValueError, match='Facade'):
        FacadeDesign(height=3, sill=2).openings(11)
    assert not FacadeDesign().openings(11, exclusions=(Box((0, 0, -1), (10, 4, 0)),))
    scene = lodge_scene(hillside_home(0), 12, garden=False)
    old = dict(scene.blocks)
    scene.place('empty-border', BoundaryPlanting('/building', '/land', density=0))
    assert scene.blocks == old and not scene.cells('/empty-border', True)
    before = snapshot(scene)
    with pytest.raises(ContractError, match='input-domain'):
        scene.place('invalid-density', BoundaryPlanting('/building', '/land', density=1.1))
    assert snapshot(scene) == before


@pytest.mark.parametrize('width,height,sill', ((1, 2, 1), (1, 3, 1), (2, 2, 2)))
def test_public_facade_dimensions_and_framed_boxes(width, height, sill):
    design = Blueprint((RoomSpec(0, 0, 0, 'library'),), bay=13, porch=False,
                       facade_design=FacadeDesign(height=height, sill=sill, width=width))
    scene = lodge_scene(design, 31, garden=False)
    boxes = [n for n in scene.nodes.values() if n.type == 'garden.window-box']
    assert boxes and all(n.contract.decisions['framed'] for n in boxes)
    assert scene.nodes['/building/room-0-0-0'].contract.decisions['facade_projection'] == 2
    front = next(p for p in scene.cells(boxes[0].path) if name_of(scene.view.state(p)) == 'spruce_trapdoor')
    assert 'expected-geometry' in {d.rule for d in validate(damaged(scene, front), True)}
    for n in scene.nodes.values():
        if n.type == 'window.shuttered':
            assert sum(name_of(scene.view.state(p)) == 'glass' for p in scene.cells(n.path)) == width * height
