#!/usr/bin/env python3
"""Wide bingo 0x30 card codec search — beyond nibble-permutation cube."""

from __future__ import annotations

import itertools
from dataclasses import dataclass
from typing import Iterator

Grid = tuple[tuple[int | None, ...], ...]
CENTER = (2, 2)
FULL = 96  # 4 cards × 24 numbers


def col_table_to_rows(b: tuple, i: tuple, n: tuple, g: tuple, o: tuple) -> Grid:
    cols = (b, i, n, g, o)
    rows: list[tuple[int | None, ...]] = []
    for r in range(5):
        row: list[int | None] = []
        for c in range(5):
            if (r, c) == CENTER:
                row.append(None)
            else:
                v = cols[c][r]
                row.append(None if v == "FR" else int(v))
        rows.append(tuple(row))
    return tuple(rows)


def spot_orders() -> dict[str, list[tuple[int, int]]]:
    row_m = [(divmod(s, 5)[0], divmod(s, 5)[1]) for s in range(25) if s != 12]
    col_m = [(r, c) for c in range(5) for r in range(5) if (r, c) != CENTER]
    diag_m = [(r, c) for r in range(5) for c in range(5) if (r, c) != CENTER and (r == c or r + c == 4)]
    # fill diag with rest row-major
    seen = set(diag_m)
    diag_m += [(r, c) for r in range(5) for c in range(5) if (r, c) != CENTER and (r, c) not in seen]
    return {
        "row_major": row_m,
        "row_major_rev": list(reversed(row_m)),
        "col_major": col_m,
        "col_major_rev": list(reversed(col_m)),
        "diag_first": diag_m,
    }


def spot_indices_row_major() -> list[int]:
    """Linear spot index 0–24 (row-major), skip center 12."""
    return [i for i in range(25) if i != 12]


def empty_grid() -> list[list[int | None]]:
    g: list[list[int | None]] = [[None] * 5 for _ in range(5)]
    g[CENTER[0]][CENTER[1]] = None
    return g


def fill_grid(spots: list[tuple[int, int]], values: list[int]) -> Grid | None:
    if len(values) != len(spots):
        return None
    grid = empty_grid()
    for v, (r, c) in zip(values, spots):
        if not (1 <= v <= 75):
            return None
        if grid[r][c] is not None:
            return None
        grid[r][c] = v
    return tuple(tuple(row) for row in grid)


def overlap(a: Grid, b: Grid) -> int:
    n = 0
    for r in range(5):
        for c in range(5):
            if (r, c) == CENTER:
                continue
            if a[r][c] is not None and a[r][c] == b[r][c]:
                n += 1
    return n


def set_overlap(a: Grid, b: Grid) -> int:
    return len(
        {v for row in a for v in row if v is not None}
        & {v for row in b for v in row if v is not None}
    )


@dataclass(frozen=True)
class Oracle:
    name: str
    payload_hex: str
    cards: tuple[Grid, ...]


