"""Board utilities and win/draw detection for Connect 4."""

from typing import List, Optional, Tuple

from base_models import (
    ExceptionType,
    GameException,
    GameOutcome,
    GRID_COLUMNS,
    GRID_ROWS,
    WinningLine,
)

Grid = List[List[int]]


def create_empty_grid() -> Grid:
    """Create an empty Connect 4 grid.

    Returns:
        Grid: A ``GRID_ROWS x GRID_COLUMNS`` grid initialized with ``0``.
    """
    return [[0 for _ in range(GRID_COLUMNS)] for _ in range(GRID_ROWS)]


def validate_column(column: int) -> None:
    """Validate that a column index is within bounds.

    Args:
        column: Zero-based column index.

    Raises:
        GameException: If the column is outside valid board bounds.
    """
    if column < 0 or column >= GRID_COLUMNS:
        raise GameException(
            message=f"Column {column} is outside valid range 0-{GRID_COLUMNS - 1}.",
            code=1001,
            type=ExceptionType.INVALID_PLACEMENT,
        )


def is_column_full(grid: Grid, column: int) -> bool:
    """Check if a board column is full.

    Args:
        grid: Current game grid.
        column: Zero-based column index.

    Returns:
        bool: ``True`` when no additional disc can be dropped in the column.
    """
    validate_column(column)
    return grid[0][column] != 0


def find_landing_row(grid: Grid, column: int) -> int:
    """Find the landing row for a disc dropped in a column.

    Args:
        grid: Current game grid.
        column: Zero-based column index.

    Returns:
        int: Row index where the disc would land.

    Raises:
        GameException: If the column is full or invalid.
    """
    validate_column(column)
    for row in range(GRID_ROWS - 1, -1, -1):
        if grid[row][column] == 0:
            return row
    raise GameException(
        message=f"Column {column} is full. Choose a different column.",
        code=1002,
        type=ExceptionType.INVALID_PLACEMENT,
    )


def drop_disc(grid: Grid, column: int, player_id: int) -> int:
    """Drop a player's disc into a column using gravity.

    Args:
        grid: Current game grid (modified in place).
        column: Zero-based column index selected by the player.
        player_id: Player identifier (expected ``1`` or ``2``).

    Returns:
        int: Landing row where the disc is placed.

    Raises:
        GameException: If the move is invalid (bad player id, column out of
            range, or full column).
    """
    if player_id not in (1, 2):
        raise GameException(
            message=f"Invalid player id {player_id}. Expected 1 or 2.",
            code=1003,
            type=ExceptionType.INVALID_PLACEMENT,
        )
    row = find_landing_row(grid, column)
    grid[row][column] = player_id
    return row


def _in_bounds(row: int, column: int) -> bool:
    """Return whether a cell location is inside the grid."""
    return 0 <= row < GRID_ROWS and 0 <= column < GRID_COLUMNS


def _line_has_four(grid: Grid, row: int, column: int, d_row: int, d_col: int) -> bool:
    """Check whether a line from a start cell contains four matching discs."""
    player_id = grid[row][column]
    if player_id == 0:
        return False

    for step in range(1, 4):
        next_row = row + d_row * step
        next_col = column + d_col * step
        if not _in_bounds(next_row, next_col):
            return False
        if grid[next_row][next_col] != player_id:
            return False
    return True


def find_winning_line(grid: Grid) -> Optional[Tuple[int, WinningLine]]:
    """Find the first connect-four line on the board.

    Args:
        grid: Current board grid.

    Returns:
        ``(player_id, four (row, col) cells)`` along the line from one end to the
        other, or ``None`` if there is no connect-four.
    """
    directions = (
        (0, 1),  # horizontal
        (1, 0),  # vertical
        (1, 1),  # diagonal down-right
        (1, -1),  # diagonal down-left
    )
    for row in range(GRID_ROWS):
        for column in range(GRID_COLUMNS):
            player_id = grid[row][column]
            if player_id == 0:
                continue
            for d_row, d_col in directions:
                if _line_has_four(grid, row, column, d_row, d_col):
                    cells: List[Tuple[int, int]] = [
                        (row + d_row * k, column + d_col * k) for k in range(4)
                    ]
                    line = (
                        cells[0],
                        cells[1],
                        cells[2],
                        cells[3],
                    )
                    return (player_id, line)
    return None


def get_winner(grid: Grid) -> GameOutcome | None:
    """Return winner outcome if the board contains a connect-four.

    Args:
        grid: Current board grid.

    Returns:
        Optional terminal winner outcome. ``None`` means no winner yet.
    """
    result = find_winning_line(grid)
    if result is None:
        return None
    player_id, _ = result
    return GameOutcome.PLAYER_1_WIN if player_id == 1 else GameOutcome.PLAYER_2_WIN


def is_draw(grid: Grid) -> bool:
    """Return whether the board is a draw state.

    A draw is reached when the board is full and no player has won.
    """
    if get_winner(grid) is not None:
        return False
    return all(grid[0][column] != 0 for column in range(GRID_COLUMNS))
