"""Text extraction and Customs Officer classification for ASYCUDA transit declarations.

A declaration is classified on a single criterion: the "Customs Officer" field
in the "D CONTROL BY OFFICE OF DEPARTURE" block.

    a name is present   -> "Validé"       (the name is stored on the document)
    the field is empty  -> "Non validé"
    the text is unusable -> "À confirmer"  (an admin decides by hand)

The text can come from two sources, transparent to the caller:

  1. Native PDF text, read with PyMuPDF (fitz). Most declarations are generated
     digitally and carry a selectable text layer — no OCR needed.
  2. Cloud OCR, for scans that are pure images. See documents/ocr.py: the
     production host has no root access, so Tesseract cannot be installed and
     rasterised pages are sent to a hosted OCR API instead.

pdfplumber (the previous extractor) merges the "Customs Officer" column with
the neighbouring "Signature and name of declarant/representative" column onto
one line, so the declarant's name always trails the Customs Officer field
whether or not an officer signed. PyMuPDF's text layer keeps that ordering too,
so the declarant name is still stripped from the candidate before deciding.
"""
import logging
import re

from .ocr import OcrUnavailable, ocr_pdf

logger = logging.getLogger(__name__)

# Field labels that can immediately follow "Customs Officer" on the form when
# no name is filled in — used to avoid mistaking a label for a name.
_STOP_LABELS = (
    'signature', 'stamp', 'date', 'time', 'identity', 'results',
    'seals affixed', 'office of departure', 'control by',
)

# The markers that close the Customs Officer field on the form. The name, when
# present, sits between the label and whichever of these comes first.
_END_MARKERS = (
    r'stamp',
    r'd\s*control\s+by\s+office\s+of\s+departure',
    r'control\s+by\s+office\s+of\s+departure',
    r'principal\s+contact',
    r'signature\s+and\s+name',
)

# In layout-sorted text the form's columns are separated by a wide run of
# spaces, so the declarant column ("NETRACOM") trails the officer field on the
# same line. Cloud OCR marks the same column break with a tab instead. Either
# a tab or a run of 4+ spaces therefore ends the field.
_COLUMN_GAP_RE = re.compile(r'\t+|[ ]{4,}')

# Whole-block capture: everything between "Customs Officer" and the next
# marker, across line breaks. This is the primary strategy and matches the
# field even when the name sits several lines below the label (common in OCR
# output, where the form's columns are flattened).
_OFFICER_BLOCK_RE = re.compile(
    r'customs\s*officer\s*[:\-]?\s*(.*?)(?=' + '|'.join(_END_MARKERS) + r'|\Z)',
    re.IGNORECASE | re.DOTALL,
)

# Line-oriented fallback, kept for text layers where the block regex would
# swallow an entire column of unrelated text.
_OFFICER_LINE_RE = re.compile(r'customs\s*officer\s*[:\-]?\s*(.*)', re.IGNORECASE)

# The declarant/representative name, which shares a line with the officer field
# in the layout-sorted text. It appears in three places on the form; each is
# captured so the name can be subtracted from the officer candidate.
_DECLARANT_RES = (
    re.compile(r'represented\s+by\s*[:\-]?\s*(.+)', re.IGNORECASE),
    re.compile(r'\bdeclarant\b[^\n]*\n\s*(.+)', re.IGNORECASE),
    re.compile(r'signature\s+and\s+name\s+of\s+declarant[^\n]*\n\s*(.+)', re.IGNORECASE),
)

# Trailing field numbers/labels glued to a value by the layout pass, e.g.
# "NETRACOM51 Intended" -> "NETRACOM".
_TRAILING_FIELD_RE = re.compile(r'\s*\d{1,2}\s+[A-Z][a-z].*$')

# A plausible officer name: at least two letters, and not just digits or
# punctuation left over from the form's rules and boxes.
_NAME_RE = re.compile(r'[A-Za-zÀ-ÿ]{2,}')

# Noise the OCR of a form routinely leaves in the field: box-drawing leftovers,
# checkbox glyphs, isolated punctuation.
_NOISE_RE = re.compile(r'[|_\\/\[\]{}<>•·—–\-–—=*#~^"\']+')


def extract_pdf_text(path):
    """Returns (text, source) for a PDF.

    source is 'native' when the PDF carried a usable text layer, 'ocr' when the
    text came from the cloud OCR service, and 'none' when neither produced
    anything — in which case text is ''.
    """
    text = extract_native_text(path)
    if text:
        return text, 'native'

    # Image-only PDF: fall back to cloud OCR.
    try:
        text = ocr_pdf(path)
    except OcrUnavailable as exc:
        logger.warning('OCR unavailable for %s: %s', path, exc)
        return '', 'none'
    except Exception as exc:  # noqa: BLE001 - never fail an upload over OCR
        logger.exception('Unexpected OCR failure for %s: %s', path, exc)
        return '', 'none'

    return (text, 'ocr') if text else ('', 'none')


