"""Public API — the only import most users need."""

from __future__ import annotations

from dataclasses import dataclass
from typing import Literal

from hodoku.core.grid import ALL_UNITS, Grid
from hodoku.core.scoring import DIFFICULTY_MAX_SCORE, SOLVER_STEPS
from hodoku.core.solution_step import SolutionStep
from hodoku.core.types import DifficultyType
from hodoku.generator.generator import SudokuGenerator
from hodoku.generator.pattern import GeneratorPattern
from hodoku.solver.solver import SudokuSolver
from hodoku.solver.step_finder import SudokuStepFinder

_VALID_CELL_CHARS = frozenset("0123456789.")


def _validate_puzzle(puzzle: str) -> None:
    """Raise ValueError for malformed puzzle strings.

    Accepts the same format as Grid.set_sudoku: 81 characters, each '0'-'9'
    or '.'.  Also accepts the '+D' placed-cell prefix notation.  Raises on
    invalid characters, wrong cell count, or duplicate digits in any house.
    """
    cells: list[str] = []
    i = 0
    while i < len(puzzle):
        ch = puzzle[i]
        if ch == "+":
            i += 1
            if i < len(puzzle):
                ch = puzzle[i]
                if ch not in _VALID_CELL_CHARS:
                    raise ValueError(f"Invalid character after '+': {ch!r}")
                cells.append(ch)
        elif ch in _VALID_CELL_CHARS:
            cells.append(ch)
        else:
            raise ValueError(f"Invalid character in puzzle string: {ch!r}")
        i += 1

    if len(cells) != 81:
        raise ValueError(f"Puzzle must have exactly 81 cells, got {len(cells)}")

    # Check for duplicate digits within each house (row, col, box)
    for unit in ALL_UNITS:
        seen: dict[str, int] = {}
        for idx in unit:
            d = cells[idx]
            if d in ("0", "."):
                continue
            if d in seen:
                raise ValueError(
                    f"Duplicate digit {d} in house (cells {seen[d]} and {idx})"
                )
            seen[d] = idx


@dataclass
class SolveResult:
    solved: bool
    steps: list[SolutionStep]
    level: DifficultyType
    score: int
    solution: str  # 81-char string


@dataclass
class RatingResult:
    level: DifficultyType
    score: int


class Solver:
    """Solves and analyses sudoku puzzles using human-style logic."""

    def __init__(self) -> None:
        self._solver = SudokuSolver()

    def solve(self, puzzle: str) -> SolveResult:
        """Solve a puzzle string, returning the full solution path."""
        _validate_puzzle(puzzle)
        result = self._solver.solve(puzzle)
        return SolveResult(
            solved=result.solved,
            steps=result.steps,
            level=result.level,
            score=result.score,
            solution=result.solution,
        )

    def get_hint(self, puzzle: str) -> SolutionStep | None:
        """Return the next logical step, or None if already solved or stuck."""
        _validate_puzzle(puzzle)
        grid = Grid()
        grid.set_sudoku(puzzle)
        if grid.is_solved():
            return None
        finder = SudokuStepFinder(grid)
        for cfg in SOLVER_STEPS:
            step = finder.get_step(cfg.solution_type)
            if step is not None:
                return step
        return None

    def rate(self, puzzle: str) -> RatingResult:
        """Return difficulty level and score without recording solution steps."""
        _validate_puzzle(puzzle)
        result = self._solver.solve(puzzle)
        return RatingResult(level=result.level, score=result.score)

    def find_all_steps(self, puzzle: str) -> list[SolutionStep]:
        """Return every applicable technique at the current grid state."""
        _validate_puzzle(puzzle)
        grid = Grid()
        grid.set_sudoku(puzzle)
        return self._find_all_on_grid(grid)

    def _find_all_on_grid(self, grid: Grid) -> list[SolutionStep]:
        """Return every applicable technique on a pre-built grid."""
        finder = SudokuStepFinder(grid)
        steps: list[SolutionStep] = []
        for cfg in SOLVER_STEPS:
            if cfg.all_steps_enabled:
                steps.extend(finder.find_all(cfg.solution_type))
        return steps


class Generator:
    """Generates valid sudoku puzzles at a requested difficulty level.

    Mirrors Java's ``BackgroundGenerator``: repeatedly generates random
    symmetric puzzles and rates them until one matches the target difficulty.
    """

    MAX_TRIES = 20_000

    def __init__(self) -> None:
        self._generator = SudokuGenerator()
        self._solver = SudokuSolver()

    def generate(
        self,
        difficulty: DifficultyType = DifficultyType.MEDIUM,
        symmetric: bool = True,
        pattern: list[int] | GeneratorPattern | None = None,
        max_tries: int | None = None,
    ) -> str:
        """Generate an 81-character puzzle string at the requested difficulty.

        Raises ``RuntimeError`` if no puzzle matching the difficulty is found
        within *max_tries* attempts (default ``MAX_TRIES``).
        """
        if max_tries is None:
            max_tries = self.MAX_TRIES

        bool_pattern: list[bool] | None = None
        if isinstance(pattern, GeneratorPattern):
            bool_pattern = pattern.pattern
        elif pattern is not None:
            bool_pattern = [bool(p) for p in pattern]

        for _ in range(max_tries):
            puzzle = self._generator.generate_sudoku(
                symmetric=symmetric, pattern=bool_pattern,
            )
            if puzzle is None:
                # Pattern-based generation failed entirely
                raise RuntimeError(
                    "Could not generate a puzzle with the given pattern"
                )

            # Rate the puzzle
            result = self._solver.solve(puzzle)
            if not result.solved or result.level != difficulty:
                continue

            # Reject if score is below the previous level's max_score
            # (mirrors Java's rejectTooLowScore logic)
            if difficulty.value > DifficultyType.EASY.value:
                prev_level = DifficultyType(difficulty.value - 1)
                if result.score < DIFFICULTY_MAX_SCORE[prev_level]:
                    continue

            return puzzle

        raise RuntimeError(
            f"Could not generate a {difficulty.name} puzzle in "
            f"{max_tries} attempts"
        )

    def validate(self, puzzle: str) -> Literal["valid", "invalid", "multiple"]:
        """Check whether a puzzle has exactly one solution.

        Returns ``"valid"`` (unique), ``"invalid"`` (no solution),
        or ``"multiple"`` (more than one solution).
        """
        _validate_puzzle(puzzle)
        grid = Grid()
        grid.set_sudoku(puzzle)
        count = self._generator.get_number_of_solutions(grid)
        if count == 0:
            return "invalid"
        elif count == 1:
            return "valid"
        else:
            return "multiple"
