#!/usr/bin/env python3

from __future__ import annotations

import argparse
import csv
import io
import math
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable

from PIL import Image, ImageDraw, ImageFont, ImageOps
from reportlab.lib.pagesizes import letter
from reportlab.lib.utils import ImageReader
from reportlab.pdfgen import canvas
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont


PAGE_WIDTH_PT, PAGE_HEIGHT_PT = letter
INCH = 72.0

LABEL_WIDTH_IN = 3.375
LABEL_HEIGHT_IN = 2.333
LEFT_MARGIN_IN = 0.688
TOP_MARGIN_IN = 0.594
HORIZONTAL_GAP_IN = 0.375
VERTICAL_GAP_IN = 0.187

LABELS_ACROSS = 2
LABELS_DOWN = 4
LABELS_PER_SHEET = LABELS_ACROSS * LABELS_DOWN
COPIES_PER_EMPLOYEE = 2
PAIRS_PER_SHEET = LABELS_PER_SHEET // COPIES_PER_EMPLOYEE

PHOTO_EXTENSIONS = (".png", ".jpg", ".jpeg", ".webp")

BLUE = "#1b95cf"
BLACK = "#111111"
WHITE = "#ffffff"
LIGHT_GRAY = "#d8dce2"
LOGO_BLUE = "#2c8ecb"

FONT_REGULAR_PATH = "/System/Library/Fonts/Supplemental/Arial Narrow.ttf"
FONT_BOLD_PATH = "/System/Library/Fonts/Supplemental/Arial Narrow Bold.ttf"


@dataclass
class Employee:
    name: str
    title: str
    photo_path: Path


@dataclass
class SheetPlacement:
    page_index: int
    slot_index: int


def normalize_key(value: str) -> str:
    return re.sub(r"[^a-z0-9]", "", value.lower())


def title_case_name(value: str) -> str:
    words = re.split(r"\s+", value.strip())
    return " ".join(word[:1].upper() + word[1:] for word in words if word)


def clean_optional_value(value: object) -> str:
    return str(value or "").strip()


def build_display_name(base_name: str, prefix: str, suffix: str) -> str:
    parts = [part for part in [prefix.strip(), base_name.strip(), suffix.strip()] if part]
    return " ".join(parts)


def build_photo_keys(base_name: str, prefix: str) -> list[str]:
    keys: list[str] = []
    for candidate in [base_name, f"{prefix} {base_name}".strip(), f"{prefix}{base_name}".strip()]:
        normalized = normalize_key(candidate)
        if normalized and normalized not in keys:
            keys.append(normalized)
    return keys


def register_reportlab_fonts() -> None:
    if "ArialNarrow" not in pdfmetrics.getRegisteredFontNames():
        pdfmetrics.registerFont(TTFont("ArialNarrow", FONT_REGULAR_PATH))
    if "ArialNarrow-Bold" not in pdfmetrics.getRegisteredFontNames():
        pdfmetrics.registerFont(TTFont("ArialNarrow-Bold", FONT_BOLD_PATH))


def load_font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont:
    font_path = FONT_BOLD_PATH if bold else FONT_REGULAR_PATH
    return ImageFont.truetype(font_path, size=size)


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Generate print-ready Avery 8395 employee badge PDFs."
    )
    parser.add_argument("employees_csv", type=Path, help="CSV with employee names and titles")
    parser.add_argument("photos_dir", type=Path, help="Directory containing employee photos")
    parser.add_argument(
        "-o",
        "--output",
        type=Path,
        default=Path("output/badges.pdf"),
        help="Output PDF path",
    )
    parser.add_argument(
        "--brand-text",
        default="CHITTICK\nEYE CARE",
        help="Brand text rendered on the badge header",
    )
    parser.add_argument(
        "--logo-path",
        type=Path,
        help="Optional logo image to place above the brand text",
    )
    parser.add_argument(
        "--left-adjust",
        type=float,
        default=0.0,
        help="Shift all labels horizontally in inches for printer calibration",
    )
    parser.add_argument(
        "--top-adjust",
        type=float,
        default=0.0,
        help="Shift all labels vertically in inches for printer calibration",
    )
    return parser.parse_args()


def resolve_logo_path(explicit_logo_path: Path | None) -> Path | None:
    if explicit_logo_path is not None:
        return explicit_logo_path

    logo_dir = Path("logo")
    if not logo_dir.exists():
        return None

    candidates = sorted(
        path for path in logo_dir.iterdir() if path.is_file() and path.suffix.lower() in PHOTO_EXTENSIONS
    )
    return candidates[0] if candidates else None


