import csv
import datetime
import secrets
from collections import defaultdict, deque
from dataclasses import dataclass
from datetime import timedelta
from pathlib import Path
from pprint import pformat
from typing import Annotated

from fastapi import Depends, FastAPI, Request

CWD = Path(__file__).parent

# Weighted toward common Russian letters
WEIGHTED_ALPHABET = (
    "ааааааааоооооооороророрккккккееееетететилннннннснснснсппупмддддввззггяячшхфжыцйюъ"
)


@dataclass(slots=True)
class Puzzle:
    """Valid puzzle."""

    board: list[list[str]]
    solutions: dict[str, list[tuple[int, int]]]
    extra: dict[str, list[tuple[int, int]]]
    word_defs: dict[str, str]

    def print(self) -> None:
        """Print information about the solution."""
        data = (
            f"Solutions: {len(self.solutions)}\n"
            f"Extra:{len(self.extra)}\n"
            f"Total words: {len(self.word_defs)}\n\n"
        )
        for row in self.board:
            data += "|".join(row) + "\n"

        data += pformat(self.solutions) + "\n"
        data += pformat(self.extra) + "\n"
        print(data)  # noqa: T201


class TrieNode:
    """Prefix tree for optimized solution search."""

    def __init__(self, value: str = "") -> None:
        self.value = value
        self.children: dict[str, TrieNode] = {}
        self.valid_word = False

    def get_next(self, symbol: str) -> "TrieNode | None":
        """
        Get next possible node in from a given symobl.

        Simple lookup in children of this prefix.
        """
        return self.children.get(symbol)

    def add(self, word: str) -> None:
        """Add a word to the trie."""
        word = word.lower()
        node = self
        for i in range(len(word)):
            if word[i] not in node.children:
                node.children[word[i]] = TrieNode(word[: i + 1])
            node = node.children[word[i]]
        node.valid_word = True

    def find_node(self, word: str) -> "TrieNode | None":
        """
        Find a node in the trie.

        The node will only be returned if
        the word is in the trie, or
        is a prefix of a longer word.
        """
        word = word.lower()
        node = self
        for i in range(len(word)):
            if word[i] not in node.children:
                return None
            node = node.children[word[i]]
        return node

    def __str__(self) -> str:
        ret = "Node"
        if self.value:
            ret += f"(val={self.value})"
        ret += "\n"
        for prefix in self.children:
            ret += f"  {prefix}:\n"
            child = str(self.children[prefix])
            for line in child.splitlines():
                ret += f"    {line}\n"
        return ret


