"""
Indicizzazione full-text dei messaggi PEC.

Responsabilita':
  1. Scarica gli allegati da MinIO
  2. Estrae il testo in base al formato del file
  3. Aggiorna la colonna extracted_text in attachments
  4. Aggiorna la colonna search_vector in messages includendo il testo degli allegati

Formati supportati:
  - PDF       (.pdf)           tramite pypdf
  - Word      (.docx, .doc)    tramite python-docx
  - Excel     (.xlsx, .xls)    tramite openpyxl
  - PowerPoint(.pptx, .ppt)    tramite python-pptx
  - LibreOffice (.odt, .ods, .odp) tramite odfpy
  - RTF       (.rtf)           tramite striprtf
  - Testo     (.txt, .csv, .xml, .html, .htm) testo grezzo
  - Email     (.eml, .msg)     tramite stdlib email
  - Firmati   (.p7m)           unwrap CMS poi estrae in base all'estensione interna

Viene chiamato alla fine di _save_message in sync.py, in modo non bloccante:
un'eccezione qui non interrompe la sincronizzazione del messaggio.
"""

import io
import logging
import re
import uuid

from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession

logger = logging.getLogger(__name__)

# Dimensione massima del testo estratto per allegato (caratteri)
MAX_EXTRACTED_TEXT_LEN = 50_000
# Dimensione massima del testo aggregato degli allegati per il search_vector
MAX_COMBINED_TEXT_LEN = 200_000


# ─── Rilevamento tipo file ────────────────────────────────────────────────────

def _ext(filename: str | None) -> str:
    """Restituisce l'estensione del file in minuscolo, senza punto."""
    if not filename:
        return ""
    fn = filename.lower()
    # Gestione doppia estensione es. documento.pdf.p7m
    if fn.endswith(".p7m"):
        return "p7m"
    idx = fn.rfind(".")
    return fn[idx + 1:] if idx >= 0 else ""


def _is_extractable(content_type: str | None, filename: str | None) -> bool:
    """Ritorna True se il formato e' supportato dall'estrattore."""
    ct = (content_type or "").lower()
    e = _ext(filename)
    return e in _EXTRACTORS or ct in _CONTENT_TYPE_MAP


def _resolve_extractor(content_type: str | None, filename: str | None):
    """Ritorna la funzione estrattore appropriata, o None."""
    e = _ext(filename)
    if e in _EXTRACTORS:
        return _EXTRACTORS[e]
    ct = (content_type or "").lower()
    if ct in _CONTENT_TYPE_MAP:
        return _EXTRACTORS.get(_CONTENT_TYPE_MAP[ct])
    return None


# ─── Estrattori ───────────────────────────────────────────────────────────────

# Soglia minima di caratteri estratti da pypdf prima di ricorrere all'OCR.
# Un PDF di testo reale produce migliaia di caratteri; una scansione ne produce
# zero o pochissimi (artefatti). 50 char e' un valore conservativo sicuro.
_PDF_OCR_THRESHOLD = 50

# Numero massimo di pagine su cui eseguire OCR per evitare timeout su PDF lunghi.
_PDF_OCR_MAX_PAGES = 15


def _extract_pdf(content: bytes) -> str:
    """
    Estrae testo da PDF tramite pypdf.

    Se il testo estratto e' inferiore a _PDF_OCR_THRESHOLD caratteri (PDF
    image-only / scansione), attiva il fallback OCR via Tesseract.
    """
    try:
        import pypdf  # type: ignore[import]
        reader = pypdf.PdfReader(io.BytesIO(content))
        parts: list[str] = []
        for page in reader.pages:
            try:
                t = page.extract_text()
                if t:
                    parts.append(t)
            except Exception:
                continue
        text = " ".join(parts)
    except ImportError:
        logger.warning("pypdf non installato: impossibile estrarre testo da PDF")
        return ""
    except Exception as e:
        logger.debug(f"Errore estrazione PDF: {e}")
        return ""

    # Se il testo e' troppo corto, il PDF e' probabilmente una scansione
    if len(text.strip()) < _PDF_OCR_THRESHOLD:
        logger.debug(
            f"PDF con testo insufficiente ({len(text.strip())} char), "
            "tentativo OCR..."
        )
        ocr_text = _extract_pdf_ocr(content)
        if len(ocr_text.strip()) > len(text.strip()):
            return ocr_text

    return text


