import os
import random
from typing import Any, Callable, Dict, List, SupportsInt, cast

from debug import trace as grownet_trace
from focus import FocusConfig, FocusEngine, FocusTickSummary
from metrics import RegionMetrics

try:
    from growth import GrowthPolicy, maybe_grow  # type: ignore
except Exception:
    GrowthPolicy = None  # type: ignore
    def maybe_grow(*args, **kwargs):  # type: ignore
        return False

# Layer and shape-aware layers are imported lazily to avoid import cycles.


class Region:
    """Orchestrates layers, port bindings, and ticks.

    Ports are modeled as edge layers. A tick drives the bound edge exactly once;
    downstream layers receive activity via wiring from that edge.
    """
    class PruneSummary:
        def __init__(self):
            self.prunedSynapses = 0
            self.prunedEdges = 0  # Tract-edge pruning is intentionally not implemented here.

    def __init__(self, name: str):
        self.name: str = name
        self.layers: List[Any] = []          # Any to keep static analyzers calm
        self.input_ports: Dict[str, List[int]] = {}
        self.output_ports: Dict[str, List[int]] = {}

        # Edge layers per port (created lazily on bind)
        self.input_edges: Dict[str, int] = {}
        self.output_edges: Dict[str, int] = {}

        # Region-scope bus (optional; some codebases keep only per-layer buses)
        self.bus = None
        self.rng = random.Random(1234)
        # Optional flag to compute spatial metrics in tick_2d (also gated by env var)
        self.enable_spatial_metrics = False
        # Optional proximity policy (sidecar) configuration
        self.proximity_config = None
        # Growth + wiring bookkeeping
        self.mesh_rules: List[Dict[str, Any]] = []   # [{'src':i,'dst':j,'prob':p,'feedback':bool}]
        self.tracts: List[Any] = []                 # Tract instances
        # Automatic region growth (policy + cooldown step)
        self.growth_policy = None
        self.last_layer_growth_step = -1
        self.active_tick_metrics: RegionMetrics | None = None
        self.focus_config = FocusConfig()
        self.focus_engine = FocusEngine(self.focus_config, self.rng)
        self.active_focus_selection = None
        self.last_focus_summary = self.focus_engine.summarize()

    def set_focus_config(self, focus_config: FocusConfig | None):
        self.focus_config = focus_config or FocusConfig()
        self.focus_engine = FocusEngine(self.focus_config, self.rng)
        self.active_focus_selection = None
        self.last_focus_summary = self.focus_engine.summarize()
        return self

    def get_focus_config(self) -> FocusConfig:
        return self.focus_config

    def get_focus_engine(self) -> FocusEngine:
        return self.focus_engine

    # ---------------- construction ----------------
    def add_layer(self, excitatory_count: int, inhibitory_count: int, modulatory_count: int) -> int:
        from layer import Layer
        layer = Layer(excitatory_count, inhibitory_count, modulatory_count)
        try:
            layer.set_region(self)
        except Exception:
            pass
        self.layers.append(layer)
        return len(self.layers) - 1

    def add_input_layer_2d(self, height: int, width: int, gain: float, epsilon_fire: float) -> int:
        from input_layer_2d import InputLayer2D
        layer = InputLayer2D(height, width, gain, epsilon_fire)
        try:
            layer.set_region(self)  # type: ignore[attr-defined]
        except Exception:
            pass
        self.layers.append(layer)
        return len(self.layers) - 1

    
    def add_input_layer_nd(self, shape: list[int], gain: float, epsilon_fire: float) -> int:
        from input_layer_nd import InputLayerND
        layer = InputLayerND(shape, gain, epsilon_fire)
        try:
            layer.set_region(self)  # type: ignore[attr-defined]
        except Exception:
            pass
        self.layers.append(layer)
        return len(self.layers) - 1


    def add_output_layer_2d(self, height: int, width: int, smoothing: float) -> int:
        from output_layer_2d import OutputLayer2D
        layer = OutputLayer2D(height, width, smoothing)
        try:
            layer.set_region(self)  # type: ignore[attr-defined]
        except Exception:
            pass
        self.layers.append(layer)
        return len(self.layers) - 1

    # ---------------- wiring ----------------
    def connect_layers(self, source_index: int, dest_index: int, probability: float, feedback: bool = False) -> int:
        """Create random synapses from every neuron in `source_index` to neurons in `dest_index`.
        Returns the number of edges created (edge count parity with Java/C++)."""
        if source_index < 0 or source_index >= len(self.layers):
            raise IndexError(f"source_index out of range: {source_index}")
        if dest_index < 0 or dest_index >= len(self.layers):
            raise IndexError(f"dest_index out of range: {dest_index}")
        prob = max(0.0, min(1.0, float(probability)))
        src_layer = self.layers[source_index]
        dst_layer = self.layers[dest_index]
        count = 0

        # Layer exposes get_neurons(); neurons expose connect(target, feedback=False)
        for src_neuron in getattr(src_layer, "get_neurons")():
            for dst_neuron in getattr(dst_layer, "get_neurons")():
                if self.rng.random() <= prob:
                    try:
                        src_neuron.connect(dst_neuron, feedback=feedback)
                    except TypeError:
                        src_neuron.connect(dst_neuron)
                    count += 1
        # Record mesh rule for auto-wiring newly grown neurons later
        try:
            self.mesh_rules.append({
                'src': int(source_index), 'dst': int(dest_index),
                'prob': float(prob), 'feedback': bool(feedback),
            })
        except Exception:
            pass
        return count

    def connect_layers_windowed(self,
                                src_index: int,
                                dest_index: int,
                                kernel_h: int,
                                kernel_w: int,
                                stride_h: int = 1,
                                stride_w: int = 1,
                                padding: str = "valid",
                                feedback: bool = False) -> int:
        """Wire src(2D) → dst using sliding windows (deterministic).

        - If dst is OutputLayer2D: each window sends all its source pixels to the
          output neuron at the window's center.
        - Otherwise: allowed source pixels are connected via a Tract that forwards
          events to dst.propagate_from_2d, which then fans to dst neurons.
        Returns number of hook wires created (deterministic count).
        """
        from input_layer_2d import InputLayer2D
        from output_layer_2d import OutputLayer2D
        if src_index < 0 or src_index >= len(self.layers):
            raise IndexError("src_index out of range")
        if dest_index < 0 or dest_index >= len(self.layers):
            raise IndexError("dest_index out of range")

        src_layer = self.layers[src_index]
        dst_layer = self.layers[dest_index]
        if not isinstance(src_layer, InputLayer2D):
            raise ValueError("connect_layers_windowed requires src to be InputLayer2D")

        source_height, source_width = int(getattr(src_layer, "height", 0)), int(getattr(src_layer, "width", 0))
        kernel_height, kernel_width = int(kernel_h), int(kernel_w)
        stride_height, stride_width = max(1, int(stride_h)), max(1, int(stride_w))
        padding_mode = str(padding or "valid").lower()

        # Determine window origins (top-left) for 'valid' (no padding) or 'same' (approximate center pad)
        window_origins = []
        if padding_mode == "same":
            pad_rows = max(0, (kernel_height - 1) // 2)
            pad_cols = max(0, (kernel_width - 1) // 2)
            start_row = -pad_rows
            start_col = -pad_cols
            row_cursor = start_row
            while row_cursor + kernel_height <= source_height + pad_rows + pad_rows:
                col_cursor = start_col
                while col_cursor + kernel_width <= source_width + pad_cols + pad_cols:
                    window_origins.append((row_cursor, col_cursor))
                    col_cursor += stride_width
                row_cursor += stride_height
        else:
            row_cursor = 0
            while row_cursor + kernel_height <= source_height:
                col_cursor = 0
                while col_cursor + kernel_width <= source_width:
                    window_origins.append((row_cursor, col_cursor))
                    col_cursor += stride_width
                row_cursor += stride_height

        # Compute allowed source indices and (optionally) sink map for OutputLayer2D
        allowed_source_indices: set[int] = set()
        sink_map: dict[int, set[int]] = {}
        # We'll return the number of unique source subscriptions installed.
        wires = 0

        if isinstance(dst_layer, OutputLayer2D):
            for (origin_row, origin_col) in window_origins:
                # window clamp to valid coordinates
                clipped_row_start, clipped_col_start = max(0, origin_row), max(0, origin_col)
                clipped_row_end, clipped_col_end = min(source_height, origin_row + kernel_height), min(source_width, origin_col + kernel_width)
                if clipped_row_start >= clipped_row_end or clipped_col_start >= clipped_col_end:
                    continue
                # center index (integer floor)
                center_row = min(source_height - 1, max(0, origin_row + kernel_height // 2))
                center_col = min(source_width - 1, max(0, origin_col + kernel_width // 2))
                center_flat_index = center_row * source_width + center_col
                for row_index in range(clipped_row_start, clipped_row_end):
                    for col_index in range(clipped_col_start, clipped_col_end):
                        source_flat_index = row_index * source_width + col_index
                        allowed_source_indices.add(source_flat_index)
                        sink_map.setdefault(source_flat_index, set()).add(center_flat_index)
            # Create a tract that delivers directly to mapped output neurons
            from tract import Tract
            tract_obj = Tract(src_layer, dst_layer, self.bus, feedback, None,
                              allowed_source_indices=allowed_source_indices, sink_map=sink_map)
            try:
                self.tracts.append(tract_obj)
            except Exception:
                pass
            wires = len(allowed_source_indices)
        else:
            # For generic layers, allow all pixels that participate in any window;
            # destination layer will fan to its neurons with 2D context.
            for (origin_row, origin_col) in window_origins:
                clipped_row_start, clipped_col_start = max(0, origin_row), max(0, origin_col)
                clipped_row_end, clipped_col_end = min(source_height, origin_row + kernel_height), min(source_width, origin_col + kernel_width)
                if clipped_row_start >= clipped_row_end or clipped_col_start >= clipped_col_end:
                    continue
                for row_index in range(clipped_row_start, clipped_row_end):
                    for col_index in range(clipped_col_start, clipped_col_end):
                        allowed_source_indices.add(row_index * source_width + col_index)
            from tract import Tract
            tract_obj = Tract(src_layer, dst_layer, self.bus, feedback, None,
                              allowed_source_indices=allowed_source_indices)
            try:
                self.tracts.append(tract_obj)
            except Exception:
                pass
            wires = len(allowed_source_indices)

        return wires

    # ---- growth plumbing: wire a just-grown neuron like its peers ----
    def autowire_new_neuron(self, layer_obj, new_idx: int) -> None:
        """When a layer adds a neuron, connect it to upstream/downstream based on recorded rules and tracts."""
        try:
            layer_i = self.layers.index(layer_obj)
        except ValueError:
            return
        # 1) Outbound mesh (this layer -> others)
        for rule in list(self.mesh_rules):
            try:
                if rule.get('src') != layer_i:
                    continue
                dst_i = int(rule.get('dst'))
                prob = float(rule.get('prob', 0.0)); feedback_flag = bool(rule.get('feedback', False))
                dst = self.layers[dst_i]
                new_source_neuron = layer_obj.get_neurons()[new_idx]
                for target_neuron in dst.get_neurons():
                    if self.rng.random() <= prob:
                        try:
                            new_source_neuron.connect(target_neuron, feedback_flag)
                        except Exception:
                            pass
            except Exception:
                continue
        # 2) Inbound mesh (others -> this layer)
        for rule in list(self.mesh_rules):
            try:
                if rule.get('dst') != layer_i:
                    continue
                src_i = int(rule.get('src'))
                prob = float(rule.get('prob', 0.0)); feedback_flag = bool(rule.get('feedback', False))
                src = self.layers[src_i]
                target_neuron = layer_obj.get_neurons()[new_idx]
                for source_neuron in src.get_neurons():
                    if self.rng.random() <= prob:
                        try:
                            source_neuron.connect(target_neuron, feedback_flag)
                        except Exception:
                            pass
            except Exception:
                continue
        # 3) Tracts where this layer is the source (attach source neuron)
        for tract in list(self.tracts):
            try:
                if getattr(tract, 'src', None) is layer_obj and hasattr(tract, 'attach_source_neuron'):
                    tract.attach_source_neuron(new_idx)
            except Exception:
                pass

    # ---- optional: escalate to a new layer (safe no-op default) ----
    def request_layer_growth(
        self,
        saturated_layer,
        connection_probability: float | None = None,
        excitatory_count: int | None = None,
    ) -> int | None:
        """Create a spillover layer and connect saturated_layer -> new_layer (simple default).
        Disabled unless layer_growth_enabled=True on the requesting neuron's slot_cfg.
        """
        try:
            idx = self.layers.index(saturated_layer)
        except ValueError:
            return None
        if excitatory_count is None:
            excitatory_count = max(4, (getattr(saturated_layer, "excitatory_count", 4) // 2) or 4)
        if connection_probability is None:
            connection_probability = 1.0
        new_idx = self.add_layer(excitatory_count=max(1, int(excitatory_count)), inhibitory_count=0, modulatory_count=0)
        # connect saturated -> new deterministically
        self.connect_layers(idx, new_idx, probability=float(connection_probability), feedback=False)
        growth_event = {}
        grownet_trace.add_layer_fields(growth_event, saturated_layer)
        growth_event["created_layer"] = True
        growth_event["reason"] = "pressure"
        growth_event["dest_layer_index"] = int(new_idx)
        grownet_trace.emit("growth.layer.create", **growth_event)
        return new_idx

    # ---------------- edge helpers ----------------
    def ensure_input_edge(self, port: str) -> int:
        """Ensure an Input edge layer exists for this port; create lazily."""
        idx = self.input_edges.get(port)
        if idx is not None:
            return idx

        # Minimal scalar input edge: a 1-neuron layer that forwards to internal graph.
        edge_idx = self.add_layer(1, 0, 0)
        self.input_edges[port] = edge_idx
        return edge_idx

    def ensure_output_edge(self, port: str) -> int:
        """Ensure an Output edge layer exists for this port; create lazily."""
        idx = self.output_edges.get(port)
        if idx is not None:
            return idx

        # Minimal scalar output edge: a 1-neuron layer acting as a sink.
        edge_idx = self.add_layer(1, 0, 0)
        self.output_edges[port] = edge_idx
        return edge_idx

    def bind_input(self, port: str, layer_indices: List[int]) -> None:
        """Bind a scalar input port to one or more layers (edge-only delivery).

        If any target is an InputLayer2D, treat it as the port's edge and wire
        it forward to the remaining targets (convenience path).
        """
        self.input_ports[port] = list(layer_indices)

        # Convenience: if user passes a shape-aware input layer as a bound target,
        # treat that layer as the edge for this port and wire it forward.
        try:
            from input_layer_2d import InputLayer2D  # type: ignore
        except Exception:
            InputLayer2D = None  # type: ignore

        edge_idx = None
        if layer_indices and InputLayer2D is not None:
            for layer_index in layer_indices:
                layer_obj = self.layers[layer_index]
                if isinstance(layer_obj, InputLayer2D):
                    edge_idx = layer_index
                    break
        if edge_idx is not None:
            self.input_edges[port] = edge_idx

            # Wire edge to any other attached layers (excluding itself)
            for layer_index in layer_indices:
                if layer_index != edge_idx:
                    self.connect_layers(edge_idx, layer_index, 1.0, False)
        else:

            # Scalar edge layer path
            in_edge = self.ensure_input_edge(port)
            for layer_index in layer_indices:
                if layer_index != in_edge:
                    self.connect_layers(in_edge, layer_index, 1.0, False)

    def bind_input_2d(self, port: str, height: int, width: int, gain: float, epsilon_fire: float, attach_layers: List[int]) -> None:
        from input_layer_2d import InputLayer2D

        # create or reuse edge
        edge_idx = self.input_edges.get(port)
        if edge_idx is None or not isinstance(self.layers[edge_idx], InputLayer2D):
            edge_idx = self.add_input_layer_2d(height, width, gain, epsilon_fire)
            self.input_edges[port] = edge_idx
        self.input_ports[port] = list(attach_layers)

        # wire edge → attached layers
        for layer_index in attach_layers:
            if layer_index != edge_idx:
                self.connect_layers(edge_idx, layer_index, 1.0, False)


    def bind_output(self, port: str, layer_indices: List[int]) -> None:
        self.output_ports[port] = list(layer_indices)
        out_edge = self.ensure_output_edge(port)

        for layer_index in layer_indices:
            if layer_index != out_edge:
                self.connect_layers(layer_index, out_edge, 1.0, False)

    
    def bind_input_nd(self, port: str, shape: list[int], gain: float, epsilon_fire: float, layer_indices: list[int]) -> None:
        idx = self.input_edges.get(port)
        need_new = True
        if idx is not None:
            layer_obj = self.layers[idx]
            if hasattr(layer_obj, "has_shape") and layer_obj.has_shape(shape):
                need_new = False
        if idx is None or need_new:
            edge_idx = self.add_input_layer_nd(shape, gain, epsilon_fire)
            self.input_edges[port] = edge_idx
            idx = edge_idx
        self.input_ports[port] = list(layer_indices)
        for layer_index in layer_indices:
            if layer_index != idx:
                self.connect_layers(idx, layer_index, 1.0, False)


    # ---------------- pulses ----------------
    def pulse_inhibition(self, factor: float) -> None:
        """Raise inhibition for the next tick (ephemeral)."""
        try:
            if self.bus is not None and hasattr(self.bus, "inhibition_factor"):
                self.bus.inhibition_factor = factor
        except Exception:
            pass
        for layer in self.layers:
            try:
                if hasattr(layer, "bus") and layer.bus is not None:
                    layer.bus.inhibition_factor = factor
            except Exception:
                pass

    def pulse_modulation(self, factor: float) -> None:
        """Scale modulation for the next tick (ephemeral)."""
        try:
            if self.bus is not None and hasattr(self.bus, "modulation_factor"):
                self.bus.modulation_factor = factor
        except Exception:
            pass
        for layer in self.layers:
            try:
                if hasattr(layer, "bus") and layer.bus is not None:
                    layer.bus.modulation_factor = factor
            except Exception:
                pass

    # ---------------- ticks ----------------
    def _record_delivery_event(self, count: int = 1) -> None:
        """Record actual target deliveries for the active tick metrics object."""
        metrics_obj = self.active_tick_metrics
        if metrics_obj is None:
            return
        try:
            metrics_obj.inc_delivered_events(int(count))
        except Exception:
            pass

    def _finish_tick_metrics(
        self,
        metrics: RegionMetrics,
        input_frame=None,
        enable_parallel_structure: bool = False,
    ) -> RegionMetrics:
        """Run end-of-tick work and populate structural/spatial metrics."""
        for layer_obj in self.layers:
            layer_obj.end_tick()

        if self.bus is not None and hasattr(self.bus, "decay"):
            self.bus.decay()

        self._populate_structural_metrics(metrics, enable_parallel_structure)

        if input_frame is not None:
            try:
                if self.enable_spatial_metrics or os.environ.get("GROWNET_ENABLE_SPATIAL_METRICS") == "1":
                    self.compute_spatial_metrics_internal(metrics, input_frame)
            except Exception:
                pass

        try:
            if self.growth_policy:
                maybe_grow(self, self.growth_policy)
        except Exception:
            pass

        return metrics

    def _populate_structural_metrics(
        self,
        metrics: RegionMetrics,
        enable_parallel_structure: bool = False,
    ) -> None:
        """Aggregate slot and synapse counts for the current region state."""
        use_parallel_structure = enable_parallel_structure and os.environ.get("GROWNET_ENABLE_PAL") == "1"
        if use_parallel_structure:
            try:
                from pal.api import parallel_map, ParallelOptions
                from pal.domains import build_layer_neuron_tiles

                neuron_counts = [len(getattr(layer_obj, "get_neurons")()) for layer_obj in self.layers]
                tiles = build_layer_neuron_tiles(neuron_counts, tile_size=2048)

                def kernel(tile):
                    layer_index = int(tile[0])
                    start_index = int(tile[1])
                    end_index = int(tile[2])
                    layer_obj = self.layers[layer_index]
                    local_slots = 0
                    local_synapses = 0
                    neurons = getattr(layer_obj, "get_neurons")()
                    for neuron_index in range(start_index, end_index):
                        neuron_obj = neurons[neuron_index]
                        slots_map = getattr(neuron_obj, "slots", None)
                        local_slots += len(slots_map) if isinstance(slots_map, dict) else 0
                        outgoing_list = neuron_obj.get_outgoing() if hasattr(neuron_obj, "get_outgoing") else []
                        local_synapses += len(outgoing_list)
                    return (local_slots, local_synapses)

                def reduce_in_order(local_results):
                    total_slots = 0
                    total_synapses = 0
                    for slot_count, synapse_count in local_results:
                        total_slots += int(slot_count)
                        total_synapses += int(synapse_count)
                    return (total_slots, total_synapses)

                total_slots, total_synapses = parallel_map(
                    tiles,
                    kernel,
                    reduce_in_order,
                    ParallelOptions(),
                )
                metrics.add_slots(total_slots)
                metrics.add_synapses(total_synapses)
                return
            except Exception:
                pass

        for layer_obj in self.layers:
            for neuron_obj in getattr(layer_obj, "get_neurons")():
                slots_map = getattr(neuron_obj, "slots", None)
                metrics.add_slots(len(slots_map) if isinstance(slots_map, dict) else 0)
                outgoing_list = neuron_obj.get_outgoing() if hasattr(neuron_obj, "get_outgoing") else []
                metrics.add_synapses(len(outgoing_list))

    def tick(self, port: str, value: float) -> RegionMetrics:
        """Drive a scalar into the edge bound to `port`; return per-tick metrics."""
        return self.tick_inputs({str(port): float(value)})

    def tick_inputs(self, port_values: Dict[str, float]) -> RegionMetrics:
        """Drive one or more scalar ports in a single end-of-tick cycle."""
        metrics = RegionMetrics()
        self.active_tick_metrics = metrics
        try:
            for port_name, input_value in port_values.items():
                edge_idx = self.input_edges.get(port_name)
                if edge_idx is None:
                    raise KeyError(f"No InputEdge for port '{port_name}'. Call bind_input(...) first.")

                # Phase A: drive each bound edge exactly once for this tick.
                self.layers[edge_idx].forward(float(input_value))
            # Phase B sidecars: optional proximity connectivity after propagation, before end_tick/decay.
            try:
                if getattr(self, "proximity_config", None) is not None and getattr(self.proximity_config, "proximity_connect_enabled", False):
                    from policy.proximity_connectivity import ProximityEngine
                    ProximityEngine.apply(self, self.proximity_config)
            except Exception:
                pass

            return self._finish_tick_metrics(metrics)
        finally:
            self.active_tick_metrics = None


    
    def tick_2d(self, port: str, frame) -> RegionMetrics:
        """Drive a 2D frame into an InputLayer2D edge bound to `port`."""
        metrics = RegionMetrics()
        self.active_tick_metrics = metrics
        self.active_focus_selection = None
        try:
            edge_idx = self.input_edges.get(port)
            if edge_idx is None:
                raise ValueError(f"No InputEdge for port '{port}'. Call bind_input_2d(...) first.")
            layer = self.layers[edge_idx]
            if hasattr(layer, "forward_image"):
                layer.forward_image(frame)
            else:
                raise ValueError(f"InputEdge for '{port}' is not 2D (expected InputLayer2D).")
            # Optional proximity connectivity (post-propagation, pre end_tick/decay)
            try:
                if getattr(self, "proximity_config", None) is not None and getattr(self.proximity_config, "proximity_connect_enabled", False):
                    from policy.proximity_connectivity import ProximityEngine
                    ProximityEngine.apply(self, self.proximity_config)
            except Exception:
                pass
            return self._finish_tick_metrics(metrics, input_frame=frame, enable_parallel_structure=True)
        finally:
            self.active_tick_metrics = None
            self.active_focus_selection = None

    def tick_2d_focused(self, port: str, frame) -> RegionMetrics:
        """Drive a 2D frame through the optional serial focus layer.

        When focus is disabled, this method preserves the baseline tick_2d path.
        When focus is enabled, the frame is analyzed for sparse candidate points,
        one active focus point is selected at a time, and selected points are
        routed serially through the existing 2D runtime path.
        """
        if not self.focus_engine.is_enabled():
            return self.tick_2d(port, frame)

        metrics = RegionMetrics()
        self.active_tick_metrics = metrics
        self.active_focus_selection = None
        try:
            edge_idx = self.input_edges.get(port)
            if edge_idx is None:
                raise ValueError(f"No InputEdge for port '{port}'. Call bind_input_2d(...) first.")
            layer = self.layers[edge_idx]
            if not hasattr(layer, "index"):
                raise ValueError(f"InputEdge for '{port}' is not 2D (expected InputLayer2D).")

            focus_tick_number = self._next_focus_tick_number()
            focus_selections = self.focus_engine.plan_frame(frame, focus_tick_number)
            self.last_focus_summary = self.focus_engine.summarize()
            if not focus_selections:
                return self._finish_tick_metrics(metrics, input_frame=frame, enable_parallel_structure=True)

            for focus_selection in focus_selections:
                self.active_focus_selection = focus_selection
                candidate_point = focus_selection.candidate.point
                if candidate_point is None:
                    continue
                row_index = int(candidate_point[0])
                col_index = int(candidate_point[1])
                if row_index < 0 or row_index >= len(frame):
                    continue
                row_values = frame[row_index]
                if col_index < 0 or col_index >= len(row_values):
                    continue
                pixel_value = float(row_values[col_index])
                if pixel_value == 0.0:
                    continue
                neuron_index = layer.index(row_index, col_index)
                layer.get_neurons()[neuron_index].on_input(pixel_value)

            try:
                if getattr(self, "proximity_config", None) is not None and getattr(self.proximity_config, "proximity_connect_enabled", False):
                    from policy.proximity_connectivity import ProximityEngine
                    ProximityEngine.apply(self, self.proximity_config)
            except Exception:
                pass
            return self._finish_tick_metrics(metrics, input_frame=frame, enable_parallel_structure=True)
        finally:
            self.active_tick_metrics = None
            self.active_focus_selection = None
            self.last_focus_summary = self.focus_engine.summarize()


    def tick_image(self, port: str, frame) -> RegionMetrics:
        """Compatibility alias for tick_2d.

        This path preserves per-pixel 2D delivery semantics. It does not
        reduce the frame to a scalar summary before ticking.
        """
        return self.tick_2d(port, frame)

    def tick_nd(self, port: str, flat, shape) -> RegionMetrics:
        """Deliver a row-major flat tensor + explicit shape into an InputLayerND edge."""
        metrics = RegionMetrics()
        self.active_tick_metrics = metrics
        try:
            edge_idx = self.input_edges.get(port)
            if edge_idx is None:
                raise ValueError(f"No InputEdge for port '{port}'. Call bind_input_nd(...) first.")
            layer = self.layers[edge_idx]
            if not hasattr(layer, "forward_nd"):
                raise ValueError(f"InputEdge for '{port}' is not ND (expected InputLayerND).")
            layer.forward_nd(flat, shape)

            # Optional proximity connectivity (post-propagation, pre end_tick/decay)
            try:
                if getattr(self, "proximity_config", None) is not None and getattr(self.proximity_config, "proximity_connect_enabled", False):
                    from policy.proximity_connectivity import ProximityEngine
                    ProximityEngine.apply(self, self.proximity_config)
            except Exception:
                pass

            return self._finish_tick_metrics(metrics)
        finally:
            self.active_tick_metrics = None

    # ---- mojo-parity thin alias ----
    def autowire_new_neuron_by_ref(self, layer_obj, new_idx: int) -> None:
        return self.autowire_new_neuron(layer_obj, new_idx)

    
    def prune(
        self,
        synapse_stale_window: int = 10000,
        synapse_min_strength: float = 0.05) -> 'Region.PruneSummary':
        """Prune weak/stale synapses via per-neuron hooks; returns a small summary."""

        prune_summary = Region.PruneSummary()

        # A prune function takes (int, float) and returns something int()-convertible.
        PruneFn = Callable[[int, float], SupportsInt]

        for layer in self.layers:
            for neuron in getattr(layer, "get_neurons")():
                fn = getattr(neuron, "prune_synapses", None) or getattr(neuron, "pruneSynapses", None)
                if callable(fn):
                    prune_fn = cast(PruneFn, fn)
                    try:
                        result = prune_fn(synapse_stale_window, synapse_min_strength)
                    except Exception:
                        result = 0  # defensive default if an impl raises
                    try:
                        prune_summary.prunedSynapses += int(result)
                    except (TypeError, ValueError):

                        # If an implementation returns a non-numeric/None, ignore it.
                        pass
        return prune_summary

    # ---------------- accessors ----------------
    def get_name(self) -> str:
        return self.name

    def get_layers(self) -> List[Any]:
        return self.layers

    def get_bus(self):
        return self.bus

    # ---------------- growth policy helpers ----------------
    def set_growth_policy(self, policy):
        """Attach a GrowthPolicy to this region to enable automatic layer growth."""
        self.growth_policy = policy
        return self

    def get_growth_policy(self):
        return self.growth_policy

    def get_focus_summary(self):
        focus_summary = self.last_focus_summary
        if focus_summary is not None and (
            focus_summary.active_focus_point is not None
            or focus_summary.active_focus_value is not None
            or focus_summary.candidate_count > 0
            or focus_summary.anchor_count > 0
        ):
            return focus_summary

        for layer_obj in self.layers:
            for neuron_obj in getattr(layer_obj, "get_neurons")():
                anchor_state = getattr(neuron_obj, "last_slot_anchor_state", None)
                focus_selection = getattr(neuron_obj, "last_slot_focus_selection", None)
                if anchor_state is None and focus_selection is None:
                    continue
                return FocusTickSummary(
                    policy_name=getattr(focus_selection, "policy_name", "scalar_anchor") if focus_selection is not None else "scalar_anchor",
                    candidate_count=1,
                    selection_count=1,
                    active_focus_point=getattr(getattr(focus_selection, "candidate", None), "point", None) if focus_selection is not None else None,
                    active_focus_value=getattr(getattr(focus_selection, "candidate", None), "scalar_value", None) if focus_selection is not None else None,
                    active_anchor_point=getattr(anchor_state, "point", None) if anchor_state is not None else None,
                    active_anchor_value=getattr(anchor_state, "scalar_value", None) if anchor_state is not None else None,
                    anchor_count=self.focus_engine.anchor_count(),
                    selected_points=(
                        (getattr(focus_selection.candidate, "point", None),)
                        if focus_selection is not None and getattr(focus_selection.candidate, "point", None) is not None
                        else tuple()
                    ),
                    selected_values=(
                        (float(focus_selection.candidate.energy),)
                        if focus_selection is not None
                        else tuple()
                    ),
                )
        return focus_summary

    # ---------------- internal helpers ----------------
    def compute_spatial_metrics_internal(self, metrics: RegionMetrics, input_frame) -> None:
        """Compute activePixels, centroid, and bbox from the best available 2D layer.

        Prefer the furthest downstream OutputLayer2D frame this tick; if no non-zero
        output is found, fall back to the input frame passed to tick_2d.
        """
        from output_layer_2d import OutputLayer2D

        chosen_frame = None
        # pick last OutputLayer2D (furthest downstream by index)
        for layer in reversed(self.layers):
            if isinstance(layer, OutputLayer2D):
                try:
                    output_frame = layer.get_frame()
                    chosen_frame = output_frame
                    break
                except Exception:
                    continue

        if chosen_frame is None:
            chosen_frame = input_frame

        # If chosen is entirely zeros, but input has non-zeros, prefer input
        def is_all_zero(image):
            try:
                for row_values in image:
                    for pixel_value in row_values:
                        if float(pixel_value) != 0.0:
                            return False
                return True
            except Exception:
                return True

        if chosen_frame is not input_frame and is_all_zero(chosen_frame) and not is_all_zero(input_frame):
            chosen_frame = input_frame

        # Compute metrics
        total = 0.0
        sum_r = 0.0
        sum_c = 0.0
        rmin, rmax, cmin, cmax = 10**9, -1, 10**9, -1
        height = len(chosen_frame) if chosen_frame is not None else 0
        width = len(chosen_frame[0]) if height > 0 else 0

        for row_index in range(height):
            row_values = chosen_frame[row_index]
            for col_index in range(min(width, len(row_values))):
                pixel_value = float(row_values[col_index])
                if pixel_value > 0.0:
                    total += pixel_value
                    sum_r += row_index * pixel_value
                    sum_c += col_index * pixel_value
                    if row_index < rmin: rmin = row_index
                    if row_index > rmax: rmax = row_index
                    if col_index < cmin: cmin = col_index
                    if col_index > cmax: cmax = col_index

        active = 0
        if height > 0 and width > 0:
            # count active pixels (value > 0.0)
            active = sum(1 for row_index in range(height) for col_index in range(width) if float(chosen_frame[row_index][col_index]) > 0.0)

        metrics.active_pixels = active
        if total > 0.0:
            metrics.centroid_row = sum_r / total
            metrics.centroid_col = sum_c / total
        else:
            metrics.centroid_row = 0.0
            metrics.centroid_col = 0.0

        bbox = (0, -1, 0, -1) if rmax < rmin or cmax < cmin else (rmin, rmax, cmin, cmax)
        metrics.bbox = bbox

    def compute_spatial_metrics(self, image_2d, prefer_output: bool = True) -> RegionMetrics:
        """Public wrapper returning RegionMetrics computed from a 2D image.

        If prefer_output is True, and a downstream OutputLayer2D has non-zero output,
        metrics are computed from that output; otherwise metrics derive from image_2d.
        """
        metrics = RegionMetrics()
        try:
            if prefer_output:
                self.compute_spatial_metrics_internal(metrics, image_2d)
            else:
                # compute directly from provided image
                total = 0.0
                sum_r = 0.0
                sum_c = 0.0
                rmin, rmax, cmin, cmax = 10**9, -1, 10**9, -1
                height = len(image_2d) if image_2d is not None else 0
                width = len(image_2d[0]) if height > 0 else 0
                active = 0
                for row_index in range(height):
                    row_values = image_2d[row_index]
                    for col_index in range(min(width, len(row_values))):
                        pixel_value = float(row_values[col_index])
                        if pixel_value > 0.0:
                            active += 1
                            total += pixel_value
                            sum_r += row_index * pixel_value
                            sum_c += col_index * pixel_value
                            if row_index < rmin: rmin = row_index
                            if row_index > rmax: rmax = row_index
                            if col_index < cmin: cmin = col_index
                            if col_index > cmax: cmax = col_index
                metrics.active_pixels = active
                if total > 0.0:
                    metrics.centroid_row = sum_r / total
                    metrics.centroid_col = sum_c / total
                else:
                    metrics.centroid_row = 0.0
                    metrics.centroid_col = 0.0
                bbox = (0, -1, 0, -1) if rmax < rmin or cmax < cmin else (rmin, rmax, cmin, cmax)
                metrics.bbox = bbox
        except Exception:
            pass
        return metrics

    def _next_focus_tick_number(self) -> int:
        try:
            if self.layers:
                layer_bus = self.layers[0].get_bus()
                if layer_bus is not None and hasattr(layer_bus, "get_current_step"):
                    return int(layer_bus.get_current_step()) + 1
        except Exception:
            return 1
        return 1
