# Extensible actual-geometry validators and bounded player movement.
from collections import deque
from hearth.blocks import AIR, LIGHTS, name_of, parse, passable, solid, validate_state
from hearth.kernel.errors import Diagnostic
from hearth.space import Box

VALIDATORS = {}


def validator(name):

    def register(fn):
        VALIDATORS[name] = fn
        return fn

    return register


def state(scene, p):
    record = scene.blocks.get(tuple(p))
    return record.state if record else AIR


def fail(kind, node, point, message):
    return Diagnostic(kind, node.path, point, message)


@validator('protected')
def protected(scene, node, rule):
    from hearth.blocks import blockstates_equivalent
    for p, expected in zip(rule.cells, rule.data['states']):
        if not blockstates_equivalent(state(scene, p), expected):
            yield fail('protected-region', node, p, 'Preserved geometry changed')


@validator('support')
def support(scene, node, rule):
    for p in rule.cells:
        if not solid(state(scene, p)):
            yield fail('support', node, p, 'Required actual load-bearing cell is missing')


@validator('clear')
def clear(scene, node, rule):
    for p in rule.cells:
        if not passable(state(scene, p)):
            yield fail('clearance', node, p, 'Protected player headroom is obstructed')


@validator('sealed')
def sealed(scene, node, rule):
    for p in rule.cells:
        if name_of(state(scene, p)) in ('air', 'water'):
            yield fail('enclosure', node, p, 'Enclosed boundary has a gap')


@validator('expected')
def expected(scene, node, rule):
    allowed = set(rule.data['names'])
    for p in rule.cells:
        if name_of(state(scene, p)) not in allowed:
            yield fail('expected-geometry', node, p, f'Expected one of {sorted(allowed)}')


@validator('pool')
def pool(scene, node, rule):
    for x, y, z in rule.cells:
        if name_of(state(scene, (x, y, z))) != 'water':
            yield fail('pool-water', node, (x, y, z), 'Pool water missing')
        for q in ((x, y - 1, z), (x - 1, y, z), (x + 1, y, z), (x, y, z - 1), (x, y, z + 1)):
            if not solid(state(scene, q)) and name_of(state(scene, q)) != 'water':
                yield fail('pool-containment', node, q, 'Basin leaks')


def standable(scene, p):
    x, y, z = p
    return passable(state(scene, p)) and passable(state(scene, (x, y + 1, z))) and solid(state(scene, (x, y - 1, z)))


def reachable(scene, start, box):
    if not standable(scene, start):
        return set()
    reached = {start}
    queue = deque([start])
    while queue:
        x, y, z = queue.popleft()
        for dx, dz in ((1, 0), (-1, 0), (0, 1), (0, -1)):
            for dy in (0, 1, -1):
                q = (x + dx, y + dy, z + dz)
                if q not in reached and box.contains(q) and standable(scene, q):
                    # Ascending requires clearance above the lower step as well.
                    high = (x, y + 2, z) if dy == 1 else (q[0], q[1] + 2, q[2])
                    if dy and not passable(state(scene, high)):
                        continue
                    reached.add(q)
                    queue.append(q)
    return reached


@validator('route')
def route(scene, node, rule):
    if not rule.cells or rule.box is None:
        yield fail('route-contract', node, None, 'Route needs start, targets and bounded search region')
        return
    reached = reachable(scene, rule.cells[0], rule.box)
    for p in rule.cells:
        if p not in reached:
            yield fail('reachability', node, p, 'No walkable route with two blocks of headroom from entrance')


@validator('light')
def light(scene, node, rule):
    sources = [p for p in rule.box.cells() if name_of(state(scene, p)) in LIGHTS]
    if not sources:
        yield fail('lighting', node, rule.box.lo, 'Completed room has no actual light source')
        return
    # Propagate actual sources through transmissive cells, with one unit attenuation.
    levels = {p: LIGHTS[name_of(state(scene, p))] for p in sources}
    queue = deque(sources)
    domain = rule.box.expand(1)
    while queue:
        p = queue.popleft()
        level = levels[p] - 1
        if level < rule.data.get('minimum', 1):
            continue
        for dx, dy, dz in ((1, 0, 0), (-1, 0, 0), (0, 1, 0), (0, -1, 0), (0, 0, 1), (0, 0, -1)):
            q = (p[0] + dx, p[1] + dy, p[2] + dz)
            if not domain.contains(q) or levels.get(q, 0) >= level:
                continue
            material = state(scene, q)
            if passable(material) or 'glass' in name_of(material) or name_of(material) == 'chain':
                levels[q] = level
                queue.append(q)
    for p in rule.cells:
        if levels.get(p, 0) < rule.data.get('minimum', 1):
            yield fail('lighting', node, p, 'Actual source attenuation/occlusion leaves required location dark')