def _extract_pdf_ocr(content: bytes) -> str:
    """
    OCR su PDF image-only tramite pdf2image + Tesseract.

    Converte le pagine del PDF in immagini PIL a 200 DPI (buon compromesso
    qualita'/velocita' su CPU) e applica Tesseract con lingua italiana + inglese.
    Processa al massimo _PDF_OCR_MAX_PAGES pagine per evitare timeout.
    """
    try:
        from pdf2image import convert_from_bytes  # type: ignore[import]
        import pytesseract  # type: ignore[import]

        pages = convert_from_bytes(
            content,
            dpi=200,
            last_page=_PDF_OCR_MAX_PAGES,
        )
        parts: list[str] = []
        for page_img in pages:
            try:
                t = pytesseract.image_to_string(page_img, lang="ita+eng")
                if t and t.strip():
                    parts.append(t.strip())
            except Exception:
                continue
        return " ".join(parts)
    except ImportError:
        logger.warning(
            "pdf2image o pytesseract non installati: impossibile OCR PDF"
        )
        return ""
    except Exception as e:
        logger.debug(f"Errore OCR PDF: {e}")
        return ""


def _extract_image_ocr(content: bytes) -> str:
    """
    Estrae testo da un file immagine (PNG, JPEG, TIFF, BMP, ecc.) tramite OCR.

    Usa Tesseract con lingua italiana + inglese per massima copertura
    su documenti italiani.
    """
    try:
        import pytesseract  # type: ignore[import]
        from PIL import Image  # type: ignore[import]

        img = Image.open(io.BytesIO(content))
        # Converti in RGB se necessario (TIFF multi-frame, palette, ecc.)
        if img.mode not in ("RGB", "L"):
            img = img.convert("RGB")
        text = pytesseract.image_to_string(img, lang="ita+eng")
        return " ".join(text.split())
    except ImportError:
        logger.warning(
            "pytesseract o Pillow non installati: impossibile OCR immagine"
        )
        return ""
    except Exception as e:
        logger.debug(f"Errore OCR immagine: {e}")
        return ""


def _extract_docx(content: bytes) -> str:
    """Estrae testo da DOCX/DOC tramite python-docx."""
    try:
        import docx  # type: ignore[import]
        doc = docx.Document(io.BytesIO(content))
        parts = [p.text for p in doc.paragraphs if p.text and p.text.strip()]
        # Include anche le tabelle
        for table in doc.tables:
            for row in table.rows:
                for cell in row.cells:
                    if cell.text and cell.text.strip():
                        parts.append(cell.text.strip())
        return " ".join(parts)
    except ImportError:
        logger.warning("python-docx non installato: impossibile estrarre testo da DOCX")
        return ""
    except Exception as e:
        logger.debug(f"Errore estrazione DOCX: {e}")
        return ""


def _extract_xlsx(content: bytes) -> str:
    """Estrae testo da XLSX/XLS tramite openpyxl."""
    try:
        import openpyxl  # type: ignore[import]
        wb = openpyxl.load_workbook(io.BytesIO(content), read_only=True, data_only=True)
        parts: list[str] = []
        for ws in wb.worksheets:
            for row in ws.iter_rows():
                for cell in row:
                    if cell.value is not None:
                        v = str(cell.value).strip()
                        if v:
                            parts.append(v)
        return " ".join(parts)
    except ImportError:
        logger.warning("openpyxl non installato: impossibile estrarre testo da XLSX")
        return ""
    except Exception as e:
        logger.debug(f"Errore estrazione XLSX: {e}")
        return ""


