"""Grid perception layer — objectness, symmetry, colour grouping.

This is the İlim organ of the ARC solver: it *distinguishes* structure
from raw data without altering it (KV₃ — observer non-interference).
"""

from __future__ import annotations

import copy
from collections import Counter, deque
from typing import Dict, FrozenSet, List, Optional, Set, Tuple

from .types import BBox, Color, Grid, GridInfo, Object, Point

# ── Directions ─────────────────────────────────────────────────────
DIRS4: List[Tuple[int, int]] = [(-1, 0), (1, 0), (0, -1), (0, 1)]
DIRS8: List[Tuple[int, int]] = DIRS4 + [(-1, -1), (-1, 1), (1, -1), (1, 1)]


# ── Grid utilities ─────────────────────────────────────────────────
def grid_size(g: Grid) -> Tuple[int, int]:
    """Return *(height, width)* of a grid."""
    h = len(g)
    w = len(g[0]) if h > 0 else 0
    return h, w


def copy_grid(g: Grid) -> Grid:
    """Deep-copy a grid."""
    return [row[:] for row in g]


def make_grid(h: int, w: int, fill: Color = 0) -> Grid:
    """Create an *h × w* grid filled with *fill*."""
    return [[fill] * w for _ in range(h)]


def grids_equal(a: Grid, b: Grid) -> bool:
    """Return ``True`` when two grids are exactly equal."""
    if len(a) != len(b):
        return False
    return all(ra == rb for ra, rb in zip(a, b))


# ── Colour analysis ───────────────────────────────────────────────
def grid_colors(g: Grid) -> Set[Color]:
    """Return set of all colours present in *g*."""
    return {c for row in g for c in row}


def color_counts(g: Grid) -> Dict[Color, int]:
    """Count each colour's occurrences."""
    return dict(Counter(c for row in g for c in row))


def detect_background(g: Grid) -> Color:
    """Detect background colour — most frequent, tie-breaking with 0."""
    counts = color_counts(g)
    if not counts:
        return 0
    mx = max(counts.values())
    candidates = sorted(c for c, n in counts.items() if n == mx)
    return 0 if 0 in candidates else candidates[0]


# ── Connected components ──────────────────────────────────────────
def _flood(
    g: Grid,
    start: Point,
    visited: Set[Point],
    match_fn,
    dirs: List[Tuple[int, int]],
) -> Set[Point]:
    """BFS flood-fill collecting all cells that satisfy *match_fn*."""
    h, w = grid_size(g)
    component: Set[Point] = set()
    queue = deque([start])
    while queue:
        r, c = queue.popleft()
        if (r, c) in visited:
            continue
        if not (0 <= r < h and 0 <= c < w):
            continue
        if not match_fn(r, c):
            continue
        visited.add((r, c))
        component.add((r, c))
        for dr, dc in dirs:
            nb = (r + dr, c + dc)
            if nb not in visited:
                queue.append(nb)
    return component


def extract_objects(
    g: Grid,
    background: Optional[Color] = None,
    connectivity: int = 4,
) -> List[Object]:
    """Extract single-colour connected components, ignoring *background*."""
    if background is None:
        background = detect_background(g)
    h, w = grid_size(g)
    dirs = DIRS4 if connectivity == 4 else DIRS8
    visited: Set[Point] = set()
    objects: List[Object] = []
    for r in range(h):
        for c in range(w):
            if (r, c) in visited or g[r][c] == background:
                continue
            color = g[r][c]
            comp = _flood(g, (r, c), visited, lambda rr, cc: g[rr][cc] == color, dirs)
            if comp:
                rs = [p[0] for p in comp]
                cs = [p[1] for p in comp]
                bbox = BBox(min(rs), min(cs), max(rs), max(cs))
                objects.append(Object(color=color, pixels=frozenset(comp), bbox=bbox))
    return objects


