# Reusable diagnostics over final or reloaded geometry and explicit design metadata.
from __future__ import annotations

from collections import deque
from dataclasses import dataclass, field, asdict
from pathlib import Path

from .components import SITE_COMPONENTS, BUILTIN_FEATURES
from .blocks import Grid, AIR, registry, split_state, name, is_air, passable, seal, full_support, floor_support, blockstates_equivalent
from .materials import DIRECTIONS
from .navigation import flood, walkable


@dataclass(frozen=True)
class Diagnostic:
    """Identify a failed rule, responsible component and optional world coordinate."""
    rule: str
    component: str
    message: str
    location: tuple | None = None


@dataclass
class Report:
    """Collect reusable validation results without raising until explicitly requested."""
    diagnostics: list[Diagnostic] = field(default_factory=list)
    checked: dict = field(default_factory=dict)

    @property
    def valid(self):
        return not self.diagnostics

    def add(self, rule, component, message, location=None):
        self.diagnostics.append(Diagnostic(rule, component, message, tuple(location) if location is not None else None))

    def require_valid(self):
        if not self.valid:
            details = '\n'.join(f'{d.rule}: {d.component} {d.location or ""}: {d.message}' for d in self.diagnostics[:25])
            raise ValidationError(f'{len(self.diagnostics)} architectural validation failures\n{details}', self)
        return self

    def to_dict(self):
        return {'valid': self.valid, 'checked': self.checked, 'diagnostics': [asdict(d) for d in self.diagnostics]}


class ValidationError(ValueError):
    """Carry the full report for programmatic error handling."""

    def __init__(self, message, report):
        super().__init__(message)
        self.report = report


RULES = []


def validation_rule(function):
    """Register an additional pure geometry rule receiving grid, metadata and report."""
    if function not in RULES:
        RULES.append(function)
    return function


def validate(building, metadata=None) -> Report:
    """Validate an artifact, sparse grid or reloaded path with explicit design metadata.

    Args:
        building: Building, Grid, or Path to a Litematic with a sidecar.
        metadata: Optional metadata dictionary or sidecar Path for reloaded geometry.

    Returns:
        Report: Diagnostics with component and coordinate information.
    """
    if isinstance(building, (str, Path)):
        g = Grid.load(Path(building), metadata)
    else:
        g = building if isinstance(building, Grid) else building.grid
    m = g.metadata if metadata is None or isinstance(metadata, Path) else metadata
    report = Report()
    if m.get('schema') != 1 or any(k not in m for k in ('rooms', 'routes', 'start', 'interior')):
        report.add('metadata.schema', 'artifact', 'schema 1 architecture metadata with rooms, routes, start and interior is required')
        return report
    for rule in (states_and_bounds, multiblocks, attachments, circulation, envelope, furnishing, *RULES):
        rule(g, m, report)
    for kind in {f['kind'] for f in m.get('features', [])}:
        if kind in SITE_COMPONENTS and SITE_COMPONENTS[kind].rule:
            SITE_COMPONENTS[kind].rule(g, m, report)
    for feature in m.get('resolved', {}).get('features', []):
        kind = feature['kind']
        if kind not in BUILTIN_FEATURES:
            if kind not in SITE_COMPONENTS:
                report.add('component.missing', kind, 'register the recorded site component to run its validation rules')
            elif feature.get('version') != SITE_COMPONENTS[kind].version:
                report.add('component.version', kind, 'registered component version differs from the exported design')
    report.checked['blocks'] = len(g.blocks)
    return report


def states_and_bounds(g, m, report):
    """Validate every explicit property and derived or recorded bounds."""
    domains = registry()
    for state in sorted(set(g.blocks.values())):
        try:
            n, props = split_state(state)
            short = n.removeprefix('minecraft:')
            if short not in domains:
                raise ValueError('unknown block')
            if any(k not in domains[short][0] or v not in domains[short][0][k] for k, v in props.items()):
                raise ValueError('unknown property or value')
        except (TypeError, ValueError) as exc:
            point = next(p for p, s in g.blocks.items() if s == state)
            report.add('block.state', 'geometry', f'{state}: {exc}', point)
    if 'size_xyz' in m:
        ox, oy, oz = m['origin']
        sx, sy, sz = m['size_xyz']
        for p in g.blocks:
            if not (ox <= p[0] < ox + sx and oy <= p[1] < oy + sy and oz <= p[2] < oz + sz):
                report.add('bounds.export', 'geometry', 'block outside recorded schematic bounds', p)
                break
    b = g.bounds
    size = [b[i + 3] - b[i] + 3 for i in range(3)]
    limits = m.get('limits', {})
    if limits and (max(size[0], size[2]) > limits['horizontal'] or size[1] > limits['vertical'] or size[0] * size[1] * size[2] > limits['volume'] or len(g.blocks) > limits['blocks']):
        report.add('bounds.resources', 'geometry', f'geometry size {size} exceeds configured Limits')
    report.checked['block_states'] = len(set(g.blocks.values()))