def read_employees(csv_path: Path, photos_dir: Path) -> list[Employee]:
    if not csv_path.exists():
        raise FileNotFoundError(f"Missing CSV file: {csv_path}")
    if not photos_dir.exists():
        raise FileNotFoundError(f"Missing photos directory: {photos_dir}")

    photos = build_photo_index(photos_dir)
    employees: list[Employee] = []

    with csv_path.open(newline="", encoding="utf-8-sig") as handle:
        reader = csv.DictReader(handle)
        if not reader.fieldnames:
            raise ValueError("CSV must include a header row.")
        fields = {normalize_key(name): name for name in reader.fieldnames}

        name_field = first_present(fields, ["name", "employee", "employeename", "fullname"])
        title_field = first_present(fields, ["title", "jobtitle", "position", "role"])
        first_name_field = first_present(fields, ["firstname", "first"])
        last_name_field = first_present(fields, ["lastname", "last", "surname"])
        prefix_field = first_present(fields, ["prefix", "nameprefix", "credentialprefix"])
        suffix_field = first_present(fields, ["suffix", "namesuffix", "credentials"])

        if title_field is None:
            raise ValueError("CSV must include a title column.")
        if name_field is None and (first_name_field is None or last_name_field is None):
            raise ValueError(
                "CSV must include either a name column or both first_name and last_name columns."
            )

        for row_number, row in enumerate(reader, start=2):
            if name_field is not None:
                base_name = clean_optional_value(row.get(name_field, ""))
            else:
                first_name = clean_optional_value(row.get(first_name_field, ""))
                last_name = clean_optional_value(row.get(last_name_field, ""))
                base_name = f"{first_name} {last_name}".strip()

            prefix = clean_optional_value(row.get(prefix_field, "")) if prefix_field else ""
            suffix = clean_optional_value(row.get(suffix_field, "")) if suffix_field else ""
            title = clean_optional_value(row.get(title_field, ""))
            if not base_name or not title:
                raise ValueError(f"Row {row_number} is missing a name or title.")

            photo_keys = build_photo_keys(base_name, prefix)
            photo_path = next((photos.get(key) for key in photo_keys if photos.get(key) is not None), None)
            if photo_path is None:
                raise FileNotFoundError(
                    f"Could not find a photo for '{base_name}'. Expected a file like "
                    f"'{photo_keys[0]}.png' or '.jpg' in {photos_dir}."
                )

            display_name = build_display_name(
                title_case_name(base_name),
                title_case_name(prefix),
                suffix.upper(),
            )
            employees.append(Employee(name=display_name, title=title, photo_path=photo_path))

    if not employees:
        raise ValueError("No employees found in the CSV.")

    return employees


def build_photo_index(photos_dir: Path) -> dict[str, Path]:
    photos: dict[str, Path] = {}
    for path in photos_dir.iterdir():
        if not path.is_file() or path.suffix.lower() not in PHOTO_EXTENSIONS:
            continue
        photos[normalize_key(path.stem)] = path
    return photos


def first_present(field_map: dict[str, str], candidates: Iterable[str]) -> str | None:
    for candidate in candidates:
        field = field_map.get(candidate)
        if field is not None:
            return field
    return None


def fit_text(
    draw: ImageDraw.ImageDraw,
    text: str,
    box: tuple[int, int, int, int],
    *,
    max_size: int,
    min_size: int,
    bold: bool,
    max_lines: int = 1,
) -> tuple[ImageFont.FreeTypeFont, list[str]]:
    for size in range(max_size, min_size - 1, -1):
        font = load_font(size=size, bold=bold)
        lines = wrap_text(draw, text, font, box[2] - box[0], max_lines)
        if lines is None:
            continue

        widths = [draw.textbbox((0, 0), line, font=font)[2] for line in lines]
        line_height = draw.textbbox((0, 0), "Ag", font=font)[3]
        total_height = len(lines) * line_height + max(0, len(lines) - 1) * int(size * 0.12)
        if max(widths, default=0) <= box[2] - box[0] and total_height <= box[3] - box[1]:
            return font, lines

    font = load_font(size=min_size, bold=bold)
    lines = wrap_text(draw, text, font, box[2] - box[0], max_lines) or [text]
    return font, lines[:max_lines]