def _extract_pptx(content: bytes) -> str:
    """Estrae testo da PPTX/PPT tramite python-pptx."""
    try:
        from pptx import Presentation  # type: ignore[import]
        prs = Presentation(io.BytesIO(content))
        parts: list[str] = []
        for slide in prs.slides:
            for shape in slide.shapes:
                if shape.has_text_frame:
                    for para in shape.text_frame.paragraphs:
                        t = para.text.strip()
                        if t:
                            parts.append(t)
        return " ".join(parts)
    except ImportError:
        logger.warning("python-pptx non installato: impossibile estrarre testo da PPTX")
        return ""
    except Exception as e:
        logger.debug(f"Errore estrazione PPTX: {e}")
        return ""


def _extract_odt(content: bytes) -> str:
    """Estrae testo da ODT/ODS/ODP tramite odfpy."""
    try:
        from odf import opendocument, teletype  # type: ignore[import]
        from odf.text import P  # type: ignore[import]
        doc = opendocument.load(io.BytesIO(content))
        parts: list[str] = []
        for el in doc.body.getElementsByType(P):
            t = teletype.extractText(el).strip()
            if t:
                parts.append(t)
        return " ".join(parts)
    except ImportError:
        logger.warning("odfpy non installato: impossibile estrarre testo da ODT")
        return ""
    except Exception as e:
        logger.debug(f"Errore estrazione ODT: {e}")
        return ""


def _extract_rtf(content: bytes) -> str:
    """Estrae testo da RTF tramite striprtf."""
    try:
        from striprtf.striprtf import rtf_to_text  # type: ignore[import]
        raw = content.decode("latin-1", errors="replace")
        return rtf_to_text(raw)
    except ImportError:
        logger.warning("striprtf non installato: impossibile estrarre testo da RTF")
        return ""
    except Exception as e:
        logger.debug(f"Errore estrazione RTF: {e}")
        return ""


def _extract_plain(content: bytes) -> str:
    """Estrae testo da file di testo puro (txt, csv, xml, html, ecc.)."""
    try:
        # Prova UTF-8 prima, poi latin-1 come fallback
        try:
            text = content.decode("utf-8")
        except UnicodeDecodeError:
            text = content.decode("latin-1", errors="replace")
        # Per XML/HTML: rimuove i tag
        if "<" in text and ">" in text:
            text = re.sub(r"<[^>]+>", " ", text)
            text = re.sub(r"&[a-zA-Z]+;", " ", text)
        return " ".join(text.split())
    except Exception as e:
        logger.debug(f"Errore estrazione testo plain: {e}")
        return ""


def _extract_eml(content: bytes) -> str:
    """Estrae testo da un file EML allegato."""
    try:
        import email as emaillib
        msg = emaillib.message_from_bytes(content)
        parts: list[str] = []
        subject = msg.get("Subject", "")
        if subject:
            parts.append(subject)
        sender = msg.get("From", "")
        if sender:
            parts.append(sender)
        # Estrae body
        if msg.is_multipart():
            for part in msg.walk():
                ct = part.get_content_type()
                if ct == "text/plain":
                    try:
                        payload = part.get_payload(decode=True)
                        if payload:
                            charset = part.get_content_charset() or "utf-8"
                            parts.append(payload.decode(charset, errors="replace"))
                    except Exception:
                        pass
        else:
            payload = msg.get_payload(decode=True)
            if payload:
                charset = msg.get_content_charset() or "utf-8"
                parts.append(payload.decode(charset, errors="replace"))  # type: ignore[arg-type]
        return " ".join(parts)
    except Exception as e:
        logger.debug(f"Errore estrazione EML: {e}")
        return ""


