import argparse
import re
import shutil
import textwrap
from pathlib import Path

from .assets import load_project_version
from .boards import BOARD_MODE_CHOICES, VISIBLE_BOARD_MODE_CHOICES, board_size_fits_paper, board_target_mm_for_mode
from .cards import render_sources
from .layout import grid_capacity, render_board_pages, render_card_pages
from .pdf import write_pdf
from .settings import (
    BOARD_DECK,
    CARD_SIZE_OPTIONS_MM,
    DECKS_DIR,
    PAGES_DIR,
    PAPER_SIZES_MM,
    RENDER_DIR,
    ROOT,
    TEMP_DIR,
    BuildConfig,
    mm_to_px,
)


def should_delete_temp(config: BuildConfig) -> bool:
    if config.render_only:
        return False
    if config.delete_temp is not None:
        return config.delete_temp
    return prompt_bool("Delete temporary rendered files", True)


def build_printable(config: BuildConfig) -> Path | None:
    print("Rendering source cards...")
    card_items, board_items = render_sources(config)
    print(f"Rendered {len(card_items)} card-side images and {len(board_items)} boards.")
    if config.render_only:
        print(f"Render-only mode: wrote source images under {RENDER_DIR.relative_to(ROOT)} and skipped page/PDF creation.")
        return None

    if PAGES_DIR.exists():
        shutil.rmtree(PAGES_DIR)
    PAGES_DIR.mkdir(parents=True, exist_ok=True)

    cols, rows = grid_capacity(config)
    print(f"Card grid: {cols} columns x {rows} rows on {config.paper_name.upper()} ({cols * rows} images/page)")
    next_page, deferred_character_items = render_card_pages(card_items, config, 1)
    next_page = render_board_pages(board_items, config, next_page, deferred_character_items)
    pdf = write_pdf(config)
    if pdf:
        print(f"Wrote {pdf.relative_to(ROOT)}")
    if should_delete_temp(config):
        shutil.rmtree(TEMP_DIR, ignore_errors=True)
        print(f"Deleted {TEMP_DIR.relative_to(ROOT)}")
    return pdf


def prompt_choice(label: str, options, default: str) -> str:
    option_text = ", ".join(options)
    value = input(f"{label} [{default}] ({option_text}): ").strip().lower()
    if not value:
        return default
    if value in options:
        return value
    print(f"Unknown option '{value}', using {default}.")
    return default


def board_size_label(board_mode: str, board_custom_mm: tuple[float, float] | None) -> str:
    if board_custom_mm is None:
        return board_mode
    suffix = "-fit" if board_mode == "custom-fit" else ""
    return f"{board_custom_mm[0]:g}x{board_custom_mm[1]:g}{suffix}"


def print_board_size_does_not_fit(
    board_mode: str,
    board_custom_mm: tuple[float, float] | None,
    paper_mm: tuple[float, float],
    margin_mm: float,
) -> None:
    board_w, board_h = board_target_mm_for_mode(first_board_path(), board_mode, board_custom_mm)
    usable_w = paper_mm[0] - 2 * margin_mm
    usable_h = paper_mm[1] - 2 * margin_mm
    print(
        f"Board size '{board_size_label(board_mode, board_custom_mm)}' "
        f"({board_w:.2f} x {board_h:.2f} mm) does not fit inside "
        f"the printable area ({usable_w:.2f} x {usable_h:.2f} mm)."
    )


def prompt_int(label: str, default: int, allowed=None) -> int:
    suffix = f" [{default}]"
    if allowed:
        suffix += " (" + ", ".join(str(x) for x in allowed) + ")"
    value = input(f"{label}{suffix}: ").strip()
    if not value:
        return default
    try:
        parsed = int(value)
    except ValueError:
        print(f"Invalid number '{value}', using {default}.")
        return default
    if allowed and parsed not in allowed:
        print(f"Unsupported value '{parsed}', using {default}.")
        return default
    return parsed


def prompt_positive_int(label: str, default: int) -> int:
    value = input(f"{label} [{default}]: ").strip()
    if not value:
        return default
    try:
        parsed = int(value)
    except ValueError:
        print(f"Invalid number '{value}', using {default}.")
        return default
    if parsed <= 0:
        print(f"Unsupported value '{parsed}', using {default}.")
        return default
    return parsed