class PuzzleGenerator:
    """Helper for generating puzzles."""

    def __init__(self, dict_path: Path) -> None:
        self.words: dict[str, str] = {}
        self.trie = TrieNode()
        self._load_dict(dict_path)

    def _load_dict(self, dict_path: Path) -> None:
        with dict_path.open("r") as f:
            rows = csv.DictReader(f, delimiter="|")
            for row in rows:
                word = row["word"].lower().replace("ё", "е")  # noqa: RUF001
                self.words[word] = row["def"]
                self.trie.add(word)

    def _find_solutions(  # noqa: C901
        self, board: list[list[str]]
    ) -> dict[str, list[tuple[int, int]]]:
        """
        Find all solutions for the given board.

        It will look at all possible words
        that can be found for the board,
        starting from any point.
        """
        solutions = {}
        # This thing has a path and a reference
        # to a trie node.
        paths: deque[tuple[list[tuple[int, int]], TrieNode]] = deque()

        # Initialize paths.
        for x in range(4):
            for y in range(4):
                cell = board[x][y]
                if not cell:
                    continue
                prefix = self.trie.get_next(cell)
                if prefix is not None:
                    paths.append(([(x, y)], prefix))

        while paths:
            path, trie_node = paths.pop()
            if trie_node.valid_word and len(trie_node.value) >= 4:
                word = trie_node.value
                if word in solutions:
                    return {}
                solutions[word] = path
            for dx, dy in (
                (1, 1),
                (-1, -1),
                (-1, 1),
                (1, -1),
                (1, 0),
                (-1, 0),
                (0, 1),
                (0, -1),
            ):
                newpoint = (path[-1][0] + dx, path[-1][1] + dy)
                # If we try to get to the same point twice
                if newpoint in path:
                    continue
                # If the word is within board bounds,
                # we check if the current path is a valid word.
                # If no, continue.
                if 0 <= newpoint[0] < 4 and 0 <= newpoint[1] < 4:
                    next_letter = board[newpoint[0]][newpoint[1]]
                    if next_letter is None:
                        continue
                    next_node = trie_node.get_next(next_letter)
                    if next_node is not None:
                        full_path = [*path, newpoint]
                        paths.append((full_path, next_node))

        return solutions

    def _find_minimal_cover(  # noqa: C901, PLR0912
        self, solutions: dict[str, list[tuple[int, int]]], target: int
    ) -> tuple[dict[str, list[tuple[int, int]]], dict[str, list[tuple[int, int]]]]:
        """
        Find minimum cover of the board.

        This function is used to find minimum
        possible combination of words that
        cover the whole board.

        Also, at the end we find extra words that can
        be found along the cover ones using same paths.
        """
        all_cells = set()
        for path in solutions.values():
            all_cells.update(path)

        if len(all_cells) < 16:
            return solutions, {}

        uncovered = set(all_cells)
        cover: dict[str, list[tuple[int, int]]] = {}
        sorted_words = sorted(solutions.items(), key=lambda x: len(x[1]), reverse=True)

        for word, path in sorted_words:
            new_cells = set(path) & uncovered
            if new_cells:
                cover[word] = path
                uncovered -= new_cells
            if not uncovered:
                break

        # Count how many times each cell is covered by minimal cover
        cell_coverage: defaultdict[tuple[int, int], int] = defaultdict(int)
        for path in cover.values():
            for tile in path:
                cell_coverage[tile] += 1

        # Fill to target: prefer longer words that cover least-covered cells
        while len(cover) < target:
            best_word = None
            best_score: float = -1
            for word, path in sorted_words:
                if word in cover:
                    continue
                score = len(path) * 10 + sum(
                    1 / (1 + cell_coverage[tile]) for tile in path
                )
                if score > best_score:
                    best_score = score
                    best_word = word
            if best_word is not None:
                cover[best_word] = solutions[best_word]
                for tile in solutions[best_word]:
                    cell_coverage[tile] += 1
            else:
                break

        # We want to remove all unnecessary neighbours

        neigbours: defaultdict[tuple[int, int], set[tuple[int, int]]] = defaultdict(set)

        for path in cover.values():
            prev = path[0]
            for tile in path[1:]:
                neigbours[prev].add(tile)
                neigbours[tile].add(prev)
                prev = tile

        extras = {w: p for w, p in solutions.items() if w not in cover}

        to_delete = []
        for word, path in extras.items():
            current = path[0]
            for next_point in path[1:]:
                if next_point not in neigbours[current]:
                    to_delete.append(word)
                    break
                current = next_point

        for word in to_delete:
            extras.pop(word)

        return cover, extras

    def _get_valid_points(
        self,
        solutions: dict[str, list[tuple[int, int]]],
    ) -> set[tuple[int, int]]:
        """Get all points that are used in solutions."""
        used_cells = set()
        for solution in solutions.values():
            used_cells |= set(solution)

        return used_cells

    def _verify_solution(
        self, board: list[list[str]], target: int
    ) -> tuple[set[tuple[int, int]], Puzzle | None]:
        solutions = self._find_solutions(board)
        cover, extra = self._find_minimal_cover(solutions, target)
        valid_points = self._get_valid_points(cover)

        # We found a valid solution | board combination
        if len(valid_points) != 16 or len(cover) < target:
            return valid_points, None

        word_defs = {word: self.words[word] for word in cover | extra}

        return valid_points, Puzzle(
            board=board,
            solutions=cover,
            extra=extra,
            word_defs=word_defs,
        )

    def check_board(self, compressed_board: str, target: int = 10) -> Puzzle | None:
        """Check if given board is valid."""
        board = [list(compressed_board[i * 4 : i * 4 + 4]) for i in range(4)]
        _, puzzle = self._verify_solution(board, target)
        return puzzle

    def generate(self, target: int, timeout: int = 10) -> Puzzle:
        """
        Generate a valid puzzle that has solutions.

        :param target: minimum number of words required to solve the puzzle.
        :param timeout: since puzzles can fail to generate sometimes,
            we use timeout to not let the puzzle generator hung forever!
        :return: puzzle.
        """
        board: list[list[str]] = [["" for _ in range(4)] for _ in range(4)]
        valid_points: set[tuple[int, int]] = set()
        single_board_attempt = 0
        started = datetime.datetime.now()
        while True:
            if datetime.datetime.now() - started > timedelta(seconds=timeout):
                raise ValueError(
                    f"Coud not find a valid solution in {timeout} seconds!"
                )
            if single_board_attempt > 50:
                valid_points = set()
                single_board_attempt = 0

            for x in range(4):
                for y in range(4):
                    if (x, y) not in valid_points:
                        board[x][y] = secrets.choice(WEIGHTED_ALPHABET)

            valid_points, puzzle = self._verify_solution(board, target)
            if not puzzle:
                single_board_attempt += 1
                continue

            return puzzle


def setup_generator(app: FastAPI) -> None:
    """Create puzzle-generator."""
    app.state.generator = PuzzleGenerator(CWD / "dict.csv")


def get_generator(request: Request) -> PuzzleGenerator:
    """Get puzzle generator from the state."""
    return request.app.state.generator


PuzzleGeneratorDep = Annotated[PuzzleGenerator, Depends(get_generator)]


if __name__ == "__main__":
    generator = PuzzleGenerator(CWD / "dict.csv")
    res = generator.generate(target=10)
    if res is None:
        print("No solution found!")  # noqa: T201
    else:
        res.print()
