# src/chitai/services/matching.py

"""
Normalisation for book-level duplicate detection.

Two copies of one book rarely agree on how it is written down. One says
`The Metamorphosis`, the other `Metamorphosis`; one credits `Kafka, Franz`, the other
`Franz Kafka`; one carries the ISBN-10 and the other the ISBN-13 of the same edition.
These functions reduce each of those to a single key, so the comparison is an equality
test the database can index rather than a similarity score nobody can explain.

Everything here is pure: the keys are computed once and stored on the row (see
`Book.normalized_title`, `Author.normalized_name`, `Identifier.normalized_value`), so
no Postgres extension is needed at query time.
"""

from __future__ import annotations

import re
import unicodedata

from chitai.services.utils import is_valid_isbn, isbn10_to_isbn13


# Asides a title carries that say nothing about which book it is:
# "Frankenstein (Illustrated)", "Dune [Deluxe]".
_BRACKETED = re.compile(r"[(\[{][^)\]}]*[)\]}]")

# Edition and format qualifiers, matched only as a *trailing* run of words. Anchoring
# to the end is what keeps "The Illustrated Man" a book and "Moby Dick Illustrated" a
# format note — a qualifier trails the title, it is never the thing the title is about.
_EDITION_NOISE = re.compile(
    r"\s+(?:"
    # "2nd edition", but also the compact forms publishers actually print on a
    # cover: "2E", "3 Ed", "5e". The number alone is never enough — "Catch 22" is
    # a title and must survive.
    r"\d+(?:st|nd|rd|th)?\s*(?:edition|edn|ed|e)"
    r"|(?:first|second|third|fourth|fifth|sixth|new|revised|expanded|updated|"
    r"annotated|illustrated|unabridged|abridged|complete|definitive|deluxe|"
    r"anniversary|collectors|international|kindle|paperback|hardcover|hardback|"
    r"ebook|audiobook)"
    r"(?:\s+(?:and|&)\s+\w+)*"
    r"(?:\s+ed(?:ition|n)?)?"
    r")$"
)

_LEADING_ARTICLE = re.compile(r"^(?:the|a|an)\s+")

# Anything that is not a letter, a digit or a space, once accents are gone.
_PUNCTUATION = re.compile(r"[^0-9a-z ]+")

_WHITESPACE = re.compile(r"\s+")

# Junk an extractor leaves on the end of a name: the `;` from a `DC:creator` list, the
# `.epub` from a filename the author's name was read out of.
_TRAILING_SEPARATORS = " ;,&/"
_FILE_EXTENSION = re.compile(
    r"\.(?:epub|pdf|mobi|azw3?|djvu|fb2|txt|cbz|cbr)$", re.IGNORECASE
)

# `J. R. R.` survives punctuation stripping as three one-letter words; `J.R.R.` as one.
# Joining any run of them makes both `jrr`.
_INITIAL_RUN = re.compile(r"\b(?:[a-z] )+[a-z]\b")

# ISBNs are the same number under several names; everything else keeps its own.
_ISBN_NAMES = {"isbn", "isbn-10", "isbn10", "isbn-13", "isbn13"}

# Generated fresh for every build of a file, so two copies of one book never share one.
# Matching on them would only re-find files the hash check already catches.
_PER_BUILD_NAMES = {"uuid", "urn:uuid"}

# Below this an identifier is not specific enough to be evidence: a Calibre `id` of
# "42" would otherwise pair two unrelated books.
_MIN_IDENTIFIER_LENGTH = 4


def _fold(text: str) -> str:
    """Casefolded, accent-free, punctuation-free, single-spaced."""
    decomposed = unicodedata.normalize("NFKD", text.casefold())
    unaccented = "".join(c for c in decomposed if not unicodedata.combining(c))

    return _WHITESPACE.sub(" ", _PUNCTUATION.sub(" ", unaccented)).strip()