def _extract_p7m(content: bytes, original_filename: str | None = None) -> str:
    """
    Estrae testo da un documento con firma digitale CAdES (.p7m).

    Prova a fare l'unwrap del CMS envelope tramite la libreria cryptography
    (gia' presente nel worker). Se l'unwrap ha successo, determina il formato
    del documento interno dall'estensione del nome originale (es. fattura.pdf.p7m
    -> PDF) e applica l'estrattore appropriato.
    """
    inner_content: bytes | None = None

    # Metodo 1: cryptography (CMS/PKCS7)
    try:
        from cryptography.hazmat.primitives.serialization import pkcs7  # type: ignore[import]
        # load_pem_pkcs7_certificates / load_der_pkcs7_certificates non espongono il payload
        # Usiamo il modulo backend direttamente
        from cryptography.hazmat.backends import default_backend
        from cryptography.hazmat.primitives.asymmetric import padding as asym_padding
        from cryptography.x509 import load_der_x509_certificate  # noqa: F401

        # Prova parsing DER diretto della struttura CMS ContentInfo
        # La struttura ASN.1 di SignedData contiene encapContentInfo -> eContent
        from cryptography.hazmat.bindings._rust import (  # type: ignore[import]
            x509 as rust_x509,
        )
        _ = rust_x509  # solo per verificare import
    except Exception:
        pass

    # Metodo piu' semplice: parsing ASN.1 manuale per estrarre eContent
    # La struttura DER di CMS SignedData:
    # SEQUENCE {
    #   OID (signedData)
    #   [0] EXPLICIT SEQUENCE {
    #     INTEGER (version)
    #     SET (digestAlgorithms)
    #     SEQUENCE (encapContentInfo) {
    #       OID (contentType = data)
    #       [0] EXPLICIT OCTET STRING (eContent) <- questo e' il contenuto originale
    #     }
    #     ...
    #   }
    # }
    try:
        inner_content = _unwrap_p7m_asn1(content)
    except Exception as e:
        logger.debug(f"Unwrap P7M ASN1 fallito: {e}")

    if not inner_content:
        logger.debug("Impossibile estrarre contenuto dal file .p7m")
        return ""

    # Determina l'estensione interna dal nome file originale
    # es. "fattura.pdf.p7m" -> inner ext = "pdf"
    inner_ext = ""
    if original_filename:
        fn = original_filename.lower()
        if fn.endswith(".p7m"):
            fn = fn[:-4]  # rimuove .p7m
        idx = fn.rfind(".")
        if idx >= 0:
            inner_ext = fn[idx + 1:]

    extractor = _EXTRACTORS.get(inner_ext)
    if extractor:
        logger.debug(f"P7M: estrazione interna come {inner_ext!r}")
        return extractor(inner_content)

    # Fallback: prova a riconoscere il formato dall'header del contenuto
    if inner_content[:4] == b"%PDF":
        return _extract_pdf(inner_content)
    if inner_content[:2] in (b"PK",):  # ZIP-based (docx, xlsx, pptx, odt)
        # Prova nell'ordine piu' comune
        for fn in (_extract_docx, _extract_xlsx, _extract_pptx, _extract_odt):
            result = fn(inner_content)
            if result.strip():
                return result
    # Ultimo tentativo: plain text
    return _extract_plain(inner_content)


def _unwrap_p7m_asn1(data: bytes) -> bytes | None:
    """
    Parsing ASN.1 DER minimale per estrarre eContent da una struttura CMS SignedData.
    Non verifica la firma: serve solo per l'estrazione del testo.
    """
    pos = 0
    length = len(data)

    def read_tag_length(buf: bytes, offset: int) -> tuple[int, int, int]:
        """Ritorna (tag, length, new_offset)."""
        tag = buf[offset]
        offset += 1
        lb = buf[offset]
        offset += 1
        if lb & 0x80:
            num_bytes = lb & 0x7F
            ln = int.from_bytes(buf[offset:offset + num_bytes], "big")
            offset += num_bytes
        else:
            ln = lb
        return tag, ln, offset

    # outer SEQUENCE
    tag, ln, pos = read_tag_length(data, pos)
    if tag != 0x30:
        return None

    # OID (contentType = signedData)
    tag, ln, pos = read_tag_length(data, pos)
    if tag != 0x06:
        return None
    pos += ln  # skip OID value

    # [0] EXPLICIT
    tag, ln, pos = read_tag_length(data, pos)
    if tag != 0xA0:
        return None

    # SEQUENCE (SignedData)
    tag, ln, pos = read_tag_length(data, pos)
    if tag != 0x30:
        return None

    # INTEGER (version)
    tag, ln, pos = read_tag_length(data, pos)
    if tag != 0x02:
        return None
    pos += ln

    # SET (digestAlgorithms)
    tag, ln, pos = read_tag_length(data, pos)
    if tag != 0x31:
        return None
    pos += ln

    # SEQUENCE (encapContentInfo)
    tag, ln, pos = read_tag_length(data, pos)
    if tag != 0x30:
        return None

    # OID (contentType dentro encapContentInfo)
    tag, ln, pos = read_tag_length(data, pos)
    if tag != 0x06:
        return None
    pos += ln

    # [0] EXPLICIT (eContent, opzionale)
    tag, ln, pos = read_tag_length(data, pos)
    if tag != 0xA0:
        return None

    # OCTET STRING con il contenuto originale
    tag, ln, pos = read_tag_length(data, pos)
    if tag != 0x04:
        return None
    return data[pos: pos + ln]