def prompt_nonnegative_float(label: str, default: float) -> float:
    value = input(f"{label} [{default:g}]: ").strip()
    if not value:
        return default
    try:
        parsed = float(value)
    except ValueError:
        print(f"Invalid number '{value}', using {default:g}.")
        return default
    if parsed < 0:
        print(f"Unsupported value '{parsed:g}', using {default:g}.")
        return default
    return parsed


def prompt_bool(label: str, default: bool) -> bool:
    suffix = "Y/n" if default else "y/N"
    value = input(f"{label} [{suffix}]: ").strip().lower()
    if not value:
        return default
    return value.startswith("y")


def prompt_card_size(default: tuple[float, float]) -> tuple[float, float]:
    options = list(CARD_SIZE_OPTIONS_MM)
    choice = prompt_choice("Card size", options + ["custom"], "poker")
    if choice == "custom":
        raw = input("Custom card size in mm, width x height: ").strip().lower().replace(" ", "")
        match = re.fullmatch(r"([0-9.]+)x([0-9.]+)", raw)
        if match:
            return float(match.group(1)), float(match.group(2))
        print(f"Invalid custom size, using {default[0]} x {default[1]} mm.")
        return default
    return CARD_SIZE_OPTIONS_MM[choice]


def prompt_board_mode(
    paper_mm: tuple[float, float],
    margin_mm: float,
    initial: tuple[str, tuple[float, float] | None] | None = None,
) -> tuple[str, tuple[float, float] | None]:
    if initial is not None:
        board_mode, custom_board = initial
        if board_size_fits_paper(board_mode, custom_board, paper_mm, margin_mm):
            return board_mode, custom_board
        print_board_size_does_not_fit(board_mode, custom_board, paper_mm, margin_mm)

    option_text = ", ".join(VISIBLE_BOARD_MODE_CHOICES)
    while True:
        raw = input(f"Board size [original] ({option_text}): ").strip().lower()
        if not raw:
            raw = "original"
        try:
            board_mode, custom_board = parse_board_size_arg(raw)
        except argparse.ArgumentTypeError as error:
            print(error)
            continue
        if board_size_fits_paper(board_mode, custom_board, paper_mm, margin_mm):
            return board_mode, custom_board
        print_board_size_does_not_fit(board_mode, custom_board, paper_mm, margin_mm)


def prompt_gamma() -> tuple[bool, float, float]:
    choice = prompt_choice("Gamma correction", ["default", "none", "custom"], "default")
    if choice == "none":
        return False, 1.0, 0.0
    if choice == "custom":
        raw = input("Gamma exponent [0.7]: ").strip()
        try:
            gamma = float(raw) if raw else 0.7
        except ValueError:
            gamma = 0.7
        return True, gamma, 0.02
    return True, 0.7, 0.02


def prompt_output_pdf_name() -> str:
    return input("Output PDF name [blockminers_printable.pdf]: ").strip()


def parse_card_size_arg(value: str) -> tuple[float, float]:
    normalized = value.strip().lower().replace(" ", "")
    if normalized in CARD_SIZE_OPTIONS_MM:
        return CARD_SIZE_OPTIONS_MM[normalized]
    match = re.fullmatch(r"([0-9]+(?:\.[0-9]+)?)x([0-9]+(?:\.[0-9]+)?)", normalized)
    if not match:
        choices = ", ".join(sorted(CARD_SIZE_OPTIONS_MM))
        raise argparse.ArgumentTypeError(f"expected one of {choices}, or WIDTHxHEIGHT in mm")
    width = float(match.group(1))
    height = float(match.group(2))
    if width <= 0 or height <= 0:
        raise argparse.ArgumentTypeError("custom card size must be positive")
    return width, height