def extract_multicolor_objects(
    g: Grid,
    background: Optional[Color] = None,
    connectivity: int = 4,
) -> List[Object]:
    """Extract connected non-background regions (any colour mix)."""
    if background is None:
        background = detect_background(g)
    h, w = grid_size(g)
    dirs = DIRS4 if connectivity == 4 else DIRS8
    visited: Set[Point] = set()
    objects: List[Object] = []
    for r in range(h):
        for c in range(w):
            if (r, c) in visited or g[r][c] == background:
                continue
            comp = _flood(
                g, (r, c), visited,
                lambda rr, cc: g[rr][cc] != background, dirs,
            )
            if comp:
                rs = [p[0] for p in comp]
                cs = [p[1] for p in comp]
                bbox = BBox(min(rs), min(cs), max(rs), max(cs))
                objects.append(Object(color=-1, pixels=frozenset(comp), bbox=bbox))
    return objects


# ── Geometric transforms ─────────────────────────────────────────
def rotate_90(g: Grid) -> Grid:
    """Rotate 90° clockwise."""
    h, w = grid_size(g)
    return [[g[h - 1 - r][c] for r in range(h)] for c in range(w)]


def rotate_180(g: Grid) -> Grid:
    """Rotate 180°."""
    return [row[::-1] for row in g[::-1]]


def rotate_270(g: Grid) -> Grid:
    """Rotate 270° clockwise (= 90° counter-clockwise)."""
    h, w = grid_size(g)
    return [[g[r][w - 1 - c] for r in range(h)] for c in range(w)]


def flip_h(g: Grid) -> Grid:
    """Flip horizontally (left ↔ right)."""
    return [row[::-1] for row in g]


def flip_v(g: Grid) -> Grid:
    """Flip vertically (top ↔ bottom)."""
    return list(reversed([row[:] for row in g]))


def transpose(g: Grid) -> Grid:
    """Transpose (swap rows/columns)."""
    h, w = grid_size(g)
    return [[g[r][c] for r in range(h)] for c in range(w)]


# ── Manipulation ─────────────────────────────────────────────────
def crop_to_content(g: Grid, background: Optional[Color] = None) -> Grid:
    """Crop to the bounding box of non-background content."""
    if background is None:
        background = detect_background(g)
    h, w = grid_size(g)
    rows = [r for r in range(h) if any(g[r][c] != background for c in range(w))]
    cols = [c for c in range(w) if any(g[r][c] != background for r in range(h))]
    if not rows or not cols:
        return [[background]]
    r0, r1 = min(rows), max(rows)
    c0, c1 = min(cols), max(cols)
    return [g[r][c0 : c1 + 1] for r in range(r0, r1 + 1)]


def extract_subgrid(g: Grid, bbox: BBox) -> Grid:
    """Extract sub-grid within *bbox*."""
    return [g[r][bbox.c0 : bbox.c1 + 1] for r in range(bbox.r0, bbox.r1 + 1)]


def overlay(
    base: Grid,
    top: Grid,
    r_off: int = 0,
    c_off: int = 0,
    transparent: Optional[Color] = None,
) -> Grid:
    """Overlay *top* onto *base* at offset; *transparent* colour is skipped."""
    result = copy_grid(base)
    bh, bw = grid_size(base)
    th, tw = grid_size(top)
    for r in range(th):
        for c in range(tw):
            nr, nc = r + r_off, c + c_off
            if 0 <= nr < bh and 0 <= nc < bw:
                if transparent is None or top[r][c] != transparent:
                    result[nr][nc] = top[r][c]
    return result