# ─── Mapping formato -> estrattore ────────────────────────────────────────────

_EXTRACTORS: dict[str, object] = {
    # Documenti Office
    "pdf":  _extract_pdf,
    "docx": _extract_docx,
    "doc":  _extract_docx,
    "xlsx": _extract_xlsx,
    "xls":  _extract_xlsx,
    "pptx": _extract_pptx,
    "ppt":  _extract_pptx,
    # LibreOffice
    "odt":  _extract_odt,
    "ods":  _extract_odt,
    "odp":  _extract_odt,
    # Testo
    "txt":  _extract_plain,
    "csv":  _extract_plain,
    "xml":  _extract_plain,
    "html": _extract_plain,
    "htm":  _extract_plain,
    "json": _extract_plain,
    # RTF
    "rtf":  _extract_rtf,
    # Email
    "eml":  _extract_eml,
    "msg":  _extract_eml,
    # Firma digitale CAdES
    "p7m":  _extract_p7m,
    # Immagini (OCR)
    "png":  _extract_image_ocr,
    "jpg":  _extract_image_ocr,
    "jpeg": _extract_image_ocr,
    "tiff": _extract_image_ocr,
    "tif":  _extract_image_ocr,
    "bmp":  _extract_image_ocr,
    "gif":  _extract_image_ocr,
    "webp": _extract_image_ocr,
}

# Mapping content-type -> estensione normalizzata (per fallback quando il filename manca)
_CONTENT_TYPE_MAP: dict[str, str] = {
    "application/pdf": "pdf",
    "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
    "application/msword": "doc",
    "application/vnd.ms-word": "doc",
    "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx",
    "application/vnd.ms-excel": "xls",
    "application/vnd.openxmlformats-officedocument.presentationml.presentation": "pptx",
    "application/vnd.ms-powerpoint": "ppt",
    "application/vnd.oasis.opendocument.text": "odt",
    "application/vnd.oasis.opendocument.spreadsheet": "ods",
    "application/vnd.oasis.opendocument.presentation": "odp",
    "application/rtf": "rtf",
    "text/rtf": "rtf",
    "text/plain": "txt",
    "text/csv": "csv",
    "text/xml": "xml",
    "application/xml": "xml",
    "text/html": "html",
    "message/rfc822": "eml",
    "application/pkcs7-mime": "p7m",
    "application/x-pkcs7-mime": "p7m",
    # Immagini (OCR)
    "image/png":  "png",
    "image/jpeg": "jpeg",
    "image/jpg":  "jpeg",
    "image/tiff": "tiff",
    "image/bmp":  "bmp",
    "image/gif":  "gif",
    "image/webp": "webp",
}


# ─── Job principale ───────────────────────────────────────────────────────────

async def index_message(
    message_id: uuid.UUID,
    db: AsyncSession,
) -> None:
    """
    Indicizza un messaggio per la ricerca full-text.

    Non solleva eccezioni: tutti gli errori vengono loggati ma non propagati,
    per non interrompere il flusso di sincronizzazione.
    """
    try:
        await _do_index_message(message_id, db)
    except Exception as e:
        logger.error(
            f"Errore indicizzazione messaggio {message_id}: {e}",
            exc_info=True,
        )


