from __future__ import annotations

from dataclasses import dataclass


@dataclass(frozen=True)
class Piece:
    """A polyomino piece defined by a set of (x, y) coordinates.

    Coordinates are relative to a reference point (typically the
    top-left corner of the bounding box after normalization).
    """

    name: str
    squares: frozenset[tuple[int, int]]

    @property
    def size(self) -> int:
        return len(self.squares)

    def to_dict(self) -> dict:
        return {
            "name": self.name,
            "squares": sorted(self.squares),
        }

    @classmethod
    def from_dict(cls, data: dict) -> Piece:
        return cls(
            name=data["name"],
            squares=frozenset(tuple(s) for s in data["squares"]),
        )


@dataclass
class OrientedPiece:
    """A piece in a specific orientation placed at a specific position."""

    piece_id: int
    orientation_id: int
    squares: list[tuple[int, int]]
    corners: list[tuple[int, int]]


@dataclass
class Move:
    """A complete move: which piece, which orientation, where placed."""

    piece_id: int
    orientation_id: int
    x: int
    y: int


@dataclass
class PieceOrientation:
    """A piece in a specific orientation (before placement on board)."""

    piece_id: int
    orientation_id: int
    squares: list[tuple[int, int]]  # normalized relative coordinates
    corners: list[tuple[int, int]]  # corner cells relative to squares


def _normalize(squares: list[tuple[int, int]]) -> list[tuple[int, int]]:
    """Shift coordinates so the minimum x and y are 0."""
    min_x = min(x for x, _ in squares)
    min_y = min(y for _, y in squares)
    return [(x - min_x, y - min_y) for x, y in squares]


def _rotate(squares: list[tuple[int, int]]) -> list[tuple[int, int]]:
    """Rotate 90 degrees clockwise: (x, y) -> (y, -x)."""
    return [(y, -x) for x, y in squares]


def _flip(squares: list[tuple[int, int]]) -> list[tuple[int, int]]:
    """Flip horizontally: (x, y) -> (-x, y)."""
    return [(-x, y) for x, y in squares]


def _compute_corners(squares: list[tuple[int, int]]) -> list[tuple[int, int]]:
    """Compute the corner cells for a piece.

    Corner cells are the diagonal neighbors of each square that are not
    edge-adjacent to any other square in the piece. These are the cells
    where a same-color piece must touch (corner-to-corner).
    """
    square_set = set(squares)
    corners = set()
    for x, y in squares:
        for dx, dy in [(-1, -1), (1, -1), (-1, 1), (1, 1)]:
            cx, cy = x + dx, y + dy
            if (cx, cy) not in square_set:
                # Check that this corner is not edge-adjacent to another square
                edge_neighbors = [
                    (cx + 1, cy), (cx - 1, cy), (cx, cy + 1), (cx, cy - 1)
                ]
                if not any(en in square_set for en in edge_neighbors):
                    corners.add((cx, cy))
    return sorted(corners)


def generate_orientations(piece: Piece) -> list[PieceOrientation]:
    """Generate all unique orientations of a piece.

    Applies 4 rotations and 2 flips, then deduplicates by comparing
    normalized coordinate sets.
    """
    orientations: list[PieceOrientation] = []
    seen: set[tuple[tuple[int, int], ...]] = set()

    squares = sorted(piece.squares)
    for flip_count in range(2):
        for rotation_count in range(4):
            current = list(squares)
            for _ in range(rotation_count):
                current = _rotate(current)
            for _ in range(flip_count):
                current = _flip(current)
            normalized = tuple(sorted(_normalize(current)))
            if normalized not in seen:
                seen.add(normalized)
                corners = _compute_corners(list(normalized))
                orientations.append(
                    PieceOrientation(
                        piece_id=-1,  # Set by PieceSet
                        orientation_id=len(orientations),
                        squares=list(normalized),
                        corners=corners,
                    )
                )
    return orientations


# ---------------------------------------------------------------------------
# Standard Blokus piece sets
# ---------------------------------------------------------------------------

# The 21 free polyominoes of size 1-5
# Naming follows standard pentomino/tetromino conventions

_I1 = Piece("I1", frozenset([(0, 0)]))

_I2 = Piece("I2", frozenset([(0, 0), (0, 1)]))

_I3 = Piece("I3", frozenset([(0, 0), (0, 1), (0, 2)]))

_V3 = Piece("V3", frozenset([(0, 0), (1, 0), (0, 1)]))

_I4 = Piece("I4", frozenset([(0, 0), (0, 1), (0, 2), (0, 3)]))

_L4 = Piece("L4", frozenset([(0, 0), (0, 1), (0, 2), (1, 0)]))