def parse_board_size_arg(value: str) -> tuple[str, tuple[float, float] | None]:
    normalized = value.strip().lower().replace(" ", "")
    if normalized in BOARD_MODE_CHOICES and normalized != "custom":
        return normalized, None
    match = re.fullmatch(r"([0-9]+(?:\.[0-9]+)?)x([0-9]+(?:\.[0-9]+)?)(-fit)?", normalized)
    if not match:
        choices = ", ".join(mode for mode in BOARD_MODE_CHOICES if not mode.startswith("custom"))
        raise argparse.ArgumentTypeError(f"expected one of {choices}, or WIDTHxHEIGHT in mm")
    width = float(match.group(1))
    height = float(match.group(2))
    if width <= 0 or height <= 0:
        raise argparse.ArgumentTypeError("custom board size must be positive")
    return ("custom-fit" if match.group(3) else "custom"), (width, height)


def first_board_path() -> Path:
    board_paths = sorted((DECKS_DIR / BOARD_DECK).glob("*-jumbo-front.png"))
    return board_paths[0] if board_paths else DECKS_DIR / BOARD_DECK / "board.png"


def board_size_summary(config: BuildConfig) -> str:
    width, height = board_target_mm_for_mode(first_board_path(), config.board_mode, config.board_custom_mm)
    return f"{config.board_mode} ({width:.2f} x {height:.2f} mm)"


def default_config() -> BuildConfig:
    return BuildConfig(
        paper_name="a3",
        paper_mm=PAPER_SIZES_MM["a3"],
        dpi=300,
        crop_black_borders=False,
        card_mm=CARD_SIZE_OPTIONS_MM["poker"],
        board_mode="original",
        board_custom_mm=None,
        gamma_enabled=True,
        gamma=0.7,
        black_point=0.02,
        project_version=load_project_version(),
        starter_unique_colors=True,
        rent_raised_count=3,
        margin_mm=10.0,
        bleed_mm=1.0,
        clean_edge_mm=0.35,
        output_pdf_name="",
        delete_temp=True,
        render_only=False,
    )


def interactive_config(args: argparse.Namespace) -> BuildConfig:
    default = default_config()
    paper = args.paper or prompt_choice("Paper size", list(PAPER_SIZES_MM), default.paper_name)
    dpi = args.dpi if args.dpi is not None else prompt_int("DPI", default.dpi, [300, 600])

    if args.crop or args.no_crop:
        crop = args.crop
    else:
        crop = prompt_bool("Crop black borders", default.crop_black_borders)

    card_mm = args.card_size if args.card_size else prompt_card_size(default.card_mm)

    if args.margin is not None:
        if args.margin < 0:
            raise SystemExit("--margin must not be negative.")
        margin_mm = args.margin
    else:
        margin_mm = prompt_nonnegative_float("Page margin in mm", default.margin_mm)

    board_mode, custom_board = prompt_board_mode(PAPER_SIZES_MM[paper], margin_mm, args.board_size)

    if args.starter_colors:
        starter_unique_colors = args.starter_colors == "separate"
    else:
        unify_starter_colors = prompt_bool("Unify starter badge colors", not default.starter_unique_colors)
        starter_unique_colors = not unify_starter_colors

    if args.rent_raised is not None:
        if args.rent_raised <= 0:
            raise SystemExit("--rent-raised must be a positive non-zero integer.")
        rent_raised_count = args.rent_raised
    else:
        rent_raised_count = prompt_positive_int("Number of Rent raised", default.rent_raised_count)

    if args.gamma or args.no_gamma:
        gamma_enabled = args.gamma
        gamma = default.gamma
        black_point = default.black_point
    else:
        gamma_enabled, gamma, black_point = prompt_gamma()

    output_pdf_name = args.output if args.output is not None else prompt_output_pdf_name()
    return BuildConfig(
        paper_name=paper,
        paper_mm=PAPER_SIZES_MM[paper],
        dpi=dpi,
        crop_black_borders=crop,
        card_mm=card_mm,
        board_mode=board_mode,
        board_custom_mm=custom_board,
        gamma_enabled=gamma_enabled,
        gamma=gamma,
        black_point=black_point,
        project_version=default.project_version,
        starter_unique_colors=starter_unique_colors,
        rent_raised_count=rent_raised_count,
        margin_mm=margin_mm,
        bleed_mm=default.bleed_mm,
        clean_edge_mm=default.clean_edge_mm,
        output_pdf_name=output_pdf_name,
        delete_temp=None,
        render_only=default.render_only,
    )