def multiblocks(g, m, report):
    """Check both halves of doors and beds, as well as their actual support."""
    count = 0
    for (x, y, z), state in g.blocks.items():
        n = name(state)
        if n.endswith('_door'):
            _, props = split_state(state)
            dy = 1 if props.get('half') == 'lower' else -1
            other = g.get(x, y + dy, z)
            on, op = split_state(other)
            expected = dict(props, half='upper' if dy == 1 else 'lower')
            if on != 'minecraft:' + n or op != expected:
                report.add('multiblock.door', 'door', 'matching opposite door half is missing or inconsistent', (x, y, z))
            if dy == 1 and not floor_support(g.get(x, y - 1, z)):
                report.add('support.door', 'door', 'door has no solid threshold', (x, y, z))
            count += 1
        elif n.endswith('_bed'):
            _, props = split_state(state)
            dx, dz = DIRECTIONS.get(props.get('facing'), (0, 0))
            sign = 1 if props.get('part') == 'foot' else -1
            other = g.get(x + sign * dx, y, z + sign * dz)
            on, op = split_state(other)
            if on != 'minecraft:' + n or op != dict(props, part='head' if sign == 1 else 'foot'):
                report.add('multiblock.bed', 'bed', 'adjacent matching bed head/foot is missing', (x, y, z))
            if not full_support(g.get(x, y - 1, z)):
                report.add('support.bed', 'bed', 'bed is unsupported', (x, y, z))
            count += 1
    report.checked['multiblock_cells'] = count


def attachments(g, m, report):
    """Check continuous supports, ladder backing, lantern suspension and plant substrate."""
    for support in m['supports']:
        x, y0, z = support['bottom']
        _, y1, _ = support['top']
        for y in range(y0, y1 + 1):
            if not full_support(g.get(x, y, z)):
                report.add('support.column', support['owner'], 'load path to ground or waterbed is interrupted', (x, y, z))
                break
    for (x, y, z), state in g.blocks.items():
        n = name(state)
        if n == 'ladder':
            facing = split_state(state)[1].get('facing')
            dx, dz = DIRECTIONS.get(facing, (0, 0))
            if not full_support(g.get(x - dx, y, z - dz)):
                report.add('support.ladder', 'ladder', 'rung lacks a solid backing wall', (x, y, z))
        elif n == 'lantern':
            hanging = split_state(state)[1].get('hanging') == 'true'
            other = g.get(x, y + (1 if hanging else -1), z)
            if not full_support(other) and not name(other).endswith(('_fence', '_wall', '_slab')) and name(other) not in ('chain', 'lantern'):
                report.add('support.light', 'lantern', 'light has no supporting bracket or ceiling', (x, y, z))
        elif n in ('poppy', 'cornflower', 'allium', 'oxeye_daisy', 'short_grass', 'fern', 'dandelion'):
            if name(g.get(x, y - 1, z)) not in ('grass_block', 'dirt', 'rooted_dirt', 'moss_block', 'podzol', 'coarse_dirt'):
                report.add('support.plant', 'planting', 'invalid substrate', (x, y, z))
        elif n == 'wheat' and name(g.get(x, y - 1, z)) != 'farmland':
            report.add('support.crop', 'field', 'wheat needs farmland', (x, y, z))
        elif n == 'lily_pad' and name(g.get(x, y - 1, z)) != 'water':
            report.add('support.lily', 'water', 'lily pad needs water', (x, y, z))
    report.checked['support_columns'] = len(m['supports'])


def circulation(g, m, report):
    """Inspect final clearance and traverse actual doors, stairs and ladders."""
    resolved = m.get('resolved', {})
    if resolved.get('design', {}).get('site', {}).get('terrain') != 'water':
        dx, dz = DIRECTIONS[resolved.get('entrance', {}).get('side', 'south')]
        x, y, z = m['start']
        outside = (x + dx, y, z + dz)
        if not walkable(g, outside):
            report.add('reachability.terrain', 'approach', 'outer landing does not meet clear, walkable terrain', outside)
    for route in m['routes']:
        for x, y, z in route['points']:
            if any(not passable(g.get(x, y + dy, z)) for dy in range(route['headroom'])):
                report.add('clearance.route', route['owner'], 'reserved route has less than two clear headroom blocks', (x, y, z))
                break
            if not walkable(g, (x, y, z)):
                report.add('support.route', route['owner'], 'route lacks a usable floor or ladder', (x, y, z))
                break
    for stair in m['stairs']:
        state = g.get(*stair['point'])
        if not name(state).endswith('_stairs') or split_state(state)[1].get('facing') != stair['facing']:
            report.add('circulation.stair', stair['owner'], 'stair tread missing or faces against the ascent', stair['point'])
        x, y, z = stair['point']
        if not full_support(g.get(x, y - 1, z)):
            report.add('support.stair', stair['owner'], 'tread lacks solid support', stair['point'])
    try:
        reachable = flood(g, m['start'])
    except ValueError as exc:
        report.add('circulation.limit', 'artifact', str(exc))
        reachable = {}
    for room in m['rooms']:
        if tuple(room['anchor']) not in reachable:
            report.add('reachability.room', room['id'], 'room is unreachable from the exterior start', room['anchor'])
        for x, z in room['cells']:
            p = (x, room['floor'] + 1, z)
            if walkable(g, p) and p not in reachable:
                report.add('reachability.floor', room['id'], 'usable floor area is isolated from the entrance', p)
                break
    if tuple(m['entrance']) not in reachable:
        report.add('reachability.entrance', 'entrance', 'the actual approach does not reach the door', m['entrance'])
    for f in m['features']:
        if tuple(f['target']) not in reachable:
            report.add('reachability.site', f['owner'], 'outdoor working space is unreachable', f['target'])
    for f in m['furniture']:
        if tuple(f['access']) not in reachable:
            report.add('reachability.furniture', f['owner'], f'{f["kind"]} interaction space is blocked', f['access'])
    report.checked['reachable_positions'] = len(reachable)
    report.checked['rooms'] = len(m['rooms'])