_T4 = Piece("T4", frozenset([(0, 0), (1, 0), (2, 0), (1, 1)]))

_S4 = Piece("S4", frozenset([(0, 0), (1, 0), (1, 1), (2, 1)]))

_O4 = Piece("O4", frozenset([(0, 0), (1, 0), (0, 1), (1, 1)]))

_I5 = Piece("I5", frozenset([(0, 0), (0, 1), (0, 2), (0, 3), (0, 4)]))

_L5 = Piece("L5", frozenset([(0, 0), (0, 1), (0, 2), (0, 3), (1, 0)]))

_Y5 = Piece("Y5", frozenset([(0, 0), (0, 1), (0, 2), (0, 3), (1, 1)]))

_N5 = Piece("N5", frozenset([(0, 0), (1, 0), (2, 0), (2, 1), (3, 1)]))

_T5 = Piece("T5", frozenset([(0, 0), (1, 0), (2, 0), (1, 1), (1, 2)]))

_U5 = Piece("U5", frozenset([(0, 0), (2, 0), (0, 1), (1, 1), (2, 1)]))

_V5 = Piece("V5", frozenset([(0, 0), (0, 1), (0, 2), (1, 0), (2, 0)]))

_W5 = Piece("W5", frozenset([(0, 0), (0, 1), (1, 0), (1, 1), (2, 0)]))

_Z5 = Piece("Z5", frozenset([(0, 0), (1, 0), (1, 1), (1, 2), (2, 2)]))

_F5 = Piece("F5", frozenset([(0, 0), (1, 0), (1, 1), (2, 1), (1, 2)]))

_X5 = Piece("X5", frozenset([(0, 0), (1, 0), (2, 0), (1, 1), (1, -1)]))

_P5 = Piece("P5", frozenset([(0, 0), (1, 0), (0, 1), (1, 1), (0, 2)]))

STANDARD_PIECES: list[Piece] = [
    _I1, _I2, _I3, _V3, _I4, _L4, _T4, _S4, _O4,
    _I5, _L5, _Y5, _N5, _T5, _U5, _V5, _W5, _Z5, _F5, _X5, _P5,
]

# Blokus Duo uses the same pieces but on a 14x14 board
DUO_PIECES: list[Piece] = STANDARD_PIECES

# Blokus Junior: simplified pieces (only 12 unique pieces, 2 copies each)
# Uses only pieces with size <= 4 for simplicity
JUNIOR_PIECES: list[Piece] = [
    _I1, _I2, _I3, _V3, _I4, _L4, _T4, _S4, _O4,
]


class PieceSet:
    """A collection of pieces with pre-computed orientations.

    This class manages the piece set used in a Blokus game, including
    all unique orientations for each piece and a lookup table for
    generating moves.
    """

    def __init__(self, pieces: list[Piece]):
        self.pieces: list[Piece] = pieces
        self.piece_names: list[str] = [p.name for p in pieces]
        self.piece_id_map: dict[str, int] = {name: i for i, name in enumerate(self.piece_names)}

        # Pre-compute all orientations for each piece
        self.orientations: list[list[PieceOrientation]] = []
        for piece_id, piece in enumerate(pieces):
            orients = generate_orientations(piece)
            for orient in orients:
                orient.piece_id = piece_id
            self.orientations.append(orients)

        # Total number of (piece, orientation) combinations
        self.num_orientations: int = sum(len(o) for o in self.orientations)

    @property
    def num_pieces(self) -> int:
        return len(self.pieces)

    def get_orientations(self, piece_id: int) -> list[PieceOrientation]:
        return self.orientations[piece_id]

    def get_piece(self, piece_id: int) -> Piece:
        return self.pieces[piece_id]

    def get_piece_id(self, name: str) -> int:
        return self.piece_id_map[name]

    def to_dict(self) -> dict:
        return {
            "pieces": [p.to_dict() for p in self.pieces],
        }

    @classmethod
    def from_dict(cls, data: dict) -> PieceSet:
        pieces = [Piece.from_dict(p) for p in data["pieces"]]
        return cls(pieces)

    @classmethod
    def from_json(cls, path: str) -> PieceSet:
        import json

        with open(path) as f:
            data = json.load(f)
        return cls.from_dict(data)

    def save_json(self, path: str) -> None:
        import json

        with open(path, "w") as f:
            json.dump(self.to_dict(), f, indent=2)

    def __len__(self) -> int:
        return len(self.pieces)

    def __repr__(self) -> str:
        return f"PieceSet(num_pieces={self.num_pieces}, num_orientations={self.num_orientations})"
