__license__ = 'AGPL v3'
__copyright__ = '2026, Bilingify LLC'

"""Calibre metadata → Readest wire records, and per-book push planning.

Standard-library only (no calibre / Qt imports) so it can be unit-tested
outside calibre. The wire shape is the camelCase Book record consumed by
POST /api/sync (apps/readest-app/src/utils/transform.ts::transformBookToDB,
inverted — same as readest.koplugin's row_to_wire).
"""

import json
from datetime import datetime, timezone

try:  # inside calibre the plugin loads as a package
    from calibre_plugins.readest.api import meta_hash
except ImportError:  # plain imports for the unit tests
    from api import meta_hash

# Readest-supported formats, in upload preference order.
FORMAT_PRIORITY = ('EPUB', 'PDF', 'AZW3', 'MOBI', 'AZW', 'FB2', 'FBZ', 'CBZ', 'TXT', 'MD')

EXTS = {
    'EPUB': 'epub',
    'PDF': 'pdf',
    'MOBI': 'mobi',
    'AZW': 'azw',
    'AZW3': 'azw3',
    'CBZ': 'cbz',
    'FB2': 'fb2',
    'FBZ': 'fbz',
    'TXT': 'txt',
    'MD': 'md',
}

CLOUD_BOOKS_SUBDIR = 'Readest/Books'


def pick_format(available):
    """Best Readest-supported format among calibre's, or None."""
    upper = {f.upper() for f in available}
    for fmt in FORMAT_PRIORITY:
        if fmt in upper:
            return fmt
    return None


def book_file_name(file_hash, fmt):
    # Matches getRemoteBookFilename for S3 ({hash}/{hash}.{ext}); R2
    # deployments resolve it through the download API's hash+extension
    # fallback, same as the koplugin.
    return '%s/%s/%s.%s' % (CLOUD_BOOKS_SUBDIR, file_hash, file_hash, EXTS[fmt])


def cover_file_name(file_hash):
    return '%s/%s/cover.png' % (CLOUD_BOOKS_SUBDIR, file_hash)


def _clean(value):
    # The server strips NUL from strings (utils/sanitize.ts::sanitizeString);
    # strip locally too so pushed and pulled values compare equal.
    if isinstance(value, str):
        return value.replace('\x00', '')
    return value


def build_metadata(book):
    """BookMetadata JSON for the books row (libs/document.ts::BookMetadata).

    `book` is a plain dict extracted from calibre's Metadata object:
    title, authors, languages, publisher, pubdate, comments, tags, series,
    series_index, uuid, isbn, custom_columns.
    """
    authors = [_clean(a) for a in book.get('authors') or [] if a]
    meta = {
        'title': _clean(book.get('title')) or '',
        'author': authors[0] if len(authors) == 1 else authors,
    }
    languages = book.get('languages') or []
    if isinstance(languages, str):
        languages = [languages]
    if languages:
        meta['language'] = languages[0] if len(languages) == 1 else languages
    optional = {
        'publisher': _clean(book.get('publisher')),
        'published': _clean(book.get('pubdate')),
        'description': _clean(book.get('comments')),
        'subject': [_clean(t) for t in book.get('tags') or []] or None,
        'series': _clean(book.get('series')),
        'isbn': _clean(book.get('isbn')),
        # Partial MD5 of the RAW calibre library file. The uploaded blob has
        # metadata embedded so its hash (book_hash) shifts with every embed;
        # this stable fingerprint is how a later push detects "the file
        # itself changed" without any machine-local state.
        'calibreSourceHash': book.get('source_hash'),
    }
    if book.get('series'):
        optional['seriesIndex'] = book.get('series_index')
    if book.get('uuid'):
        optional['identifier'] = 'urn:uuid:%s' % book['uuid']
    if book.get('custom_columns'):
        optional['customColumns'] = book['custom_columns']
    for key, value in optional.items():
        if value not in (None, '', []):
            meta[key] = value
    return meta


def _author_string(book):
    return ', '.join(_clean(a) for a in book.get('authors') or [] if a)


def build_wire_book(book, file_hash, fmt, now_ms):
    """Base wire record for a calibre book (before server-row merging)."""
    identifiers = []
    if book.get('uuid'):
        identifiers.append('urn:uuid:%s' % book['uuid'])
    if book.get('isbn'):
        identifiers.append('isbn:%s' % book['isbn'])
    title = _clean(book.get('title')) or ''
    authors = [_clean(a) for a in book.get('authors') or [] if a]
    return {
        'hash': file_hash,
        'bookHash': file_hash,
        'metaHash': meta_hash(title, authors, identifiers),
        'format': fmt,
        'title': title,
        'sourceTitle': title,
        'author': _author_string(book),
        'tags': [_clean(t) for t in book.get('tags') or []],
        'metadata': build_metadata(book),
        'createdAt': now_ms,
        'updatedAt': now_ms,
    }


def iso_to_ms(iso):
    if not iso:
        return None
    text = iso.replace('Z', '+00:00')
    dt = datetime.fromisoformat(text)
    if dt.tzinfo is None:
        dt = dt.replace(tzinfo=timezone.utc)
    return int(dt.timestamp() * 1000)


def _parse_row_metadata(row):
    raw = row.get('metadata')
    if isinstance(raw, dict):
        return raw
    if isinstance(raw, str) and raw:
        try:
            return json.loads(raw)
        except ValueError:
            return None
    return None