def scale_grid(g: Grid, factor: int) -> Grid:
    """Upscale by integer *factor*."""
    h, w = grid_size(g)
    return [
        [g[r // factor][c // factor] for c in range(w * factor)]
        for r in range(h * factor)
    ]


def tile_grid(g: Grid, rows: int, cols: int) -> Grid:
    """Tile *g* into a *rows × cols* mosaic."""
    h, w = grid_size(g)
    return [[g[r % h][c % w] for c in range(w * cols)] for r in range(h * rows)]


def recolor(g: Grid, mapping: Dict[Color, Color]) -> Grid:
    """Re-colour using a mapping ``{old → new}``."""
    return [[mapping.get(c, c) for c in row] for row in g]


# ── Gravity ──────────────────────────────────────────────────────
def gravity_down(g: Grid, bg: Optional[Color] = None) -> Grid:
    """Drop non-background cells to the bottom of each column."""
    if bg is None:
        bg = detect_background(g)
    h, w = grid_size(g)
    result = make_grid(h, w, bg)
    for c in range(w):
        colored = [g[r][c] for r in range(h) if g[r][c] != bg]
        for i, clr in enumerate(reversed(colored)):
            result[h - 1 - i][c] = clr
    return result


def gravity_up(g: Grid, bg: Optional[Color] = None) -> Grid:
    """Push non-background cells to the top of each column."""
    if bg is None:
        bg = detect_background(g)
    h, w = grid_size(g)
    result = make_grid(h, w, bg)
    for c in range(w):
        colored = [g[r][c] for r in range(h) if g[r][c] != bg]
        for i, clr in enumerate(colored):
            result[i][c] = clr
    return result


def gravity_left(g: Grid, bg: Optional[Color] = None) -> Grid:
    """Push non-background cells to the left of each row."""
    if bg is None:
        bg = detect_background(g)
    h, w = grid_size(g)
    result = make_grid(h, w, bg)
    for r in range(h):
        colored = [g[r][c] for c in range(w) if g[r][c] != bg]
        for i, clr in enumerate(colored):
            result[r][i] = clr
    return result


def gravity_right(g: Grid, bg: Optional[Color] = None) -> Grid:
    """Push non-background cells to the right of each row."""
    if bg is None:
        bg = detect_background(g)
    h, w = grid_size(g)
    result = make_grid(h, w, bg)
    for r in range(h):
        colored = [g[r][c] for c in range(w) if g[r][c] != bg]
        for i, clr in enumerate(reversed(colored)):
            result[r][w - 1 - i] = clr
    return result


# ── Fill enclosed ────────────────────────────────────────────────
def fill_enclosed(g: Grid, fill_color: Optional[Color] = None) -> Grid:
    """Fill background regions fully enclosed by non-background cells."""
    bg = detect_background(g)
    h, w = grid_size(g)
    # BFS from border — find exterior background
    exterior: Set[Point] = set()
    queue: deque[Point] = deque()
    for r in range(h):
        for c in (0, w - 1):
            if g[r][c] == bg and (r, c) not in exterior:
                exterior.add((r, c))
                queue.append((r, c))
    for c in range(w):
        for r in (0, h - 1):
            if g[r][c] == bg and (r, c) not in exterior:
                exterior.add((r, c))
                queue.append((r, c))
    while queue:
        r, c = queue.popleft()
        for dr, dc in DIRS4:
            nr, nc = r + dr, c + dc
            if 0 <= nr < h and 0 <= nc < w and (nr, nc) not in exterior and g[nr][nc] == bg:
                exterior.add((nr, nc))
                queue.append((nr, nc))
    # Determine fill colour
    if fill_color is None:
        counts = color_counts(g)
        non_bg = {c: n for c, n in counts.items() if c != bg}
        fill_color = max(non_bg, key=non_bg.get) if non_bg else 1
    result = copy_grid(g)
    for r in range(h):
        for c in range(w):
            if g[r][c] == bg and (r, c) not in exterior:
                result[r][c] = fill_color
    return result


# ── Symmetry detection ───────────────────────────────────────────
def detect_symmetry(g: Grid) -> Dict[str, bool]:
    """Detect mirror / rotation symmetries."""
    return {
        "h_mirror": grids_equal(g, flip_h(g)),
        "v_mirror": grids_equal(g, flip_v(g)),
        "rot90": grids_equal(g, rotate_90(g)),
        "rot180": grids_equal(g, rotate_180(g)),
        "transpose": grids_equal(g, transpose(g)),
    }


# ── High-level perceive ─────────────────────────────────────────
def perceive(g: Grid) -> GridInfo:
    """Full perception pass — the İlim organ's primary output."""
    h, w = grid_size(g)
    colors = grid_colors(g)
    counts = color_counts(g)
    bg = detect_background(g)
    objects = extract_objects(g, bg)
    symmetry = detect_symmetry(g)
    return GridInfo(
        grid=g,
        height=h,
        width=w,
        colors=colors,
        color_counts=counts,
        background=bg,
        objects=objects,
        symmetry=symmetry,
    )