def extract_native_text(path):
    """Extracts the PDF's own text layer with PyMuPDF. '' for an image-only PDF."""
    try:
        import fitz
    except ImportError:
        logger.error('PyMuPDF (fitz) is not installed — cannot read PDFs.')
        return ''

    try:
        with fitz.open(path) as pdf:
            # sort=True reads each page in visual (top-to-bottom, left-to-right)
            # order rather than PDF drawing order. A declaration's filled-in
            # values are written to the content stream long after the blank
            # form's labels, so without sorting the officer's name lands
            # thousands of characters away from its "Customs Officer" label and
            # no regex could pair the two.
            return '\n'.join(page.get_text('text', sort=True) or '' for page in pdf).strip()
    except Exception as exc:  # noqa: BLE001 - a corrupt PDF must not 500
        logger.warning('PyMuPDF could not read %s: %s', path, exc)
        return ''


def _find_declarant_names(text):
    """Every spelling of the declarant/representative name on the form.

    These are subtracted from the Customs Officer candidate: the layout pass
    puts the "Signature and name of declarant/representative" column on the
    same line as the officer field, so the declarant's name trails it whether
    or not an officer actually signed.
    """
    names = set()
    for pattern in _DECLARANT_RES:
        for match in pattern.finditer(text):
            value = _COLUMN_GAP_RE.split(match.group(1).strip(), 1)[0]
            # Strip a field label glued on by the layout pass ("NETRACOM51 Intended").
            value = _TRAILING_FIELD_RE.sub('', value).strip(' :.,;-\t')
            if not value or not _NAME_RE.search(value):
                continue
            names.add(value)
            # "SOCIETE NETRACOM" also rules out a bare "NETRACOM", and vice versa.
            for word in value.split():
                if len(word) >= 4 and word.isupper():
                    names.add(word)
    return names


def _clean_candidate(candidate, declarant_names=()):
    """Normalises whitespace and strips form noise and the declarant name.

    Returns '' when nothing name-like survives.
    """
    if not candidate:
        return ''

    # Keep only the first column: in layout-sorted text the neighbouring
    # "Signature and name of declarant/representative" column trails on the
    # same line, separated by a wide run of spaces.
    candidate = _COLUMN_GAP_RE.split(candidate.strip(), 1)[0]

    # Collapse line breaks and runs of whitespace into single spaces.
    candidate = re.sub(r'\s+', ' ', candidate).strip(' :-\t')

    # Drop the declarant name wherever it appears (the merged column). Longest
    # first, so "SOCIETE NETRACOM" is removed before the bare "NETRACOM".
    for name in sorted(declarant_names, key=len, reverse=True):
        candidate = re.sub(re.escape(name), ' ', candidate, flags=re.IGNORECASE)

    # Remove any stop label the capture dragged in.
    for label in _STOP_LABELS:
        candidate = re.sub(rf'\b{re.escape(label)}\b', ' ', candidate, flags=re.IGNORECASE)

    candidate = _NOISE_RE.sub(' ', candidate)
    candidate = re.sub(r'\s+', ' ', candidate).strip(' :.,;-\t')

    # Needs real letters to be a name, not stray digits or dates.
    if not _NAME_RE.search(candidate):
        return ''
    if candidate.lower() in _STOP_LABELS:
        return ''

    # A runaway block capture is not a name — the field holds a person, not a
    # paragraph.
    if len(candidate) > 120:
        return ''

    return candidate


def classify_customs_officer(text):
    """Returns (status, officer_name) from the "Customs Officer" field.

    'validated' + the name when one follows the label, 'not_validated' when the
    field is empty, and 'pending_review' when there is no usable text at all.
    """
    from .models import CustomsDocument

    if not text or not text.strip():
        return CustomsDocument.Status.PENDING_REVIEW, None

    declarant_names = _find_declarant_names(text)

    # Strategy 1 — capture the whole field, up to the closing marker.
    for match in _OFFICER_BLOCK_RE.finditer(text):
        officer_name = _clean_candidate(match.group(1), declarant_names)
        if officer_name:
            return CustomsDocument.Status.VALIDATED, officer_name

    # Strategy 2 — same line as the label, else the line just below it.
    lines = text.splitlines()
    for i, line in enumerate(lines):
        match = _OFFICER_LINE_RE.search(line)
        if not match:
            continue

        officer_name = _clean_candidate(match.group(1), declarant_names)
        if not officer_name and i + 1 < len(lines):
            officer_name = _clean_candidate(lines[i + 1], declarant_names)

        if officer_name:
            return CustomsDocument.Status.VALIDATED, officer_name

    return CustomsDocument.Status.NOT_VALIDATED, None


def analyze_document(path):
    """Full pipeline for one uploaded declaration.

    Returns {'statut', 'agent_detecte', 'texte_brut', 'source'}:
      statut        — 'validated' | 'not_validated' | 'pending_review'
      agent_detecte — the Customs Officer name, or None
      texte_brut    — the raw extracted text (empty when extraction failed)
      source        — 'native' | 'ocr' | 'none'
    """
    from .models import CustomsDocument

    text, source = extract_pdf_text(path)

    if source == 'none':
        # Neither the text layer nor OCR produced anything: refuse to guess and
        # hand the document to an admin.
        return {
            'statut': CustomsDocument.Status.PENDING_REVIEW,
            'agent_detecte': None,
            'texte_brut': '',
            'source': source,
        }

    status_value, officer_name = classify_customs_officer(text)
    return {
        'statut': status_value,
        'agent_detecte': officer_name,
        'texte_brut': text,
        'source': source,
    }