def _row_matches_wire(row, wire):
    if (row.get('title') or '') != wire['title']:
        return False
    if (row.get('author') or '') != wire['author']:
        return False
    if (row.get('tags') or []) != (wire.get('tags') or []):
        return False
    return _parse_row_metadata(row) == wire['metadata']


def row_source_hash(row):
    """The raw-file fingerprint a server row was built from.

    v2 rows carry it as metadata.calibreSourceHash; v1 rows uploaded the raw
    file unmodified, so their book_hash IS the raw hash.
    """
    meta = _parse_row_metadata(row) or {}
    return meta.get('calibreSourceHash') or row.get('book_hash')


def metadata_uuid(meta):
    """Calibre book uuid from a metadata dict's urn:uuid identifier."""
    identifier = (meta or {}).get('identifier')
    if isinstance(identifier, str) and 'urn:uuid:' in identifier:
        return identifier.rsplit(':', 1)[-1].lower()
    return None


def _prefer_row(row, over):
    row_live, over_live = not row.get('deleted_at'), not over.get('deleted_at')
    if row_live != over_live:
        return row_live
    return (iso_to_ms(row.get('updated_at')) or 0) > (iso_to_ms(over.get('updated_at')) or 0)


def index_rows_by_uuid(rows):
    """Map calibre uuid -> best server row (live over tombstoned, then newest).

    This is the identity that survives file-content changes: the uploaded blob
    has metadata embedded, so book_hash shifts whenever the file or its
    metadata does, but the calibre uuid in metadata.identifier stays put.
    """
    best = {}
    for row in rows:
        uuid = metadata_uuid(_parse_row_metadata(row))
        if not uuid:
            continue
        current = best.get(uuid)
        if current is None or _prefer_row(row, current):
            best[uuid] = row
    return best


def pick_server_row(hash_row, uuid_row):
    """Choose the row a push should target: live hash match, then live uuid
    match, then whatever tombstone is left (for resurrection)."""
    for row in (hash_row, uuid_row):
        if row is not None and not row.get('deleted_at'):
            return row
    return hash_row if hash_row is not None else uuid_row


def plan_push(server_row, wire, local_cover_hash, source_hash):
    """Decide what to do for one book.

    Returns {'action': 'new' | 'replace' | 'update' | 'skip',
             'upload_cover': bool}.
    - new:     no server row — embed metadata, upload file, insert row
    - replace: the raw file changed (or the row has no file blob) — upload a
               fresh embedded blob under its new hash, tombstone the old row
    - update:  file unchanged, but metadata/cover/tombstone differ — row only
    - skip:    file + metadata + cover all unchanged
    """
    if server_row is None:
        return {'action': 'new', 'upload_cover': bool(local_cover_hash)}
    if not server_row.get('uploaded_at') or row_source_hash(server_row) != source_hash:
        # A replaced book gets a new hash namespace, so it needs its own cover.
        return {'action': 'replace', 'upload_cover': bool(local_cover_hash)}
    cover_changed = bool(local_cover_hash) and local_cover_hash != server_row.get('cover_hash')
    changed = (
        not _row_matches_wire(server_row, wire)
        or bool(server_row.get('deleted_at'))
        or cover_changed
    )
    return {'action': 'update' if changed else 'skip', 'upload_cover': cover_changed}


def tombstone_record(row, now_ms):
    """Wire record that soft-deletes a replaced server row."""
    return {
        'hash': row['book_hash'],
        'bookHash': row['book_hash'],
        'metaHash': row.get('meta_hash'),
        'format': row.get('format'),
        'title': row.get('title') or '',
        'author': row.get('author') or '',
        'createdAt': iso_to_ms(row.get('created_at')) or now_ms,
        'updatedAt': now_ms,
        'deletedAt': now_ms,
    }


def merge_for_push(wire, server_row, now_ms, uploaded_at_ms=None, cover_hash=None):
    """Final record for POST /sync.

    The server explicit-nulls any field absent from the wire record
    (transformBookToDB), so an update must carry over the server row's
    groupId/groupName, progress, readingStatus, uploadedAt and cover fields —
    the koplugin learned this the hard way (see syncbooks.lua:291-299).
    """
    record = dict(wire)
    record['updatedAt'] = now_ms
    record['deletedAt'] = None  # an explicit push (re)activates the book
    row = server_row or {}
    record['createdAt'] = iso_to_ms(row.get('created_at')) or wire['createdAt']
    if row.get('group_id'):
        record['groupId'] = row['group_id']
    if row.get('group_name'):
        record['groupName'] = row['group_name']
    if row.get('progress') is not None:
        record['progress'] = row['progress']
    if row.get('reading_status'):
        record['readingStatus'] = row['reading_status']
        record['readingStatusUpdatedAt'] = iso_to_ms(row.get('reading_status_updated_at'))
    record['uploadedAt'] = (
        uploaded_at_ms if uploaded_at_ms is not None else iso_to_ms(row.get('uploaded_at'))
    )
    if cover_hash:
        record['coverHash'] = cover_hash
        record['coverUpdatedAt'] = now_ms
    elif row.get('cover_hash'):
        record['coverHash'] = row['cover_hash']
        record['coverUpdatedAt'] = iso_to_ms(row.get('cover_updated_at'))
    return record