ORACLES: tuple[Oracle, ...] = (
    Oracle(
        "jul3_night",
        "540c99018310142cab00104d142aaa0082324445190c2123"
        "a240181905700e41d208001d4a0a2c060000000000000000",
        (
            ((11, 19, 43, 59, 68), (5, 16, 31, 50, 62), (12, 24, None, 48, 61), (7, 23, 38, 56, 66), (3, 20, 32, 57, 64)),
            ((15, 20, 36, 59, 75), (5, 25, 34, 53, 67), (12, 27, None, 55, 63), (11, 18, 32, 47, 71), (9, 29, 38, 58, 69)),
            ((5, 16, 32, 50, 75), (1, 24, 45, 57, 73), (4, 21, None, 54, 74), (12, 29, 38, 58, 61), (11, 25, 36, 49, 63)),
            ((9, 27, 41, 49, 70), (15, 20, 43, 55, 64), (3, 23, None, 57, 71), (2, 22, 39, 47, 66), (4, 17, 42, 52, 63)),
        ),
    ),
    Oracle(
        "jul4_1127",
        "9c106a040c214c0533102e080a2a20618426c14865204d040d025230a8508e01220e014c9406a4120000000000000000",
        (
            col_table_to_rows((4, 5, 8, 3, 13), (21, 17, 19, 26, 22), (34, 44, "FR", 33, 39), (52, 49, 54, 56, 48), (73, 66, 61, 62, 65)),
            col_table_to_rows((3, 12, 2, 6, 4), (17, 25, 29, 27, 19), (44, 36, "FR", 39, 45), (56, 53, 55, 48, 59), (61, 67, 72, 68, 75)),
            col_table_to_rows((14, 7, 6, 1, 3), (19, 22, 16, 26, 18), (34, 31, "FR", 33, 40), (50, 59, 47, 52, 58), (75, 66, 64, 68, 73)),
            col_table_to_rows((4, 2, 8, 3, 9), (17, 21, 26, 25, 27), (41, 42, "FR", 45, 31), (55, 48, 53, 50, 56), (63, 66, 73, 68, 70)),
        ),
    ),
    Oracle(
        "jul4_setB_seq6",
        "e840204e9202884310330a4a0c4c801c44468d40c2242a113030046a0b033430a0490912d00624190000000000000000",
        (
            col_table_to_rows((15, 4, 7, 8, 6), (21, 26, 27, 30, 25), (40, 35, "FR", 38, 32), (60, 55, 53, 49, 54), (74, 65, 69, 73, 70)),
            col_table_to_rows((10, 4, 15, 12, 2), (27, 18, 30, 26, 19), (42, 43, "FR", 38, 41), (60, 55, 52, 56, 48), (68, 64, 75, 61, 63)),
            col_table_to_rows((7, 11, 2, 8, 14), (19, 28, 17, 24, 21), (35, 44, "FR", 43, 36), (57, 55, 60, 48, 59), (62, 69, 70, 64, 61)),
            col_table_to_rows((6, 13, 5, 14, 3), (21, 23, 27, 24, 30), (40, 34, "FR", 31, 43), (52, 55, 53, 50, 56), (72, 63, 73, 66, 69)),
        ),
    ),
    Oracle(
        "jul4_setA_seq7",
        "c94044522038ac02186824230513846034098131830682681444203512258036e22068208928084e0000000000000000",
        (
            col_table_to_rows((4, 7, 8, 1, 15), (22, 30, 28, 18, 25), (42, 36, "FR", 43, 44), (51, 48, 53, 55, 49), (75, 74, 64, 65, 72)),
            col_table_to_rows((3, 10, 6, 14, 9), (25, 16, 28, 24, 18), (44, 33, "FR", 38, 45), (57, 48, 51, 50, 54), (74, 69, 61, 68, 73)),
            col_table_to_rows((8, 2, 11, 1, 10), (17, 30, 23, 29, 27), (35, 41, "FR", 45, 33), (51, 58, 54, 59, 56), (62, 69, 65, 74, 71)),
            col_table_to_rows((10, 14, 8, 13, 11), (29, 23, 22, 21, 17), (44, 36, "FR", 37, 34), (53, 49, 46, 59, 57), (75, 70, 71, 64, 72)),
        ),
    ),
)


def prep(block: bytes, byte_rev: bool, xor_key: int) -> bytes:
    data = bytes(reversed(block)) if byte_rev else block
    if xor_key:
        data = bytes(b ^ xor_key for b in data)
    return data