def split_name_for_badge(name: str) -> str:
    words = name.split()
    if len(words) <= 1:
        return name
    if len(words) == 2:
        return "\n".join(words)

    midpoint = math.ceil(len(words) / 2)
    first_line = " ".join(words[:midpoint])
    second_line = " ".join(words[midpoint:])
    return f"{first_line}\n{second_line}"


def fit_name_text(
    draw: ImageDraw.ImageDraw,
    text: str,
    box: tuple[int, int, int, int],
) -> tuple[ImageFont.FreeTypeFont, list[str]]:
    single_line = text.upper()
    for size in range(74, 27, -1):
        font = load_font(size=size, bold=True)
        if wrap_text(draw, single_line, font, box[2] - box[0], 1) is not None:
            return font, [single_line]

    split_font, split_lines = fit_text(
        draw,
        split_name_for_badge(single_line),
        box,
        max_size=78,
        min_size=26,
        bold=True,
        max_lines=2,
    )
    return split_font, split_lines


def split_title_for_badge(title: str) -> str:
    words = title.upper().split()
    if len(words) <= 1:
        return title.upper()
    if len(words) == 2:
        return f"{words[0]}\n{words[1]}"
    if len(words) == 3:
        return f"{' '.join(words[:2])}\n{words[2]}"

    midpoint = math.ceil(len(words) / 2)
    best_lines = (" ".join(words[:midpoint]), " ".join(words[midpoint:]))
    best_delta = abs(len(best_lines[0]) - len(best_lines[1]))

    for idx in range(1, len(words)):
        candidate = (" ".join(words[:idx]), " ".join(words[idx:]))
        if not candidate[0] or not candidate[1]:
            continue
        delta = abs(len(candidate[0]) - len(candidate[1]))
        if delta < best_delta:
            best_lines = candidate
            best_delta = delta

    return f"{best_lines[0]}\n{best_lines[1]}"


def fit_title_text(
    draw: ImageDraw.ImageDraw,
    title: str,
    box: tuple[int, int, int, int],
) -> tuple[ImageFont.FreeTypeFont, list[str]]:
    upper_title = title.upper()
    variants: list[str] = []

    forced_two_line = split_title_for_badge(title)
    if forced_two_line not in variants:
        variants.append(forced_two_line)
    if upper_title not in variants:
        variants.append(upper_title)

    best_font: ImageFont.FreeTypeFont | None = None
    best_lines: list[str] | None = None
    best_size = -1

    for variant in variants:
        font, lines = fit_text(
            draw,
            variant,
            box,
            max_size=52,
            min_size=20,
            bold=True,
            max_lines=2,
        )
        if font.size > best_size:
            best_font = font
            best_lines = lines
            best_size = font.size

    if best_font is None or best_lines is None:
        return load_font(20, bold=True), [upper_title]

    return best_font, best_lines


def wrap_text(
    draw: ImageDraw.ImageDraw,
    text: str,
    font: ImageFont.FreeTypeFont,
    max_width: int,
    max_lines: int,
) -> list[str] | None:
    words = text.split()
    if not words:
        return [""]
    if "\n" in text:
        lines = [line.strip() for line in text.splitlines() if line.strip()]
        if not lines or len(lines) > max_lines:
            return None
        for line in lines:
            width = draw.textbbox((0, 0), line, font=font)[2]
            if width > max_width:
                return None
        return lines
    if max_lines == 1:
        width = draw.textbbox((0, 0), text, font=font)[2]
        return [text] if width <= max_width else None

    lines: list[str] = []
    current = words[0]
    for word in words[1:]:
        proposal = f"{current} {word}"
        width = draw.textbbox((0, 0), proposal, font=font)[2]
        if width <= max_width:
            current = proposal
        else:
            lines.append(current)
            current = word
            if len(lines) >= max_lines:
                return None
    lines.append(current)
    return lines if len(lines) <= max_lines else None


def draw_placeholder_logo(draw: ImageDraw.ImageDraw, box: tuple[int, int, int, int]) -> None:
    x0, y0, x1, y1 = box
    cx = (x0 + x1) / 2
    cy = (y0 + y1) / 2
    width = x1 - x0
    height = y1 - y0

    outer = [cx - width * 0.42, cy - height * 0.22, cx + width * 0.42, cy + height * 0.22]
    inner = [cx - width * 0.18, cy - height * 0.12, cx + width * 0.18, cy + height * 0.12]
    pupil = [cx - width * 0.06, cy - height * 0.06, cx + width * 0.06, cy + height * 0.06]

    draw.arc(outer, start=8, end=352, fill=LOGO_BLUE, width=max(2, int(height * 0.1)))
    draw.arc(inner, start=200, end=20, fill=LOGO_BLUE, width=max(2, int(height * 0.08)))
    draw.ellipse(pupil, fill=LOGO_BLUE)