def normalize_title(title: str | None) -> str:
    """
    Reduce a title to the key two copies of one book should share.

    `Book.subtitle` is already split off by `Extractor.format_book_title`, so only what
    is left in the title column is considered here.

    Args:
        title: The title as it was stored.

    Returns:
        The comparison key, or an empty string if nothing survives normalisation —
        which is the signal not to match on the title at all.
    """
    if not title:
        return ""

    folded = _fold(_BRACKETED.sub(" ", title).replace("&", " and "))

    # Repeated because qualifiers stack: "Dune Deluxe Edition Illustrated".
    while (trimmed := _EDITION_NOISE.sub("", folded)) != folded:
        folded = trimmed

    # An article says nothing, but a title that is only an article is not improved by
    # having none, and neither is one that noise removal emptied out.
    return _LEADING_ARTICLE.sub("", folded, count=1) or folded


def format_author_name(name: str | None) -> str:
    """
    Tidy an author's name into the one form the library writes them in.

    Distinct from `normalize_author`, which throws away case, accents and spacing to
    build a comparison key. This one is what a reader sees, so it keeps everything
    that belongs to the name and only removes what an extractor added: a trailing
    separator left over from a creator list, a file extension carried in from a
    filename, and the `Surname, Given` ordering that EPUBs file names under.

    Args:
        name: The name as the file or filename gave it.

    Returns:
        The name to store, or an empty string if there is nothing left of it.
    """
    if not name:
        return ""

    tidied = _FILE_EXTENSION.sub("", name.strip().strip(_TRAILING_SEPARATORS).strip())

    if tidied.count(",") == 1:
        surname, given = (part.strip() for part in tidied.split(","))

        # Only when the part before the comma is a single word. "Dave Thomas, Andy
        # Hunt" is two people in one string, and flipping it would invent a third
        # person who does not exist. Leaving an unrecognised form alone is the safe
        # failure; rewriting it wrongly is not.
        if surname and given and " " not in surname:
            tidied = f"{given} {surname}"

    return _WHITESPACE.sub(" ", tidied).strip()


def normalize_author(name: str | None) -> str:
    """
    Reduce an author's name to the key their other books should share.

    Deliberately not reduced to surname plus initial: that collides unrelated people,
    and a wrong match here is a book pointed at a stranger's shelf.

    Args:
        name: The name as it was stored, in either `Franz Kafka` or `Kafka, Franz` form.

    Returns:
        The comparison key, or an empty string if nothing survives normalisation.
    """
    if not name:
        return ""

    # `Kafka, Franz` is one name written backwards. More than one comma is a list, or a
    # suffix, and guessing at either does more harm than leaving it alone.
    if name.count(",") == 1:
        surname, forename = name.split(",")
        name = f"{forename.strip()} {surname.strip()}"

    folded = _fold(name)

    return _INITIAL_RUN.sub(lambda run: run.group().replace(" ", ""), folded)


def normalize_identifier(name: str, value: str) -> str | None:
    """
    Reduce one identifier to a `scheme:value` key, if it can carry a match at all.

    Args:
        name: What kind of identifier it is, as stored.
        value: The identifier itself.

    Returns:
        The key, or None when the identifier is no use for matching: a per-build UUID,
        something too short to be evidence, or an ISBN that fails its own checksum.
    """
    name = (name or "").strip().casefold()
    value = (value or "").strip()

    if not name or not value or name in _PER_BUILD_NAMES:
        return None

    if name in _ISBN_NAMES:
        digits = re.sub(r"[^0-9Xx]", "", value).upper()

        if not is_valid_isbn(digits):
            return None

        # One scheme for both forms: a publisher prints whichever it likes, and the
        # ISBN-10 and ISBN-13 of an edition are the same number written twice.
        isbn = digits if len(digits) == 13 else isbn10_to_isbn13(digits)
        return f"isbn:{isbn}" if isbn else None

    folded = _fold(value) or value.casefold()
    if len(folded) < _MIN_IDENTIFIER_LENGTH:
        return None

    return f"{name}:{folded}"