def nibbles_from_bytes(data: bytes, order: str) -> list[int]:
    out: list[int] = []
    if order == "low_high":
        for b in data:
            out.extend([b & 0x0F, (b >> 4) & 0x0F])
    elif order == "high_low":
        for b in data:
            out.extend([(b >> 4) & 0x0F, b & 0x0F])
    elif order == "legacy_j":
        for j in range(24):
            out.append((data[j // 2] >> ((j & 1) * 4)) & 0x0F)
    else:
        raise ValueError(order)
    return out[:24]


def nibbles_from_gd_words(data: bytes, endian: str) -> list[int]:
    """BINGO.C savebingcard: 3×u32, 8 nibbles each at (j%8)*4."""
    if len(data) != 12:
        return []
    words = [
        int.from_bytes(data[i : i + 4], endian) for i in (0, 4, 8)
    ]
    out: list[int] = []
    for j in range(24):
        out.append((words[j // 8] >> ((j % 8) * 4)) & 0x0F)
    return out


def decode_strategies() -> Iterator[tuple[str, callable]]:
    """Yield (label, decoder(block)->Grid|None) factories via closure."""

    for byte_rev in (False, True):
        for xor_key in (0, 0xFF, 0xAA, 0x55):
            br, xk = byte_rev, xor_key
            xlabel = f"xor={xk:02X}" if xk else "xor=00"

            # --- raw byte 1–75 (need 24 bytes — duplicate 12 or use both halves) ---
            def _raw24_dup(data: bytes, _br=br, _xk=xk, spot="row_major") -> Grid | None:
                b = prep(data, _br, _xk)
                vals = [x for x in b if 1 <= x <= 75]
                if len(vals) < 12:
                    return None
                # duplicate to 24 if only 12 valid bytes
                if len(vals) < 24:
                    vals = (vals + vals)[:24]
                return fill_grid(spot_orders()[spot], vals[:24])

            for spot in spot_orders():
                yield (f"raw_byte_dup byte_rev={br} {xlabel} spot={spot}", lambda d, s=spot, f=_raw24_dup: f(d, spot=s))

            def _raw12_lo_hi(data: bytes, _br=br, _xk=xk, spot="row_major") -> Grid | None:
                b = prep(data, _br, _xk)
                vals: list[int] = []
                for x in b:
                    if 1 <= x <= 75:
                        vals.append(x)
                    lo, hi = x & 0x0F, x >> 4
                    if 1 <= lo <= 75:
                        vals.append(lo)
                    if 1 <= hi <= 75 and hi != lo:
                        vals.append(hi)
                if len(vals) < 24:
                    return None
                return fill_grid(spot_orders()[spot], vals[:24])

            for spot in spot_orders():
                yield (f"raw_byte_expand byte_rev={br} {xlabel} spot={spot}", lambda d, s=spot, f=_raw12_lo_hi: f(d, spot=s))

            # --- byte = absolute index 0–74 +1 via mod ---
            for spot in spot_orders():
                for add in (0, 1):
                    def _byte_mod(data: bytes, _br=br, _xk=xk, _spot=spot, _add=add) -> Grid | None:
                        b = prep(data, _br, _xk)
                        vals = [((x + _add) % 75) + 1 for x in b]
                        if len(vals) < 12:
                            return None
                        vals = (vals * 2)[:24]
                        return fill_grid(spot_orders()[_spot], vals)

                    yield (f"byte_mod75+{add} byte_rev={br} {xlabel} spot={spot}", _byte_mod)

            # --- byte div/mod column ---
            for spot in spot_orders():
                def _byte_div15(data: bytes, _br=br, _xk=xk, _spot=spot) -> Grid | None:
                    b = prep(data, _br, _xk)
                    vals = [(x // 15) * 15 + (x % 15) + 1 for x in b]
                    if any(not (1 <= v <= 75) for v in vals):
                        return None
                    vals = (vals * 2)[:24]
                    return fill_grid(spot_orders()[_spot], vals)

                yield (f"byte_div15_local byte_rev={br} {xlabel} spot={spot}", _byte_div15)

            # --- GD word nibble + loadbingcard spot index col ---
            for endian in ("little", "big"):
                for spot_name, use_i_col in (("row_major", True), ("row_major", False)):
                    def _gd_loadbing(
                        data: bytes,
                        _br=br,
                        _xk=xk,
                        _end=endian,
                        _spot=spot_name,
                        _i_col=use_i_col,
                    ) -> Grid | None:
                        b = prep(data, _br, _xk)
                        ns = nibbles_from_gd_words(b, _end)
                        if len(ns) != 24:
                            return None
                        vals: list[int] = []
                        idx = spot_indices_row_major()
                        for j, nib in enumerate(ns):
                            if _i_col:
                                col = idx[j] % 5
                            else:
                                _, col = spot_orders()[_spot][j]
                            v = col * 15 + nib + 1
                            if not (1 <= v <= 75):
                                return None
                            vals.append(v)
                        return fill_grid(spot_orders()[_spot], vals)

                    tag = "i%5" if use_i_col else "grid_col"
                    yield (f"gd_u32_{endian} col={tag} byte_rev={br} {xlabel} spot={spot_name}", _gd_loadbing)

            # --- standard nibble streams ---
            for nib_ord in ("low_high", "high_low", "legacy_j"):
                for spot in spot_orders():
                    for map_name in ("col+1", "col+0", "row+1", "nib+1", "nib+0"):
                        def _nib_map(
                            data: bytes,
                            _br=br,
                            _xk=xk,
                            _no=nib_ord,
                            _spot=spot,
                            _mn=map_name,
                        ) -> Grid | None:
                            b = prep(data, _br, _xk)
                            ns = nibbles_from_bytes(b, _no)
                            vals: list[int] = []
                            for j, (r, c) in enumerate(spot_orders()[_spot]):
                                nib = ns[j]
                                if _mn == "col+1":
                                    v = c * 15 + nib + 1
                                elif _mn == "col+0":
                                    v = c * 15 + nib
                                elif _mn == "row+1":
                                    v = r * 15 + nib + 1
                                elif _mn == "nib+1":
                                    v = nib + 1
                                else:
                                    v = nib
                                if not (1 <= v <= 75):
                                    return None
                                vals.append(v)
                            return fill_grid(spot_orders()[_spot], vals)

                        yield (f"nib_{nib_ord} map={map_name} byte_rev={br} {xlabel} spot={spot}", _nib_map)

            # --- nibble pair → one number (12 numbers duplicated to 24) ---
            for nib_ord in ("low_high", "high_low"):
                for pair_fn_name in ("n0*16+n1", "n0*15+n1+1", "n1*15+n0+1", "n0*10+n1"):
                    for spot in spot_orders():
                        def _pair(
                            data: bytes,
                            _br=br,
                            _xk=xk,
                            _no=nib_ord,
                            _spot=spot,
                            _pf=pair_fn_name,
                        ) -> Grid | None:
                            b = prep(data, _br, _xk)
                            ns = nibbles_from_bytes(b, _no)
                            pairs: list[int] = []
                            for i in range(0, 24, 2):
                                n0, n1 = ns[i], ns[i + 1]
                                if _pf == "n0*16+n1":
                                    v = n0 * 16 + n1
                                elif _pf == "n0*15+n1+1":
                                    v = n0 * 15 + n1 + 1
                                elif _pf == "n1*15+n0+1":
                                    v = n1 * 15 + n0 + 1
                                else:
                                    v = n0 * 10 + n1
                                if not (1 <= v <= 75):
                                    return None
                                pairs.append(v)
                            pairs = (pairs * 2)[:24]
                            return fill_grid(spot_orders()[_spot], pairs)

                        yield (f"pair_{pair_fn_name} nib_{nib_ord} byte_rev={br} {xlabel} spot={spot}", _pair)

            # --- byte high nibble = col, low = local (B=0..O=4) ---
            for swap in (False, True):
                for spot in spot_orders():
                    def _hi_col(
                        data: bytes,
                        _br=br,
                        _xk=xk,
                        _spot=spot,
                        _swap=swap,
                    ) -> Grid | None:
                        b = prep(data, _br, _xk)
                        vals: list[int] = []
                        for x in b:
                            hi, lo = (x >> 4) & 0x0F, x & 0x0F
                            if _swap:
                                hi, lo = lo, hi
                            if hi > 4:
                                return None
                            v = hi * 15 + lo + 1
                            if not (1 <= v <= 75):
                                return None
                            vals.append(v)
                        vals = (vals * 2)[:24]
                        return fill_grid(spot_orders()[_spot], vals)

                    yield (f"byte_hi_col swap={swap} byte_rev={br} {xlabel} spot={spot}", _hi_col)

            # --- 6× u16 BE/LE direct ---
            for endian in ("big", "little"):
                for spot in spot_orders():
                    def _u16(
                        data: bytes,
                        _br=br,
                        _xk=xk,
                        _end=endian,
                        _spot=spot,
                    ) -> Grid | None:
                        b = prep(data, _br, _xk)
                        vals: list[int] = []
                        for i in range(0, 12, 2):
                            v = int.from_bytes(b[i : i + 2], _end)
                            if not (1 <= v <= 75):
                                return None
                            vals.append(v)
                        vals = (vals * 4)[:24]
                        return fill_grid(spot_orders()[_spot], vals)

                    yield (f"u16_{endian} x4 byte_rev={br} {xlabel} spot={spot}", _u16)

            # --- 6-bit stream (16 values, duplicate to 24) ---
            for spot in spot_orders():
                def _6bit(data: bytes, _br=br, _xk=xk, _spot=spot) -> Grid | None:
                    b = prep(data, _br, _xk)
                    bits = "".join(f"{x:08b}" for x in b)
                    vals: list[int] = []
                    for i in range(0, len(bits) - 5, 6):
                        v = int(bits[i : i + 6], 2) + 1
                        if 1 <= v <= 75:
                            vals.append(v)
                        if len(vals) >= 24:
                            break
                    if len(vals) < 16:
                        return None
                    while len(vals) < 24:
                        vals.extend(vals)
                    return fill_grid(spot_orders()[_spot], vals[:24])

                yield (f"bit6_stream byte_rev={br} {xlabel} spot={spot}", _6bit)

            # --- complement / mirror ---
            for spot in spot_orders():
                def _mirror(data: bytes, _br=br, _xk=xk, _spot=spot) -> Grid | None:
                    b = prep(data, _br, _xk)
                    vals = [76 - x for x in b if 1 <= x <= 75]
                    if len(vals) < 12:
                        return None
                    vals = (vals * 2)[:24]
                    return fill_grid(spot_orders()[_spot], vals)

                yield (f"byte_mirror76 byte_rev={br} {xlabel} spot={spot}", _mirror)


def score_oracle(oracle: Oracle) -> tuple[int, str | None, int]:
    payload = bytes.fromhex(oracle.payload_hex)
    blocks = [payload[i * 12 : (i + 1) * 12] for i in range(4)]
    active = [b for b in blocks if any(x != 0 for x in b)]
    if len(active) != 4:
        return 0, None, 0

    best_score = 0
    best_label: str | None = None
    best_set = 0
    exact: str | None = None
    strategies = list(decode_strategies())
    total = len(strategies)

    for si, (label, decode) in enumerate(strategies):
        decoded: list[Grid] = []
        try:
            for block in active:
                g = decode(block)
                if g is None:
                    break
                decoded.append(g)
        except Exception:
            continue
        if len(decoded) != 4:
            continue

        for perm in itertools.permutations(range(4)):
            score = sum(overlap(decoded[perm[i]], oracle.cards[i]) for i in range(4))
            set_sc = sum(set_overlap(decoded[perm[i]], oracle.cards[i]) for i in range(4))
            if score > best_score:
                best_score = score
                best_label = f"{label} perm={[p+1 for p in perm]}"
                best_set = set_sc
            if score == FULL:
                exact = f"{label} perm={[p+1 for p in perm]}"
                return FULL, exact, FULL

    return best_score, best_label, best_set


def main() -> None:
    print("Wide bingo codec search (col_local uses nibble+1 => col*15+nibble+1)")
    print(f"Strategy count per slot (approx): {len(list(decode_strategies()))}\n")

    global_best = 0
    for oracle in ORACLES:
        score, label, set_sc = score_oracle(oracle)
        print(f"=== {oracle.name} ===")
        if score == FULL:
            print(f"  EXACT MATCH: {label}")
        else:
            print(f"  best position overlap: {score}/{FULL}")
            print(f"  best set overlap (numbers only): {set_sc}/{FULL}")
            print(f"  config: {label}")
        global_best = max(global_best, score)
        print()

    if global_best < FULL:
        print("No exact match in wide search across all sessions.")
    else:
        print("At least one session decoded exactly.")


if __name__ == "__main__":
    main()