def paste_logo(image: Image.Image, logo_path: Path, box: tuple[int, int, int, int]) -> None:
    with Image.open(logo_path) as logo_src:
        logo = logo_src.convert("RGBA")
        logo = ImageOps.contain(logo, (box[2] - box[0], box[3] - box[1]))
        offset_x = box[0] + ((box[2] - box[0]) - logo.width) // 2
        offset_y = box[1] + ((box[3] - box[1]) - logo.height) // 2
        image.alpha_composite(logo, (offset_x, offset_y))


def draw_photo_frame(
    image: Image.Image,
    photo_path: Path,
    photo_box: tuple[int, int, int, int],
) -> None:
    frame_pad = 10
    matte_color = "#efe8da"
    inner_border = "#dadde3"

    draw = ImageDraw.Draw(image)
    outer_box = (
        photo_box[0] - frame_pad,
        photo_box[1] - frame_pad,
        photo_box[2] + frame_pad,
        photo_box[3] + frame_pad,
    )
    draw.rectangle(outer_box, fill=matte_color)

    with Image.open(photo_path) as src:
        photo = ImageOps.fit(
            ImageOps.exif_transpose(src).convert("RGB"),
            (photo_box[2] - photo_box[0], photo_box[3] - photo_box[1]),
            method=Image.Resampling.LANCZOS,
            centering=(0.5, 0.2),
        )
        image.paste(photo, photo_box[:2])

    draw.rectangle(photo_box, outline=inner_border, width=3)


def render_badge(employee: Employee, brand_text: str, logo_path: Path | None) -> Image.Image:
    portrait_width = 700
    portrait_height = 1012
    image = Image.new("RGBA", (portrait_width, portrait_height), WHITE)
    draw = ImageDraw.Draw(image)

    radius = 42
    black_top_height = int(portrait_height * 0.19)
    blue_footer_height = int(portrait_height * 0.18)
    name_band_height = int(portrait_height * 0.24)
    name_overlap = int(name_band_height * 0.18)
    photo_box = (
        int(portrait_width * 0.19),
        black_top_height + int(portrait_height * 0.08),
        int(portrait_width * 0.81),
        int(portrait_height * 0.64),
    )

    draw.rounded_rectangle((0, 0, portrait_width, portrait_height), radius=radius, fill=BLUE)
    draw.rectangle((0, 0, portrait_width, black_top_height), fill=BLACK)
    draw.polygon(
        [
            (0, portrait_height),
            (0, int(portrait_height * 0.56)),
            (int(portrait_width * 0.56), int(portrait_height * 0.56)),
            (portrait_width, portrait_height),
        ],
        fill="#229ad2",
    )
    draw.rectangle(
        (
            0,
            photo_box[3] - name_overlap,
            portrait_width,
            photo_box[3] - name_overlap + name_band_height,
        ),
        fill=BLACK,
    )

    draw_photo_frame(image, employee.photo_path, photo_box)

    header_logo_box = (
        int(portrait_width * 0.10),
        int(black_top_height * 0.20),
        int(portrait_width * 0.90),
        int(black_top_height * 0.74),
    )
    if logo_path:
        paste_logo(image, logo_path, header_logo_box)
    else:
        draw_placeholder_logo(draw, header_logo_box)

    brand_box = (
        int(portrait_width * 0.12),
        int(black_top_height * 0.18),
        int(portrait_width * 0.88),
        int(black_top_height * 0.88),
    )
    if not logo_path:
        brand_font, brand_lines = fit_text(
            draw,
            brand_text.upper(),
            brand_box,
            max_size=42,
            min_size=16,
            bold=False,
            max_lines=2,
        )
        draw_multiline_centered(draw, brand_lines, brand_font, brand_box, WHITE, line_spacing=0.16)

    name_box = (
        int(portrait_width * 0.12),
        photo_box[3] - name_overlap + int(name_band_height * 0.30),
        int(portrait_width * 0.88),
        photo_box[3] - name_overlap + name_band_height - int(name_band_height * 0.07),
    )
    name_font, name_lines = fit_name_text(draw, employee.name, name_box)
    draw_multiline_centered(draw, name_lines, name_font, name_box, WHITE, line_spacing=0.04)

    title_box = (
        int(portrait_width * 0.07),
        portrait_height - blue_footer_height + int(blue_footer_height * 0.03),
        int(portrait_width * 0.93),
        portrait_height - int(blue_footer_height * 0.10),
    )
    title_font, title_lines = fit_title_text(draw, employee.title, title_box)
    draw_multiline_centered(draw, title_lines, title_font, title_box, BLACK, line_spacing=0.06)

    return image.rotate(270, expand=True)