def envelope(g, m, report):
    """Flood interior voids to detect leaks, including later damage to roofs and walls."""
    interior = {tuple(p) for p in m['interior']}
    visited = set()
    for room in m['rooms']:
        start = tuple(room['anchor'])
        if start in visited or seal(g.get(*start)):
            continue
        queue = deque([start])
        visited.add(start)
        failure = None
        while queue and failure is None:
            x, y, z = queue.popleft()
            for dx, dy, dz in ((1, 0, 0), (-1, 0, 0), (0, 1, 0), (0, -1, 0), (0, 0, 1), (0, 0, -1)):
                q = x + dx, y + dy, z + dz
                if q in visited or seal(g.get(*q)):
                    continue
                if q not in interior:
                    failure = q
                    break
                visited.add(q)
                queue.append(q)
        if failure is not None:
            report.add('envelope.leak', room['id'], 'interior air reaches outside its weather envelope', failure)
    for window in m['openings']:
        if window['kind'] != 'window':
            continue
        side = window['side']
        dx, dz = DIRECTIONS[side]
        for x, y, z in window['cells']:
            if 'glass' not in name(g.get(x, y, z)):
                report.add('opening.glazing', window['owner'], 'glazed aperture was overwritten', (x, y, z))
                break
            # A room-side full block obstructs light and view; roof cavities are exempt.
            q = x - dx, y, z - dz
            if q in interior and not passable(g.get(*q)):
                report.add('opening.clearance', window['owner'], 'window reveal is blocked inside the room', q)
                break
    for room in m['rooms']:
        for x, z in room['cells']:
            if name(g.get(x, room['floor'] + 1, z)) in ('water', 'dirt', 'grass_block', 'rooted_dirt', 'mud'):
                report.add('envelope.intrusion', room['id'], 'water or hillside material invades the usable room', (x, room['floor'] + 1, z))
                break
    report.checked['envelope_voids'] = len(visited)


def furnishing(g, m, report):
    """Require realized furniture, functional coverage and nearby light for every room."""
    for room in m['rooms']:
        furnished = {f['function'] for f in m['furniture'] if f['owner'] == room['id']}
        for function in room['functions']:
            if function not in furnished:
                report.add('program.missing', room['id'], f'no realized furniture for {function}')
        floor = room['floor']
        actual_lights = [record for record in m['lights'] if record['owner'] == room['id'] and name(g.get(*record['point'])) in ('lantern', 'glowstone', 'sea_lantern') and floor < record['point'][1] <= floor + 4]
        if not actual_lights:
            report.add('lighting.room', room['id'], 'room lacks an actual light near its occupied floor')
    for feature in m['features']:
        if not any(light['owner'] == feature['owner'] and name(g.get(*light['point'])) in ('lantern', 'glowstone', 'sea_lantern') for light in m['lights']):
            report.add('lighting.site', feature['owner'], 'important outdoor space lacks its actual light', feature['target'])
    for owner, target in (('entrance', m['entrance']), ('approach', m['start'])):
        nearby = [light for light in m['lights'] if name(g.get(*light['point'])) == 'lantern' and sum(abs(a - b) for a, b in zip(light['point'], target)) <= 10 and abs(light['point'][1] - target[1]) <= 4]
        if not nearby:
            report.add('lighting.approach', owner, 'public approach needs an actual nearby light', target)
    for f in m['furniture']:
        for p, expected in zip(f['cells'], f['states']):
            if not blockstates_equivalent(g.get(*p), expected):
                report.add('furnishing.changed', f['owner'], f'{f["kind"]} was removed or overwritten', p)
                break
    for record in m.get('inventories', []):
        p = tuple(record['point'])
        expected = [tuple(item) for item in record['items']]
        actual = [tuple(item) for item in g.inventories.get(p, [])]
        if expected != actual:
            report.add('inventory.changed', 'container', 'actual block entity inventory differs from the declared supplies', p)
    report.checked['furniture_groups'] = len(m['furniture'])