def parse_args(argv=None) -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Render Blockminers sources and create printable cut sheets.")
    preset = parser.add_mutually_exclusive_group()
    preset.add_argument("--default", action="store_true", help="Use default settings without prompting.")
    preset.add_argument("--mod", action="store_true", help="Use the high-resolution cropped mod preset.")
    preset.add_argument("--yes", action="store_true", help=argparse.SUPPRESS)
    parser.add_argument("--dpi", type=int, choices=[300, 600], help="Override DPI.")
    parser.add_argument("--paper", choices=sorted(PAPER_SIZES_MM), help="Override paper size.")
    parser.add_argument("--card-size", metavar="SIZE", type=parse_card_size_arg, help="Override card size by name or WIDTHxHEIGHT in mm.")
    parser.add_argument("--board-size", metavar="SIZE", type=parse_board_size_arg, help="Override board size by name or WIDTHxHEIGHT in mm.")
    parser.add_argument("--margin", type=float, help="Page margin in millimeters.")
    crop_group = parser.add_mutually_exclusive_group()
    crop_group.add_argument("--crop", action="store_true", help="Crop black borders.")
    crop_group.add_argument("--no-crop", action="store_true", help="Do not crop black borders.")
    gamma_group = parser.add_mutually_exclusive_group()
    gamma_group.add_argument("--gamma", action="store_true", help="Enable print brightening gamma.")
    gamma_group.add_argument("--no-gamma", action="store_true", help="Disable print brightening gamma.")
    parser.add_argument("--render-only", action="store_true", help="Render source card/board PNGs and skip page/PDF creation.")
    parser.add_argument("--output", help="Output PDF filename under print/. Adds .pdf if missing.")
    parser.add_argument("--rent-raised", type=int, help="Number of Rent raised encounter cards to print.")
    parser.add_argument("--keep-temp", action="store_true", help="Keep temporary rendered PNGs under print/temp/.")
    parser.add_argument(
        "--starter-colors",
        choices=["separate", "same"],
        help="Use five player colors for starter badges, or one category color repeated.",
    )
    return parser.parse_args(argv)


def apply_overrides(config: BuildConfig, args: argparse.Namespace) -> BuildConfig:
    paper_name = args.paper or config.paper_name
    dpi = args.dpi or config.dpi
    delete_temp = False if args.keep_temp else config.delete_temp
    rent_raised_count = config.rent_raised_count
    if args.rent_raised is not None:
        if args.rent_raised <= 0:
            raise SystemExit("--rent-raised must be a positive non-zero integer.")
        rent_raised_count = args.rent_raised
    margin_mm = config.margin_mm
    if args.margin is not None:
        if args.margin < 0:
            raise SystemExit("--margin must not be negative.")
        margin_mm = args.margin
    crop_black_borders = config.crop_black_borders
    if args.crop:
        crop_black_borders = True
    if args.no_crop:
        crop_black_borders = False

    gamma_enabled = config.gamma_enabled
    if args.gamma:
        gamma_enabled = True
    if args.no_gamma:
        gamma_enabled = False

    board_mode = config.board_mode
    board_custom_mm = config.board_custom_mm
    if args.board_size:
        board_mode, board_custom_mm = args.board_size

    return BuildConfig(
        paper_name=paper_name,
        paper_mm=PAPER_SIZES_MM[paper_name],
        dpi=dpi,
        crop_black_borders=crop_black_borders,
        card_mm=args.card_size if args.card_size else config.card_mm,
        board_mode=board_mode,
        board_custom_mm=board_custom_mm,
        gamma_enabled=gamma_enabled,
        gamma=config.gamma,
        black_point=config.black_point,
        project_version=config.project_version,
        starter_unique_colors=(args.starter_colors == "separate") if args.starter_colors else config.starter_unique_colors,
        rent_raised_count=rent_raised_count,
        margin_mm=margin_mm,
        bleed_mm=config.bleed_mm,
        clean_edge_mm=config.clean_edge_mm,
        output_pdf_name=args.output if args.output is not None else config.output_pdf_name,
        delete_temp=delete_temp,
        render_only=args.render_only or config.render_only,
    )