def draw_multiline_centered(
    draw: ImageDraw.ImageDraw,
    lines: list[str],
    font: ImageFont.FreeTypeFont,
    box: tuple[int, int, int, int],
    fill: str,
    *,
    line_spacing: float,
) -> None:
    x0, y0, x1, y1 = box
    line_height = draw.textbbox((0, 0), "Ag", font=font)[3]
    spacing_px = int(font.size * line_spacing)
    total_height = len(lines) * line_height + max(0, len(lines) - 1) * spacing_px
    current_y = y0 + ((y1 - y0) - total_height) / 2

    for line in lines:
        bbox = draw.textbbox((0, 0), line, font=font)
        width = bbox[2] - bbox[0]
        current_x = x0 + ((x1 - x0) - width) / 2
        draw.text((current_x, current_y), line, font=font, fill=fill)
        current_y += line_height + spacing_px


def layout_positions(left_adjust_in: float, top_adjust_in: float) -> list[tuple[float, float]]:
    label_width = LABEL_WIDTH_IN * INCH
    label_height = LABEL_HEIGHT_IN * INCH
    left_margin = (LEFT_MARGIN_IN + left_adjust_in) * INCH
    top_margin = (TOP_MARGIN_IN + top_adjust_in) * INCH
    horizontal_gap = HORIZONTAL_GAP_IN * INCH
    vertical_gap = VERTICAL_GAP_IN * INCH

    positions: list[tuple[float, float]] = []
    for row in range(LABELS_DOWN):
        for col in range(LABELS_ACROSS):
            x = left_margin + col * (label_width + horizontal_gap)
            y_top = top_margin + row * (label_height + vertical_gap)
            y = PAGE_HEIGHT_PT - y_top - label_height
            positions.append((x, y))
    return positions


def write_pdf(
    employees: list[Employee],
    output_path: Path,
    brand_text: str,
    logo_path: Path | None,
    left_adjust_in: float,
    top_adjust_in: float,
) -> None:
    register_reportlab_fonts()
    output_path.parent.mkdir(parents=True, exist_ok=True)

    pdf = canvas.Canvas(str(output_path), pagesize=letter)
    positions = layout_positions(left_adjust_in, top_adjust_in)
    label_width = LABEL_WIDTH_IN * INCH
    label_height = LABEL_HEIGHT_IN * INCH

    slot = 0
    for employee in employees:
        rendered = render_badge(employee, brand_text, logo_path).convert("RGB")
        buffer = io.BytesIO()
        rendered.save(buffer, format="PNG")
        buffer.seek(0)
        image_reader = ImageReader(buffer)

        for _ in range(COPIES_PER_EMPLOYEE):
            if slot and slot % LABELS_PER_SHEET == 0:
                pdf.showPage()
            x, y = positions[slot % LABELS_PER_SHEET]
            pdf.drawImage(image_reader, x, y, width=label_width, height=label_height, preserveAspectRatio=False)
            slot += 1

    pdf.save()


def main() -> int:
    args = parse_args()
    try:
        employees = read_employees(args.employees_csv, args.photos_dir)
        logo_path = resolve_logo_path(args.logo_path)
        if args.logo_path and not args.logo_path.exists():
            raise FileNotFoundError(f"Missing logo file: {args.logo_path}")
        write_pdf(
            employees=employees,
            output_path=args.output,
            brand_text=args.brand_text,
            logo_path=logo_path,
            left_adjust_in=args.left_adjust,
            top_adjust_in=args.top_adjust,
        )
    except Exception as exc:
        print(f"Error: {exc}", file=sys.stderr)
        return 1

    sheets = math.ceil(len(employees) / PAIRS_PER_SHEET)
    print(f"Wrote {args.output} with {len(employees)} employees across {sheets} sheet(s).")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
