import os
import json
import numpy as np
import PIL.Image  # type: ignore

# Type definitions
Image    = np.ndarray
Position = tuple[int, int]
Size     = tuple[int, int]

np.set_printoptions(threshold=200)

MINIPROJECT_OPTIONS = {
    "skip_output_images": False,
}


def clamp(minimum: int, maximum: int, value: int) -> int:
    """Clamp a value between the given bounds (inclusive)."""
    return min(max(value, minimum), maximum)


def load_image(filename: str) -> Image:
    """Load an image and return it as a numpy array."""
    print(">> Reading image from", filename)
    return np.array(PIL.Image.open(filename))


def save_image(img: Image, filename: str) -> None:
    """Save an image to a file."""
    if MINIPROJECT_OPTIONS["skip_output_images"]:
        print("** Skipping output image", filename)
        return
    print("<< Writing image to", filename)
    PIL.Image.fromarray(np.clip(img, 0, 255).astype(np.uint8)).save(filename)


def dimensions(img: Image) -> tuple[int, int]:
    """Return the dimensions of an image as (height, width)."""
    return img.shape[0], img.shape[1]


def copy_image(img: Image) -> Image:
    """Return a copy of the given image."""
    return np.copy(img)


def load_config(folder: str = "tiles") -> dict:
    """
    Load the puzzle configuration written by generate_tiles.py.

    Returns a dict with keys:
      'grid'             — int grid size
      'n_given'          — number of fixed tiles
      'n_missing'        — number of tiles to place
      'given_positions'  — list of [r, c] for fixed tiles
      'missing_solution' — dict mapping str(file_index) -> [r, c]

    This function is provided — you do not need to implement it.
    """
    path = os.path.join(folder, "config.json")
    with open(path) as f:
        raw = json.load(f)
    return {
        "grid":             raw["grid"],
        "n_given":          raw["n_given"],
        "n_missing":        raw["n_missing"],
        "given_positions":  [tuple(p) for p in raw["given_positions"]],
        "missing_solution": {int(k): tuple(v)
                             for k, v in raw["missing_solution"].items()},
    }


def load_given_tiles(folder: str, given_positions: list[Position]) -> dict[Position, Image]:
    """
    Load all given (fixed) tiles from `folder`.
    Returns a dict mapping (row, col) -> tile image.
    This function is provided — you do not need to implement it.
    """
    tiles = {}
    for (r, c) in given_positions:
        path = os.path.join(folder, f"tile_given_{r}_{c}.jpg")
        tiles[(r, c)] = np.array(PIL.Image.open(path)).astype(np.int16)
    print(f">> Loaded {len(tiles)} given tiles from {folder}/")
    return tiles


def load_missing_tiles(folder: str, n_missing: int) -> list[Image]:
    """
    Load all missing (shuffled) tiles from `folder`.
    Returns a list of tile images in file order (tile_missing_0, 1, ...).
    This function is provided — you do not need to implement it.
    """
    tiles = []
    for i in range(n_missing):
        path = os.path.join(folder, f"tile_missing_{i}.jpg")
        tiles.append(np.array(PIL.Image.open(path)).astype(np.int16))
    print(f">> Loaded {n_missing} missing tiles from {folder}/")
    return tiles

def crop_image(img: Image, corner: Position, size: Size) -> Image:
    """
    Crop a rectangular region from an image and return it as a new image.
    corner = (row, col) is the top-left corner; size = (height, width).
    This function is provided — you do not need to implement it.
    """
    r, c = corner
    h, w = size
    return copy_image(img[r: r + h, c: c + w])


def save_grid(
    given_tiles: dict[Position, Image],
    missing_tiles: list[Image],
    arrangement: dict[int, Position],
    grid: int,
    filename: str,
) -> None:
    """
    Assemble and save the full grid.

    given_tiles  : dict mapping (r,c) -> tile image for fixed tiles.
    missing_tiles: list of missing tile images.
    arrangement  : dict mapping file_index -> (r, c) for missing tiles.
    grid         : grid size.
    filename     : output path.

    This function is provided — you do not need to implement it.
    """
    tile_h, tile_w = next(iter(given_tiles.values())).shape[:2]
    canvas = np.full((grid * tile_h, grid * tile_w, 3), 160, dtype=np.uint8)

    for (r, c), tile in given_tiles.items():
        canvas[r*tile_h:(r+1)*tile_h, c*tile_w:(c+1)*tile_w] = \
            np.clip(tile, 0, 255).astype(np.uint8)

    for file_idx, (r, c) in arrangement.items():
        if r >= 0 and c >= 0:
            canvas[r*tile_h:(r+1)*tile_h, c*tile_w:(c+1)*tile_w] = \
                np.clip(missing_tiles[file_idx], 0, 255).astype(np.uint8)

    save_image(canvas, filename)