async def _do_index_message(
    message_id: uuid.UUID,
    db: AsyncSession,
) -> None:
    """Logica interna di indicizzazione (puo' sollevare eccezioni)."""
    from app.config import get_settings
    from app.models import Attachment, Message

    settings = get_settings()

    # ── Carica il messaggio ───────────────────────────────────────────────────
    msg_result = await db.execute(
        select(Message).where(Message.id == message_id)
    )
    message = msg_result.scalar_one_or_none()
    if not message:
        logger.warning(f"index_message: messaggio {message_id} non trovato in DB")
        return

    # ── Carica gli allegati ───────────────────────────────────────────────────
    att_result = await db.execute(
        select(Attachment).where(Attachment.message_id == message_id)
    )
    attachments = list(att_result.scalars().all())

    if not attachments:
        logger.debug(f"Messaggio {message_id}: nessun allegato, skip indicizzazione allegati")
        return

    # ── Crea client MinIO ─────────────────────────────────────────────────────
    try:
        from miniopy_async import Minio  # type: ignore[import]

        minio = Minio(
            endpoint=settings.minio_endpoint,
            access_key=settings.minio_access_key,
            secret_key=settings.minio_secret_key,
            secure=settings.minio_use_ssl,
        )
    except Exception as e:
        logger.warning(f"Impossibile creare client MinIO per indicizzazione {message_id}: {e}")
        return

    bucket = settings.minio_bucket
    attachment_texts: list[str] = []
    indexed_count = 0

    for att in attachments:
        # Se gia' indicizzato, usa il testo cached
        if att.extracted_text is not None:
            attachment_texts.append(att.extracted_text)
            continue

        # Controlla se il formato e' supportato
        extractor = _resolve_extractor(att.content_type, att.filename)
        if extractor is None:
            logger.debug(
                f"Formato non supportato per indicizzazione: "
                f"{att.filename!r} ({att.content_type!r})"
            )
            continue

        # Scarica da MinIO
        try:
            response = await minio.get_object(bucket, att.storage_path)
            content = await response.content.read()
            response.close()
        except Exception as e:
            logger.warning(
                f"Impossibile scaricare allegato {att.id} "
                f"({att.filename!r}) da MinIO: {e}"
            )
            continue

        # Estrai testo - per p7m passa anche il filename originale
        try:
            e = _ext(att.filename)
            if e == "p7m":
                extracted = _extract_p7m(content, att.filename)
            else:
                extracted = extractor(content)  # type: ignore[operator]
        except Exception as ex:
            logger.debug(f"Errore estrazione {att.filename!r}: {ex}")
            continue

        if not extracted or not extracted.strip():
            logger.debug(f"Nessun testo estratto da {att.filename!r}")
            continue

        # Limita la dimensione e salva sull'ORM object (col. mappata)
        att.extracted_text = extracted[:MAX_EXTRACTED_TEXT_LEN]
        attachment_texts.append(att.extracted_text)
        indexed_count += 1
        logger.debug(
            f"Testo estratto da {att.filename!r}: "
            f"{len(att.extracted_text)} caratteri"
        )

    # ── Aggiorna search_vector includendo il testo degli allegati ─────────────
    if attachment_texts:
        combined = " ".join(attachment_texts)[:MAX_COMBINED_TEXT_LEN]

        await db.execute(
            text("""
                UPDATE messages
                SET search_vector =
                    setweight(to_tsvector('italian', coalesce(subject, '')), 'A') ||
                    setweight(to_tsvector('simple',  coalesce(from_address, '')), 'B') ||
                    setweight(to_tsvector('simple',
                        coalesce(array_to_string(to_addresses, ' '), '')), 'B') ||
                    setweight(to_tsvector('italian', coalesce(body_text, '')), 'C') ||
                    setweight(to_tsvector('italian', :att_text), 'D')
                WHERE id = :message_id
            """),
            {"att_text": combined, "message_id": str(message_id)},
        )

        await db.flush()

        logger.info(
            f"Indicizzazione completata: messaggio {message_id}, "
            f"{indexed_count} allegati indicizzati su {len(attachments)} totali"
        )
    else:
        logger.debug(
            f"Messaggio {message_id}: nessun allegato con testo estraibile"
        )