@validator('furnishing')
def furnishing(scene, node, rule):
    names = {name_of(state(scene, p)) for p in rule.box.cells()}
    for group in rule.data['groups']:
        if not names.intersection(group):
            yield fail('furnishing', node, rule.box.lo, f'Missing functional furnishing: {group}')


def validate(scene, final=False, completed_scope=None):
    diagnostics = []
    for p, record in scene.blocks.items():
        try:
            validate_state(record.state)
        except ValueError as exc:
            diagnostics.append(Diagnostic('blockstate', record.owner, p, str(exc)))
            continue
        if p not in scene.reverse.get(record.owner, set()):
            diagnostics.append(Diagnostic('reverse-index', record.owner, p, 'Current cell absent from reverse index'))
        if record.owner not in scene.nodes:
            diagnostics.append(Diagnostic('ownership', record.owner, p, 'Owner missing'))
        if record.nbt is not None:
            if record.nbt.get('id') != 'minecraft:' + name_of(record.state):
                diagnostics.append(Diagnostic('block-entity', record.owner, p, 'Block entity type mismatches actual block'))
            slots = set()
            for item in record.nbt.get('Items', []):
                if not 0 <= item.get('Slot', -1) < 27 or item['Slot'] in slots or not 1 <= item.get('count', 0) <= 64 or not item.get('id', '').startswith('minecraft:'):
                    diagnostics.append(Diagnostic('inventory', record.owner, p, 'Invalid or duplicate inventory slot/item count'))
                slots.add(item.get('Slot'))
        name, props = parse(record.state)
        if name.endswith('_door') and not name.endswith('_trapdoor'):
            dy = 1 if props.get('half') == 'lower' else -1
            q = (p[0], p[1] + dy, p[2])
            other, op = parse(state(scene, q))
            if other != name or op.get('half') == props.get('half') or any(op.get(k) != props.get(k) for k in ('facing', 'hinge', 'open', 'powered')):
                diagnostics.append(Diagnostic('door-pair', record.owner, p, 'Door halves absent or inconsistent'))
        if name.endswith('_bed'):
            direction = {'north': (0, -1), 'south': (0, 1), 'east': (1, 0), 'west': (-1, 0)}[props['facing']]
            sign = 1 if props['part'] == 'foot' else -1
            q = (p[0] + direction[0] * sign, p[1], p[2] + direction[1] * sign)
            other, op = parse(state(scene, q))
            if name != other or op.get('part') == props['part'] or op.get('facing') != props['facing']:
                diagnostics.append(Diagnostic('bed-pair', record.owner, p, 'Bed halves inconsistent'))
        if name_of(record.state) in ('sunflower', 'rose_bush', 'peony', 'lilac', 'tall_grass', 'large_fern'):
            dy = 1 if props.get('half') == 'lower' else -1
            q = (p[0], p[1] + dy, p[2])
            other, op = parse(state(scene, q))
            if other != name or op.get('half') == props.get('half'):
                diagnostics.append(Diagnostic('plant-pair', record.owner, p, 'Tall plant halves inconsistent'))
    for owner, points in scene.reverse.items():
        for p in points:
            if p not in scene.blocks or scene.blocks[p].owner != owner:
                diagnostics.append(Diagnostic('reverse-index', owner, p, 'Obsolete current membership'))
    for node in scene.nodes.values():
        for port in node.contract.ports:
            if port.delegate and (node.complete or final):
                try:
                    _, actual = scene.view.resolved_port(node.path, port.key)
                    if (port.frame, port.region, port.kind) != (actual.frame, actual.region, actual.kind):
                        diagnostics.append(Diagnostic('forwarded-interface', node.path, port.frame.origin, 'Child interface changed without parent renegotiation'))
                except (KeyError, ValueError):
                    diagnostics.append(Diagnostic('forwarded-interface', node.path, port.frame.origin, 'Delegated port missing'))
        for parent in (node.parent, node.primary):
            if parent is not None and parent not in scene.nodes:
                diagnostics.append(Diagnostic('structure-graph', node.path, None, 'Current structural parent missing: ' + parent))
        if final and (not node.complete or node.stale):
            diagnostics.append(Diagnostic('unfinished', node.path, None, 'Scope unfinished or dependent inputs stale'))
        for rule in node.contract.rules:
            if rule.phase == 'complete' and not final:
                scope = node
                closed = True
                while scope:
                    closed = closed and scope.complete
                    scope = scene.nodes.get(scope.parent)
                if completed_scope and (node.path == completed_scope or node.path.startswith(completed_scope + '/')):
                    closed = node.complete
                if not closed:
                    continue
            if rule.kind not in VALIDATORS:
                diagnostics.append(Diagnostic('unknown-validator', node.path, None, rule.kind))
            else:
                diagnostics.extend(VALIDATORS[rule.kind](scene, node, rule))
    return diagnostics