def with_board_size(config: BuildConfig, board_mode: str, board_custom_mm: tuple[float, float] | None) -> BuildConfig:
    return BuildConfig(
        paper_name=config.paper_name,
        paper_mm=config.paper_mm,
        dpi=config.dpi,
        crop_black_borders=config.crop_black_borders,
        card_mm=config.card_mm,
        board_mode=board_mode,
        board_custom_mm=board_custom_mm,
        gamma_enabled=config.gamma_enabled,
        gamma=config.gamma,
        black_point=config.black_point,
        project_version=config.project_version,
        starter_unique_colors=config.starter_unique_colors,
        rent_raised_count=config.rent_raised_count,
        margin_mm=config.margin_mm,
        bleed_mm=config.bleed_mm,
        clean_edge_mm=config.clean_edge_mm,
        output_pdf_name=config.output_pdf_name,
        delete_temp=config.delete_temp,
        render_only=config.render_only,
    )


def ensure_board_size_fits(config: BuildConfig, should_prompt: bool) -> BuildConfig:
    if board_size_fits_paper(config.board_mode, config.board_custom_mm, config.paper_mm, config.margin_mm):
        return config
    print_board_size_does_not_fit(config.board_mode, config.board_custom_mm, config.paper_mm, config.margin_mm)
    if not should_prompt:
        raise SystemExit(1)
    board_mode, custom_board = prompt_board_mode(config.paper_mm, config.margin_mm)
    return with_board_size(config, board_mode, custom_board)


def main(argv=None) -> None:
    args = parse_args(argv)
    if args.default or args.mod or args.yes:
        config = default_config()
    else:
        config = interactive_config(args)
    if args.mod:
        config = BuildConfig(
            paper_name=config.paper_name,
            paper_mm=config.paper_mm,
            dpi=600,
            crop_black_borders=True,
            card_mm=config.card_mm,
            board_mode="a5-fit",
            board_custom_mm=None,
            gamma_enabled=config.gamma_enabled,
            gamma=config.gamma,
            black_point=config.black_point,
            project_version=config.project_version,
            starter_unique_colors=config.starter_unique_colors,
            rent_raised_count=config.rent_raised_count,
            margin_mm=config.margin_mm,
            bleed_mm=config.bleed_mm,
            clean_edge_mm=config.clean_edge_mm,
            output_pdf_name=config.output_pdf_name,
            delete_temp=config.delete_temp,
            render_only=config.render_only,
        )
    config = apply_overrides(config, args)
    config = ensure_board_size_fits(config, args.board_size is not None)

    print(
        textwrap.dedent(
            f"""
            Build settings:
              paper: {config.paper_name.upper()} {config.paper_mm[0]} x {config.paper_mm[1]} mm
              dpi: {config.dpi}
              card: {config.card_mm[0]} x {config.card_mm[1]} mm
              board: {board_size_summary(config)}
              card version: {config.project_version or '<none>'}
              starter badge colors: {'different player colors' if config.starter_unique_colors else 'same color per starter deck'}
              Rent raised cards: {config.rent_raised_count}
              margin: {config.margin_mm:g} mm
              bleed: {config.bleed_mm} mm ({mm_to_px(config.bleed_mm, config.dpi)} px at {config.dpi} DPI)
              crop black borders: {'yes' if config.crop_black_borders else 'no'}
              gamma: {'off' if not config.gamma_enabled else f'{config.gamma} with black point {config.black_point}'}
              output: {'rendered PNGs only' if config.render_only else 'rendered PNGs, page PNGs, and PDF'}
              pdf name: {(config.output_pdf_name.strip() or 'blockminers_printable.pdf') if not config.render_only else '<not written>'}
              temp files: {'kept for render-only output' if config.render_only else 'ask after build' if config.delete_temp is None else 'deleted after build' if config.delete_temp else 'kept under print/temp'}
            """
        ).strip()
    )
    build_printable(config)


if __name__ == "__main__":
    main()
