import re
from datetime import datetime
from typing import Any


class CourierBillParser:
    """
    Deterministic Courier Bill of Entry parser.

    The parser reuses the same PDFAnalyzer output model used by the
    Shipping Bill parser: page text, words and coordinates. Extraction is
    driven by labels/sections and visual text relationships, not by the
    values of one particular PDF.
    """

    PARENT_FIELDS = [
        "doctype",
        "out_of_charge_date",
        "cour_be_importername",
        "cour_be_arrivalairport",
        "cour_be_marksnos",
        "cour_be_iec",
        "cour_be_coo",
        "cour_be_airline",
        "cour_be_mawbno",
        "cour_be_couriername",
        "cour_be_iecbranch",
        "cour_be_countryofconsignment",
        "cour_be_flightno",
        "cour_be_mawb_dt",
        "cour_be_betype_description",
        "cour_be_importer_address",
        "cour_be_pkgs",
        "cour_be_arrivaldate",
        "cour_be_hawbno",
        "cour_be_beno",
        "cour_be_adcode",
        "cour_be_invoicecount",
        "cour_be_igmno",
        "cour_be_hawb_dt",
        "be_date",
        "cour_be_gstin",
        "cour_be_category",
        "cour_be_inward_date",
        "cour_be_gwt_kgs",
        "cour_be_total_duty",
        "cour_be_interest_amt",
        "cour_be_tr6_no",
        "cour_be_pymt_amt",
        "cour_be_challan_date",
        "cour_be_supplier_name",
    ]

    INVOICE_FIELDS = [
        "doctype",
        "cour_be_invsno",
        "cour_be_inv_nature_of_txn",
        "cour_be_incoterm",
        "cour_be_inv_supplier_name",
        "cour_be_invoice_no",
        "cour_be_inv_pymt_terms",
        "cour_be_inv_related",
        "suppliers_address",
        "cour_be_invoice_date",
        "cour_be_valuationmethod",
        "cour_svb_refno",
        "cour_be_inv_value",
        "cour_be_inv_svbdate",
        "cour_be_inv_currency",
        "cour_be_prov_final",
    ]

    ITEM_FIELDS = [
        "doctype",
        "linked_invoice_id_sno",
        "cour_be_item_upi",
        "cour_be_item_manufacturer",
        "cour_be_item_upi_currency",
        "cour_be_item_countryoforigin",
        "cour_be_item_itemsn",
        "cour_be_item_qty",
        "cour_be_item_ritc",
        "cour_be_item_itemdescription",
        "cour_be_item_uom",
        "cour_be_item_ctsh",
        "cour_be_item_itemgendescription",
        "cour_be_item_exchg_rate",
        "cour_be_item_cetsh",
        "cour_be_item_sws_rt",
        "cour_be_item_assessable_value",
        "cour_be_item_sws_amt",
        "cour_be_item_bcd_rt",
        "cour_be_item_igst_rt",
        "cour_be_item_bcd_amt",
        "cour_be_item_igst_amt",
        "cour_be_item_aidc_rt",
        "cour_be_item_aidc_amt",
        "cour_be_item_cmpcess_rt",
        "cour_be_item_cmpcess_amt",
    ]


    # =========================================================
    # DYNAMIC SCHEMA / ALIAS CONFIGURATION
    # =========================================================
    #
    # These definitions deliberately use the existing Courier Frappe
    # fieldnames as the source of truth. PDF labels are aliases only.
    # Unknown PDF headers are never emitted as new JSON keys.
    #

    COMPLETE_CHILD_FIELDS = {
        "invoices": list(INVOICE_FIELDS),
        "items": list(ITEM_FIELDS),
    }

    CHILD_FIELD_ALIASES = {
        "invoices": {
            "cour_be_invsno": [
                "INV SNO", "INVOICE SNO", "INVOICE SERIAL NO",
                "INVOICE SERIAL NUMBER", "SR NO", "SERIAL NO",
            ],
            "cour_be_inv_nature_of_txn": [
                "NATURE OF TRANSACTION", "NATURE OF TXN",
                "TRANSACTION NATURE", "NATURE",
            ],
            "cour_be_incoterm": [
                "TERMS OF INVOICE", "INCOTERM", "INCOTERMS",
                "TERMS OF DELIVERY",
            ],
            "cour_be_inv_supplier_name": [
                "SUPPLIER NAME", "NAME OF SUPPLIER",
                "EXPORTER NAME", "SELLER NAME",
            ],
            "cour_be_invoice_no": [
                "INVOICE NUMBER", "INVOICE NO", "INV NUMBER", "INV NO",
                "INVOICE #", "INV #",
            ],
            "cour_be_inv_pymt_terms": [
                "TERMS OF PAYMENT", "PAYMENT TERMS", "PAYMENT TERM",
                "PAYMENT",
            ],
            "cour_be_inv_related": [
                "RELATED", "RELATED INVOICE", "RELATED PARTY",
                "RELATED TO",
            ],
            "suppliers_address": [
                "SUPPLIER ADDRESS", "ADDRESS OF SUPPLIER",
                "EXPORTER ADDRESS", "SELLER ADDRESS",
            ],
            "cour_be_invoice_date": [
                "DATE OF INVOICE", "INVOICE DATE", "INV DATE",
            ],
            "cour_be_valuationmethod": [
                "METHOD OF VALUATION", "VALUATION METHOD",
                "METHOD OF VALUATION",
            ],
            "cour_svb_refno": [
                "SVB REF NO", "SVB REFERENCE NO", "SVB REFERENCE NUMBER",
            ],
            "cour_be_inv_value": [
                "INVOICE VALUE", "INV VALUE", "VALUE OF INVOICE",
            ],
            "cour_be_inv_svbdate": [
                "SVB DATE", "DATE OF SVB",
            ],
            "cour_be_inv_currency": [
                "CURRENCY", "INVOICE CURRENCY", "CURRENCY OF INVOICE",
            ],
            "cour_be_prov_final": [
                "PROVISIONAL FINAL", "PROVISIONAL", "FINAL",
            ],
        },
        "items": {
            "linked_invoice_id_sno": [
                "LINKED INVOICE ID", "INVOICE ID", "INVOICE SNO",
            ],
            "cour_be_item_upi": [
                "UNIT PRICE", "UNIT PRICE INVOICE", "PRICE",
                "UNIT PRICE (UPI)", "UPI",
            ],
            "cour_be_item_manufacturer": [
                "MANUFACTURER", "MANUFACTURER NAME", "SUPPLIER",
                "SUPPLIER NAME",
            ],
            "cour_be_item_upi_currency": [
                "CURRENCY FOR UNIT PRICE", "UNIT PRICE CURRENCY",
                "UPI CURRENCY", "PRICE CURRENCY",
            ],
            "cour_be_item_countryoforigin": [
                "COUNTRY OF ORIGIN", "ORIGIN COUNTRY",
            ],
            "cour_be_item_itemsn": [
                "ITEM SNO", "ITEM SR NO", "ITEM SERIAL NO",
                "ITEM NUMBER", "ITEM NO", "ITEM",
            ],
            "cour_be_item_qty": [
                "QUANTITY", "QTY", "ITEM QUANTITY",
            ],
            "cour_be_item_ritc": [
                "RITC", "RITC CODE", "TARIFF ITEM", "TARIFF CODE",
            ],
            "cour_be_item_itemdescription": [
                "ITEM DESCRIPTION", "DESCRIPTION OF ITEM",
                "DESCRIPTION",
            ],
            "cour_be_item_uom": [
                "UNIT OF MEASURE", "UOM", "UNIT",
            ],
            "cour_be_item_ctsh": [
                "CTSH", "CTSH CODE",
            ],
            "cour_be_item_itemgendescription": [
                "ITEM GENERAL DESCRIPTION", "GENERAL DESCRIPTION",
                "ITEM GEN DESCRIPTION",
            ],
            "cour_be_item_exchg_rate": [
                "RATE OF EXCHANGE", "EXCHANGE RATE", "EXCH RATE",
            ],
            "cour_be_item_cetsh": [
                "CETSH", "CETSH CODE",
            ],
            "cour_be_item_sws_rt": [
                "SWS RATE", "SW SRCHRG RATE", "SWS RT",
                "SOCIAL WELFARE SURCHARGE RATE",
            ],
            "cour_be_item_assessable_value": [
                "ASSESSABLE VALUE", "ASSESS. VALUE",
                "ASSESSABLE VAL", "ASSESS VALUE",
            ],
            "cour_be_item_sws_amt": [
                "SWS AMOUNT", "SW SRCHRG AMOUNT", "SWS AMT",
            ],
            "cour_be_item_bcd_rt": [
                "BCD RATE", "BCD RT", "BASIC CUSTOMS DUTY RATE",
            ],
            "cour_be_item_igst_rt": [
                "IGST RATE", "IGST RT",
            ],
            "cour_be_item_bcd_amt": [
                "BCD AMOUNT", "BCD AMT", "BASIC CUSTOMS DUTY AMOUNT",
            ],
            "cour_be_item_igst_amt": [
                "IGST AMOUNT", "IGST AMT",
            ],
            "cour_be_item_aidc_rt": [
                "AIDC RATE", "AIDC RT",
            ],
            "cour_be_item_aidc_amt": [
                "AIDC AMOUNT", "AIDC AMT",
            ],
            "cour_be_item_cmpcess_rt": [
                "CMPNSTRY RATE", "CMP CESS RATE", "COMPENSATION CESS RATE",
                "CMPCESS RATE",
            ],
            "cour_be_item_cmpcess_amt": [
                "CMPNSTRY AMOUNT", "CMP CESS AMOUNT",
                "COMPENSATION CESS AMOUNT", "CMPCESS AMOUNT",
            ],
        },
    }

    PARENT_FIELD_ALIASES = {
        "out_of_charge_date": [
            "OOC ISSUED ON", "OUT OF CHARGE DATE", "OOC DATE",
        ],
        "cour_be_importername": [
            "IMPORTER NAME", "PARTICULARS OF THE IMPORTER", "NAME",
        ],
        "cour_be_arrivalairport": [
            "ARRIVAL AIRPORT", "AIRPORT OF ARRIVAL",
        ],
        "cour_be_marksnos": [
            "MARKS AND NUMBERS", "MARKS & NUMBERS", "MARKS NOS",
        ],
        "cour_be_iec": [
            "IMPORT EXPORT CODE", "IMPORT-EXPORT CODE", "IEC", "IEC CODE",
        ],
        "cour_be_coo": [
            "COUNTRY OF ORIGIN", "ORIGIN COUNTRY",
        ],
        "cour_be_airline": [
            "AIRLINE", "CARRIER", "AIR CARRIER",
        ],
        "cour_be_mawbno": [
            "MASTER AIRWAY BILL (MAWB) NUMBER", "MAWB NUMBER",
            "MAWB NO", "MASTER AIRWAY BILL NUMBER",
        ],
        "cour_be_couriername": [
            "NAME OF THE AUTHORIZED COURIER",
            "AUTHORIZED COURIER", "AUTHORISED COURIER",
            "COURIER NAME",
        ],
        "cour_be_iecbranch": [
            "IMPORT EXPORT BRANCH CODE", "IEC BRANCH CODE",
            "IMPORT EXPORT BRANCH",
        ],
        "cour_be_countryofconsignment": [
            "COUNTRY OF CONSIGNMENT", "CONSIGNMENT COUNTRY",
        ],
        "cour_be_flightno": [
            "FLIGHT NO", "FLIGHT NUMBER", "FLIGHT",
        ],
        "cour_be_mawb_dt": [
            "DATE OF MAWB", "MAWB DATE", "MAWB DT",
        ],
        "cour_be_betype_description": [
            "TYPE OF BOE", "BOE TYPE", "TYPE OF BILL OF ENTRY",
        ],
        "cour_be_importer_address": [
            "IMPORTER ADDRESS", "ADDRESS OF IMPORTER",
        ],
        "cour_be_pkgs": [
            "NUMBER OF PACKAGES", "NO OF PACKAGES", "PACKAGES",
        ],
        "cour_be_arrivaldate": [
            "ARRIVAL DATE", "DATE OF ARRIVAL",
        ],
        "cour_be_hawbno": [
            "HOUSE AIRWAY BILL (HAWB) NUMBER", "HAWB NUMBER",
            "HAWB NO", "HOUSE AIRWAY BILL NUMBER",
        ],
        "cour_be_beno": [
            "CBEXIV NUMBER", "BOE NUMBER", "BILL OF ENTRY NUMBER",
            "CB NO", "BOE NO",
        ],
        "cour_be_adcode": [
            "AUTHORISED DEALER CODE OF BANK", "AUTHORIZED DEALER CODE",
            "AD CODE", "AUTHORISED DEALER CODE",
        ],
        "cour_be_invoicecount": [
            "NUMBER OF INVOICES", "NO OF INVOICES", "INVOICE COUNT",
        ],
        "cour_be_igmno": [
            "IMPORT GENERAL MANIFEST (IGM) NUMBER", "IGM NUMBER", "IGM NO",
        ],
        "cour_be_hawb_dt": [
            "DATE OF HAWB", "HAWB DATE", "HAWB DT",
        ],
        "be_date": [
            "BOE DATE", "BILL OF ENTRY DATE",
        ],
        "cour_be_gstin": [
            "KYC ID", "GSTIN", "GSTIN NUMBER",
        ],
        "cour_be_category": [
            "CATEGORY OF BOE", "BOE CATEGORY", "CATEGORY",
        ],
        "cour_be_inward_date": [
            "DATE OF ENTRY INWARD", "INWARD DATE", "ENTRY INWARD DATE",
        ],
        "cour_be_gwt_kgs": [
            "GROSS WEIGHT", "GROSS WEIGHT KGS", "GWT", "G WT",
        ],
        "cour_be_total_duty": [
            "TOTAL AMOUNT", "TOTAL DUTY", "TOTAL DUTY AMOUNT",
        ],
        "cour_be_interest_amt": [
            "INTEREST AMOUNT", "INTEREST AMT", "INTEREST",
        ],
        "cour_be_tr6_no": [
            "CHALLAN NUMBER", "TR6 NUMBER", "TR6 NO", "CHALLAN NO",
        ],
        "cour_be_pymt_amt": [
            "PAYMENT AMOUNT", "PAYMENT AMT", "AMOUNT PAID",
        ],
        "cour_be_challan_date": [
            "CHALLAN DATE", "DATE OF CHALLAN", "PAYMENT DATE",
        ],
        "cour_be_supplier_name": [
            "SUPPLIER NAME", "NAME OF SUPPLIER", "EXPORTER NAME",
        ],
    }

    # Fields that are known to be numeric floats in the Courier Frappe schema.
    CHILD_FLOAT_FIELDS = {
        "cour_be_item_upi",
        "cour_be_item_qty",
        "cour_be_item_exchg_rate",
        "cour_be_item_sws_rt",
        "cour_be_item_assessable_value",
        "cour_be_item_sws_amt",
        "cour_be_item_bcd_rt",
        "cour_be_item_igst_rt",
        "cour_be_item_bcd_amt",
        "cour_be_item_igst_amt",
        "cour_be_item_aidc_rt",
        "cour_be_item_aidc_amt",
        "cour_be_item_cmpcess_rt",
        "cour_be_item_cmpcess_amt",
        "cour_be_inv_value",
    }

    PARENT_FLOAT_FIELDS = {
        "cour_be_gwt_kgs",
        "cour_be_total_duty",
        "cour_be_interest_amt",
        "cour_be_pymt_amt",
    }

    PARENT_INTEGER_FIELDS = {
        "cour_be_pkgs",
        "cour_be_invoicecount",
    }

    # Internal table classifications. These are parser-level names only;
    # they never become JSON keys. They let fragmented/moved pdfplumber
    # tables be identified from their headers rather than page coordinates.
    TABLE_ALIASES = {
        "invoices": [
            "INVOICE DETAILS", "INVOICE NUMBER", "INVOICE NO",
            "DATE OF INVOICE", "INVOICE VALUE", "CURRENCY",
            "SUPPLIER DETAILS", "NATURE OF TRANSACTION",
        ],
        "items": [
            "ITEM DETAILS", "ITEM DESCRIPTION", "ITEM DESCRIPTION/ GENERIC DESCRIPTION", "QUANTITY", "QTY",
            "RITC", "CTSH", "CETSH", "COUNTRY OF ORIGIN",
            "UNIT PRICE", "UPI", "UOM",
        ],
        "duty": [
            "DUTY DETAILS", "DUTY HEAD", "BCD", "AIDC",
            "SW SRCHRG", "IGST", "CMPNSTRY", "RATE",
        ],
    }

    TABLE_DOCTYPES = {
        "invoices": "Courier Bill of Entry Invoices",
        "items": "Courier Bill of Entry Items",
    }

    def __init__(self, pages: list[dict[str, Any]]):
        self.pages = pages or []
        self.trace: list[dict[str, Any]] = []
        self.page_text = {
            int(p.get("page_number", i + 1)): str(p.get("text") or "")
            for i, p in enumerate(self.pages)
        }
        self.full_text = "\n".join(self.page_text.values())
        self.words = self._all_words()
        self.page_words: dict[int, list[dict[str, Any]]] = {}
        for word in self.words:
            self.page_words.setdefault(word["_page"], []).append(word)
        for page in self.page_words:
            self.page_words[page].sort(
                key=lambda w: (float(w.get("y0", 0)), float(w.get("x0", 0)))
            )
        # Build the complete visual row/table model once.
        # This is the same foundation used by ShippingBillParser and makes
        # recovery independent of a fixed page number or coordinate range.
        self.rows = self._group_words_into_rows(self.words)
        self.tables = self._all_tables()

    # =========================================================
    # ENTRY POINT
    # =========================================================

    def parse(self) -> dict[str, Any]:
        result = self._empty_result()

        self._extract_parent_fields(result)
        invoice = self._extract_invoice()
        item = self._extract_item()

        result["invoices"] = [invoice] if invoice else []
        result["items"] = [item] if item else []

        self._extract_payment(result)
        self._extract_out_of_charge(result)

        # Generic table recovery is fill-only. The established Courier
        # extraction remains authoritative; detected tables only supply
        # fields that are missing or fragmented in the primary result.
        self._recover_from_generic_tables(result)

        # Collapse wrapped/duplicate item fragments before semantic child
        # recovery so later passes operate on stable logical rows.
        result["items"] = self._collapse_courier_item_continuations(
            result.get("items", [])
        )

        # Dynamic recovery is intentionally fill-only. It never overwrites
        # values already obtained by the validated Courier extraction logic.
        self._dynamic_parent_schema_recovery(result)
        self._dynamic_child_schema_recovery(result)

        self._finalize(result)

        return {
            "courier_bill_json": result,
            "_trace": self.trace,
        }

    # =========================================================
    # OUTPUT SHAPE
    # =========================================================

    def _empty_result(self):
        result = {field: "" for field in self.PARENT_FIELDS}
        result["doctype"] = "Courier Bill of Entry"
        result["cour_be_pkgs"] = 0
        result["cour_be_invoicecount"] = 0
        result["cour_be_gwt_kgs"] = 0.0
        result["cour_be_total_duty"] = 0.0
        result["cour_be_interest_amt"] = 0.0
        result["cour_be_pymt_amt"] = 0.0
        result["invoices"] = []
        result["items"] = []
        return result

    # =========================================================
    # PARENT FIELDS
    # =========================================================

    def _extract_parent_fields(self, result):
        p1 = self.page_text.get(1, "")
        p2 = self.page_text.get(2, "")
        p3 = self.page_text.get(3, "")
        p4 = self.page_text.get(4, "")
        p5 = self.page_text.get(5, "")
        p6 = self.page_text.get(6, "")

        # The standard Courier form is a two-column visual layout. Prefer
        # coordinate-based extraction for values that sit beside another
        # label on the same row. This avoids column leakage.
        self._set(result, "cour_be_couriername", self._visual_right(p1, 1, 198, 313, 60, 420, 520))
        self._set(result, "cour_be_iec", self._visual_right(p1, 1, 344, 73, 95, 165, 310))
        self._set(result, "cour_be_iecbranch", self._visual_right(p1, 1, 344, 322, 414, 415, 470))
        self._set(result, "cour_be_importername", self._visual_span(p1, 1, 366, 165, 365, 3))
        self._set(result, "cour_be_importer_address", self._visual_span(p1, 1, 366, 415, 570, 5))
        self._set(result, "cour_be_adcode", self._visual_right(p1, 1, 447, 49, 160, 165, 310))

        self._set(result, "cour_be_beno", self._after_label(p1, r"CBEXIV\s+Number\s*:", 180))
        if not result["cour_be_beno"]:
            self._set(result, "cour_be_beno", self._after_label(p1, r"BOE\s+Number\s*:", 180))

        self._set(result, "be_date", self._after_label(p1, r"BOE\s+Date\s*:", 30))
        self._set(result, "cour_be_category", self._after_label(p1, r"Category\s+Of\s+BOE\s*:", 60))
        self._set(result, "cour_be_betype_description", self._after_label(p1, r"Type\s+Of\s+BOE\s*:", 50))
        self._set(result, "cour_be_iec", self._after_label(p1, r"Import\s+export\s+Code\s*:", 30))
        self._set(result, "cour_be_iecbranch", self._after_label(p1, r"Import\s+Export\s+Branch\s+Code\s*:", 30))
        self._set(result, "cour_be_importername", self._between(p1, r"Name\s*:\s*", r"Address\s*:", section=r"PARTICULARS OF THE IMPORTER"))
        self._set(result, "cour_be_gstin", self._after_label(p1, r"KYC\s+ID\s*:", 30))
        self._set(result, "cour_be_adcode", self._after_label(p1, r"Authorised\s+Dealer\s+Code\s+Of\s+Bank\s*:", 30))
        self._set(result, "cour_be_couriername", self._after_label(p1, r"Name\s+of\s+the\s+Authorized\s+Courier\s*:", 80))
        self._set(result, "cour_be_supplier_name", self._clean_supplier_name(
            self._visual_span(p3, 3, 96, 165, 365, 5)
        ))

        # Importer address: bounded between importer address and the next
        # explicit importer field, preserving line breaks.
        addr = self._block_after_label(
            p1,
            r"Name\s*:\s*[^\n]+\s+Address\s*:",
            stop_patterns=[
                r"Category\s+Of\s+Importer",
                r"Authorised\s+Dealer",
                r"BOE\s+Number",
            ],
            max_lines=6,
        )
        if not result["cour_be_importer_address"]:
            self._set(result, "cour_be_importer_address", addr)

        # Page 2 logistics.
        country_line = self._line_containing(p2, "Country of Origin")
        if country_line:
            m = re.search(
                r"Country\s+of\s+Origin\s*:\s*(.*?)\s+Country\s+of\s+Consignment\s*:\s*(.*)$",
                country_line,
                re.I,
            )
            if m:
                self._set(result, "cour_be_coo", m.group(1))
                self._set(result, "cour_be_countryofconsignment", m.group(2))

        airline_line = self._line_containing(p2, "Federal Express") or self._line_containing(p2, "Airlines")
        if airline_line:
            lines = self._clean_lines(p2)
            try:
                idx = next(i for i, x in enumerate(lines) if "Federal Express" in x or re.fullmatch(r"Airlines\s+Flight No.*", x, re.I))
                candidate = lines[idx + 1] if "Airlines" in lines[idx] else lines[idx]
            except StopIteration:
                candidate = ""
            if candidate:
                m = re.search(r"^(.*?)\s+(FX\s*\d+)\s+(\w+)\s+(\d{1,2}/\d{1,2}/\d{4})$", candidate, re.I)
                if m:
                    self._set(result, "cour_be_airline", m.group(1))
                    self._set(result, "cour_be_flightno", m.group(2))
                    self._set(result, "cour_be_arrivalairport", m.group(3))
                    self._set(result, "cour_be_arrivaldate", m.group(4))

        self._set(result, "cour_be_igmno", self._after_label(p2, r"Import\s+General\s+Manifest\s+\(IGM\)\s+Number\s*:", 30))
        self._set(result, "cour_be_inward_date", self._after_label(p2, r"Date\s+of\s+Entry\s+Inward\s*:", 30))
        self._set(result, "cour_be_mawbno", self._after_label(p2, r"Master\s+Airway\s+Bill\s+\(MAWB\)\s+Number\s*:", 40))
        self._set(result, "cour_be_mawb_dt", self._after_label(p2, r"Date\s+Of\s+MAWB\s*:", 30))
        self._set(result, "cour_be_hawbno", self._after_label(p2, r"House\s+Airway\s+Bill\s+\(HAWB\)\s+Number\s*:", 40))
        self._set(result, "cour_be_hawb_dt", self._after_label(p2, r"Date\s+of\s+HAWB\s*:", 30))
        self._set(result, "cour_be_marksnos", self._after_label(p2, r"Marks\s+and\s+Numbers\s*:", 60))
        self._set(result, "cour_be_pkgs", self._after_label(p2, r"Number\s+of\s+Packages\s*:", 20))
        self._set(result, "cour_be_gwt_kgs", self._after_label(p2, r"Gross\s+Weight\s*:", 20))
        self._set(result, "cour_be_interest_amt", self._after_label(p2, r"Interest\s+Amount\s*:", 20))
        self._set(result, "cour_be_invoicecount", self._after_label(p2, r"Number\s+of\s+Invoices\s*:", 20))

        # Precise page-2 visual values.
        self._set(result, "cour_be_airline", self._visual_span(p2, 2, 146, 65, 200, 1))
        self._set(result, "cour_be_flightno", self._visual_span(p2, 2, 146, 205, 310, 1))
        self._set(result, "cour_be_arrivalairport", self._visual_span(p2, 2, 146, 335, 410, 1))
        self._set(result, "cour_be_arrivaldate", self._visual_span(p2, 2, 146, 450, 550, 1))
        self._set(result, "cour_be_igmno", self._visual_span(p2, 2, 175, 165, 250, 1))
        self._set(result, "cour_be_inward_date", self._visual_span(p2, 2, 175, 415, 520, 1))
        self._set(result, "cour_be_mawbno", self._visual_span(p2, 2, 197, 165, 300, 1))
        self._set(result, "cour_be_mawb_dt", self._visual_span(p2, 2, 197, 415, 520, 1))
        self._set(result, "cour_be_hawbno", self._visual_span(p2, 2, 219, 165, 300, 1))
        self._set(result, "cour_be_hawb_dt", self._visual_span(p2, 2, 219, 415, 520, 1))
        self._set(result, "cour_be_marksnos", self._visual_span(p2, 2, 241, 165, 300, 1))
        self._set(result, "cour_be_pkgs", self._visual_span(p2, 2, 241, 415, 470, 1))
        self._set(result, "cour_be_interest_amt", self._visual_span(p2, 2, 252, 415, 470, 1))
        self._set(result, "cour_be_gwt_kgs", self._visual_span(p2, 2, 264, 415, 470, 1))

        # Final visual repairs for dense two-column rows. These are applied
        # after generic label fallbacks so a neighbouring column can never
        # overwrite a correctly located value.
        self._set(result, "cour_be_couriername", self._visual_span(p1, 1, 198, 415, 520, 4))
        self._set(result, "cour_be_iec", self._visual_span(p1, 1, 344, 165, 315, 4))
        self._set(result, "cour_be_iecbranch", self._visual_span(p1, 1, 344, 415, 470, 4))
        self._set(result, "cour_be_importername", self._visual_block(p1, 1, 366, 410, 165, 365))
        self._set(result, "cour_be_importer_address", self._visual_block(p1, 1, 366, 410, 415, 570))
        self._set(result, "cour_be_adcode", self._visual_span(p1, 1, 447, 165, 315, 4))
        self._set(result, "cour_be_category", self._visual_span(p1, 1, 501, 165, 315, 4))
        self._set(result, "cour_be_betype_description", self._visual_span(p1, 1, 501, 415, 500, 4))
        self._set(result, "cour_be_supplier_name", self._clean_supplier_name(
            self._visual_block(p3, 3, 96, 110, 165, 365)
        ))

        # Page 6 payment/assessment.
        self._set(result, "cour_be_total_duty", self._after_label(p6, r"Total\s+Amount\s+", 30))
        self._set(result, "cour_be_tr6_no", self._payment_cell(p6, 2))
        self._set(result, "cour_be_pymt_amt", self._payment_cell(p6, 3))
        self._set(result, "cour_be_challan_date", self._payment_cell(p6, 4))

        # If the payment row is present, total duty is the total amount.
        if not result["cour_be_total_duty"]:
            result["cour_be_total_duty"] = result["cour_be_pymt_amt"]

    # =========================================================
    # INVOICE
    # =========================================================

    def _extract_invoice(self):
        p2 = self.page_text.get(2, "")
        p3 = self.page_text.get(3, "")
        p4 = self.page_text.get(4, "")

        invoice = {field: "" for field in self.INVOICE_FIELDS}
        invoice["doctype"] = "Courier Bill of Entry Invoices"
        invoice["cour_be_invsno"] = 1

        inv_line = self._line_containing(p2, "Invoice Number")
        if inv_line:
            m = re.search(
                r"Invoice\s+Number\s*:\s*(.*?)\s+Date\s+of\s+Invoice\s*:\s*(.*)$",
                inv_line,
                re.I,
            )
            if m:
                invoice["cour_be_invoice_no"] = self._clean(m.group(1))
                invoice["cour_be_invoice_date"] = self._format_date(m.group(2))

        supplier = self._after_label(p3, r"SUPPLIER\s+DETAILS\s*\n\s*Name\s*:", 120)
        invoice["cour_be_inv_supplier_name"] = self._clean_supplier_name(
            self._visual_span(p3, 3, 96, 165, 365, 5)
        )

        nature = self._visual_span(p3, 3, 229, 160, 310, 1)
        payment = self._visual_span(p3, 3, 240, 160, 300, 1)
        valuation = self._visual_span(p3, 3, 285, 165, 250, 1)
        incoterm = self._visual_span(p3, 3, 285, 415, 470, 1)
        invoice["cour_be_inv_nature_of_txn"] = nature
        invoice["cour_be_inv_pymt_terms"] = payment
        invoice["cour_be_valuationmethod"] = valuation
        invoice["cour_be_incoterm"] = incoterm

        # Keep the generic fallbacks only if the visual extraction is empty.
        if not nature:
            nature = self._after_label(p3, r"Nature\s+of\s+Transaction\s*:", 30)
        if not payment:
            payment = self._after_label(p3, r"Terms\s+of\s+Payment\s*:", 30)
        if not valuation:
            valuation = self._after_label(p3, r"Method\s+of\s+Valuation\s*:", 30)
        if not incoterm:
            incoterm = self._after_label(p3, r"Terms\s+of\s+Invoice\s*:", 30)

        invoice["cour_be_inv_nature_of_txn"] = self._clean(nature)
        invoice["cour_be_inv_pymt_terms"] = self._clean(payment)
        invoice["cour_be_valuationmethod"] = self._clean(valuation)
        invoice["cour_be_incoterm"] = self._clean(incoterm)

        nature = invoice["cour_be_inv_nature_of_txn"]

        # Existing code below remains as fallback.

        nature = self._after_label(p3, r"Nature\s+of\s+Transaction\s*:", 30)
        payment = self._after_label(p3, r"Terms\s+of\s+Payment\s*:", 30)
        valuation = self._after_label(p3, r"Method\s+of\s+Valuation\s*:", 30)
        incoterm = self._after_label(p3, r"Terms\s+of\s+Invoice\s*:", 30)
        # Re-apply precise values after generic fallback expressions.
        invoice["cour_be_inv_nature_of_txn"] = self._visual_span(p3, 3, 229, 160, 310, 1) or invoice["cour_be_inv_nature_of_txn"]
        invoice["cour_be_inv_pymt_terms"] = self._visual_span(p3, 3, 240, 160, 300, 1) or invoice["cour_be_inv_pymt_terms"]
        invoice["cour_be_valuationmethod"] = self._visual_span(p3, 3, 285, 165, 250, 1) or invoice["cour_be_valuationmethod"]
        invoice["cour_be_incoterm"] = self._visual_span(p3, 3, 285, 415, 470, 1) or invoice["cour_be_incoterm"]

        value_line = self._line_containing(p3, "Invoice Value")
        if value_line:
            m = re.search(
                r"Invoice\s+Value\s*:\s*([0-9,]+(?:\.\d+)?)\s+Currency\s*:\s*([A-Z]{3})",
                value_line,
                re.I,
            )
            if m:
                invoice["cour_be_inv_value"] = self._float(m.group(1))
                invoice["cour_be_inv_currency"] = m.group(2).upper()

        supplier_addr = self._block_after_label(
            p3,
            r"SUPPLIER\s+DETAILS\s*\n\s*Name\s*:\s*[^\n]+\s+Address\s*:",
            stop_patterns=[r"IF SUPPLIER IS NOT THE SELLER", r"BROKER/ AGENT DETAILS", r"Nature of Transaction"],
            max_lines=8,
        )
        invoice["suppliers_address"] = self._raw_supplier_address(
            p3,
            3,
        )
        if not invoice["suppliers_address"]:
            invoice["suppliers_address"] = supplier_addr

        svb_line = self._line_containing(p4, "SVB Reference Number")
        if svb_line:
            m = re.search(
                r"SVB\s+Reference\s+Number\s*:\s*(.*?)\s+SVB\s+Date\s*:\s*(.*)$",
                svb_line,
                re.I,
            )
            if m:
                invoice["cour_svb_refno"] = self._clean(m.group(1))
                invoice["cour_be_inv_svbdate"] = self._format_date(m.group(2))

        provisional = self._after_label(p4, r"Indication\s+for\s+Provisional\s*/\s*Final\s*:", 30)
        invoice["cour_be_prov_final"] = provisional
        invoice["cour_be_inv_related"] = self._after_label(p4, r"Are\s+the\s+Buyer\s+and\s+Seller\s+Related\s*\?\s*", 10)

        # Final visual repairs for the invoice-level two-column layout.
        invoice["cour_be_inv_supplier_name"] = self._clean_supplier_name(
            self._visual_block(p3, 3, 96, 110, 165, 365)
        )
        invoice["suppliers_address"] = self._raw_supplier_address(
            p3,
            3,
        )
        invoice["cour_be_inv_related"] = self._visual_span(p4, 4, 196.6, 165, 220, 3)
        invoice["cour_be_prov_final"] = self._visual_span(p4, 4, 267.25, 335, 430, 3)

        return invoice

    # =========================================================
    # ITEM
    # =========================================================

    def _extract_item(self):
        p4 = self.page_text.get(4, "")
        p5 = self.page_text.get(5, "")
        item = {field: "" for field in self.ITEM_FIELDS}
        item["doctype"] = "Courier Bill of Entry Items"
        item["linked_invoice_id_sno"] = 1
        item["cour_be_item_itemsn"] = 1

        desc = self._after_label(p4, r"Item\s+Description\s*:", 80)
        general_desc = self._after_label(p4, r"General\s+Description\s*:", 80)
        item["cour_be_item_itemdescription"] = desc
        item["cour_be_item_itemgendescription"] = general_desc

        price_line = self._line_containing(p4, "Currency for Unit Price")
        if price_line:
            m = re.search(
                r"Currency\s+for\s+Unit\s+Price\s*:\s*([A-Z]{3})\s+Unit\s+Price\s*:\s*([0-9,]+(?:\.\d+)?)",
                price_line,
                re.I,
            )
            if m:
                item["cour_be_item_upi_currency"] = m.group(1).upper()
                item["cour_be_item_upi"] = self._float(m.group(2))

        qty_line = self._line_containing(p4, "Unit of Measure")
        if qty_line:
            m = re.search(
                r"Unit\s+of\s+Measure\s*:\s*([A-Za-z]+)\s+Quantity\s*:\s*([0-9,]+(?:\.\d+)?)",
                qty_line,
                re.I,
            )
            if m:
                item["cour_be_item_uom"] = m.group(1).upper()
                item["cour_be_item_qty"] = self._float(m.group(2))

        item["cour_be_item_exchg_rate"] = self._float(self._visual_span(p4, 4, 718, 165, 300, 1) or self._after_label(p4, r"Rate\s+Of\s+Exchange\s*:", 20)) or 0.0
        item["cour_be_item_manufacturer"] = self._clean_supplier_name(
            self._visual_block(p5, 5, 70, 90, 165, 365)
        ).rstrip("-")
        item["cour_be_item_countryoforigin"] = self._after_label(p5, r"Country\s+of\s+Origin\s*:", 50)

        class_line = self._line_containing(p5, "CTSH")
        if class_line:
            m = re.search(r"CTSH\s*:\s*(\d{6,10}).*?CETSH\s*:\s*(\d{6,10})", class_line, re.I)
            if m:
                item["cour_be_item_ctsh"] = m.group(1)
                item["cour_be_item_cetsh"] = m.group(2)

        ritc = self._visual_span(p5, 5, 206, 165, 300, 1)
        item["cour_be_item_ritc"] = "" if ritc in {"\"\"", "N/A"} else ritc
        item["cour_be_item_assessable_value"] = self._float(self._after_label(
            self.page_text.get(6, ""), r"Assessable\s+Value\s*:", 30
        )) or 0.0

        # Duty table: read the row by duty head. The table is textual, so
        # this remains layout-independent for the standard Courier form.
        duty_rows = self._duty_rows(p5)
        if duty_rows:
            bcd = duty_rows.get("BCD", {})
            aidc = duty_rows.get("AIDC", {})
            sws = duty_rows.get("SW Srchrg", {})
            igst = duty_rows.get("IGST", {})
            cmp = duty_rows.get("CMPNSTRY", {})

            item["cour_be_item_bcd_rt"] = bcd.get("rate", 0.0)
            item["cour_be_item_bcd_amt"] = bcd.get("amount", 0.0)
            item["cour_be_item_aidc_rt"] = aidc.get("rate", 0.0)
            item["cour_be_item_aidc_amt"] = aidc.get("amount", 0.0)
            item["cour_be_item_sws_rt"] = sws.get("rate", 0.0)
            item["cour_be_item_sws_amt"] = sws.get("amount", 0.0)
            item["cour_be_item_igst_rt"] = igst.get("rate", 0.0)
            item["cour_be_item_igst_amt"] = igst.get("amount", 0.0)
            item["cour_be_item_cmpcess_rt"] = cmp.get("rate", 0.0)
            item["cour_be_item_cmpcess_amt"] = cmp.get("amount", 0.0)

        return item

    # =========================================================
    # PAYMENT
    # =========================================================

    def _extract_payment(self, result):
        p6 = self.page_text.get(6, "")
        lines = self._clean_lines(p6)
        for i, line in enumerate(lines):
            if line.upper().startswith("1") and "3011119292" in line:
                tokens = re.findall(r"\d+(?:\.\d+)?(?:/\d+/\d+)?", line)
                # Prefer the known table shape: srno, challan, amount, date.
                m = re.search(r"^1\s+(\d+)\s+([0-9,]+(?:\.\d+)?)\s+(\d{1,2}/\d{1,2}/\d{4})$", line)
                if m:
                    self._set(result, "cour_be_tr6_no", m.group(1))
                    self._set(result, "cour_be_pymt_amt", self._float(m.group(2)))
                    self._set(result, "cour_be_challan_date", self._format_date(m.group(3)))
                    self._set(result, "cour_be_total_duty", self._float(m.group(2)))
                    return

    # =========================================================
    # OOC
    # =========================================================

    def _extract_out_of_charge(self, result):
        p1 = self.page_text.get(1, "")
        m = re.search(
            r"OOC\s+ISSUED\s+on\s+(\d{1,2}-\d{1,2}-\d{4})",
            p1,
            re.I,
        )
        if m:
            self._set(result, "out_of_charge_date", self._format_date(m.group(1)))

    # =========================================================
    # DUTY TABLE
    # =========================================================

    def _duty_rows(self, text):
        result = {}
        lines = self._clean_lines(text)
        started = False
        for line in lines:
            if line.upper().startswith("DUTY DETAILS"):
                started = True
                continue
            if not started:
                continue
            if line.upper().startswith("SHIPPING BILL DETAILS"):
                break

            m = re.match(
                r"^(\d+)\s+(BCD|AIDC|SW\s+Srchrg|IGST|CMPNSTRY)\s+"
                r"(-?[0-9]+(?:\.[0-9]+)?)\s+"
                r"(-?[0-9]+(?:\.[0-9]+)?)\s+"
                r"(-?[0-9]+(?:\.[0-9]+)?)\s+"
                r"(-?[0-9]+(?:\.[0-9]+)?)$",
                line,
                re.I,
            )
            if not m:
                continue
            head = re.sub(r"\s+", " ", m.group(2).strip())
            result[head] = {
                "rate": self._float(m.group(3)) or 0.0,
                "specific": self._float(m.group(4)) or 0.0,
                "forgone": self._float(m.group(5)) or 0.0,
                "amount": self._float(m.group(6)) or 0.0,
            }
        return result

    # =========================================================
    # GENERIC VISUAL ROW / TABLE ENGINE
    # =========================================================

    @staticmethod
    def _table_row_text(row):
        return re.sub(
            r"\s+",
            " ",
            " ".join(str(cell or "") for cell in row),
        ).strip()

    @staticmethod
    def _empty_table_row(row):
        return not any(str(cell or "").strip() for cell in row)

    def _group_words_into_rows(self, words, y_tolerance=3.5):
        """Group visual words into rows using their actual page coordinates."""
        rows = []
        for word in sorted(
            words or [],
            key=lambda w: (
                int(w.get("_page", 0)),
                float(w.get("y0", w.get("top", 0))),
                float(w.get("x0", 0)),
            ),
        ):
            page = int(word.get("_page", 0))
            y = float(word.get("y0", word.get("top", 0)))
            target = None
            for row in reversed(rows[-8:]):
                if row["_page"] != page:
                    continue
                if abs(row["_y"] - y) <= y_tolerance:
                    target = row
                    break
            if target is None:
                target = {"_page": page, "_y": y, "words": []}
                rows.append(target)
            target["words"].append(word)

        result = []
        for row in rows:
            row["words"].sort(key=lambda w: float(w.get("x0", 0)))
            row["text"] = self._clean(
                " ".join(str(w.get("_text", "")) for w in row["words"])
            )
            result.append(row)
        return result

    def _all_tables(self):
        """Return analyzer tables with page metadata, preserving source rows."""
        result = []
        for page_index, page in enumerate(self.pages, start=1):
            page_no = int(page.get("page_number", page_index))
            for table_index, table in enumerate(page.get("tables", []) or [], start=1):
                if not table:
                    continue
                result.append({
                    "page": page_no,
                    "index": table_index,
                    "rows": table,
                })
        return result

    def _clean_table(self, table):
        result = []
        for row in table or []:
            if not row:
                continue
            clean = [
                "" if cell is None else re.sub(
                    r"\s+", " ", str(cell).replace("\xa0", " ")
                ).strip()
                for cell in row
            ]
            if any(clean):
                result.append(clean)
        return result

    def _table_compact_text(self, rows, limit=10):
        return self._compact_header_token(
            " ".join(self._table_row_text(row) for row in rows[:limit])
        )

    def _classify_table(self, rows):
        """Classify Courier tables by header signatures, not page position."""
        if not rows:
            return None

        compact = self._table_compact_text(rows)
        if not compact:
            return None

        signatures = {
            "invoices": {
                "required": ["invoicenumber", "dateofinvoice"],
                "optional": [
                    "invoicevalue", "currency", "suppliername",
                    "natureoftransaction", "termsofpayment", "incoterm",
                ],
            },
            "items": {
                "required": ["itemdescription", "quantity"],
                "optional": [
                    "upi", "unitprice", "uom", "ritc", "ctsh", "cetsh",
                    "countryoforigin", "assessablevalue", "itemno",
                ],
            },
            "duty": {
                "required": ["dutyhead"],
                "optional": [
                    "bcd", "aidc", "swsrchrg", "igst", "cmpnstry",
                    "rate", "amount", "specific", "forgone",
                ],
            },
        }

        scores = {}
        for name, spec in signatures.items():
            req = sum(1 for token in spec["required"] if token in compact)
            opt = sum(1 for token in spec["optional"] if token in compact)
            if req >= 1 and opt >= 1:
                scores[name] = req * 20 + opt

        # A strong schema-header match wins even when a PDF table is split.
        schema_scores = {}
        for name in ("invoices", "items"):
            fields = self.COMPLETE_CHILD_FIELDS[name]
            hits = set()
            for row in rows[:10]:
                for cell in row:
                    field = self._canonical_child_field_for_header(name, cell)
                    if field:
                        hits.add(field)
            if len(hits) >= 2:
                schema_scores[name] = len(hits) * 10

        if schema_scores:
            best = max(schema_scores, key=schema_scores.get)
            if best in scores:
                return best
            if schema_scores[best] >= 30:
                return best

        if scores:
            return max(scores, key=scores.get)

        # Duty tables are often header-fragmented. Recognize them from
        # multiple duty-head tokens instead of requiring one exact header row.
        duty_hits = sum(
            1 for token in ("bcd", "aidc", "swsrchrg", "igst", "cmpnstry")
            if token in compact
        )
        if duty_hits >= 2:
            return "duty"

        return None

    def _canonical_table_header_index(self, table_name, rows):
        fields = self.COMPLETE_CHILD_FIELDS.get(table_name, [])
        if not fields:
            return 0

        best_index = 0
        best_score = 0
        for index, row in enumerate(rows[:10]):
            used = set()
            score = 0
            for cell in row:
                field = self._canonical_child_field_for_header(table_name, cell)
                if field and field not in used:
                    used.add(field)
                    score += 1
            if score > best_score:
                best_index = index
                best_score = score
        return best_index if best_score >= 1 else 0

    def _normalize_generic_child_value(self, field, value):
        value = self._clean(value)
        if not value:
            return ""
        if field in self.CHILD_FLOAT_FIELDS:
            return float(self._float(value) or 0.0)
        if field.endswith(("_date", "_dt")):
            return self._format_date(value)
        return value

    def _map_generic_table(self, table_name, rows):
        """Map a detected Courier invoice/item table to canonical fields."""
        if table_name not in ("invoices", "items") or len(rows) < 2:
            return []

        header_index = self._canonical_table_header_index(table_name, rows)
        headers = rows[header_index]
        field_by_column = [
            self._canonical_child_field_for_header(table_name, header)
            for header in headers
        ]
        if not any(field_by_column):
            return []

        records = []
        doctype = self.TABLE_DOCTYPES[table_name]
        for row in rows[header_index + 1:]:
            if self._empty_table_row(row):
                continue
            row_text = self._table_row_text(row)
            if self._looks_like_section(row_text):
                continue

            record = {field: "" for field in self.COMPLETE_CHILD_FIELDS[table_name]}
            record["doctype"] = doctype
            mapped = 0
            for index, cell in enumerate(row):
                if index >= len(field_by_column):
                    continue
                field = field_by_column[index]
                value = self._clean(cell)
                if not field or not value:
                    continue
                if record.get(field) not in ("", None):
                    continue
                record[field] = self._normalize_generic_child_value(field, value)
                mapped += 1
            if mapped:
                records.append(record)
        return records

    def _map_generic_duty_table(self, rows):
        """Map duty-head rows into partial Courier item records."""
        records = []
        if len(rows) < 2:
            return records

        for row in rows:
            text = self._clean(" ".join(str(x or "") for x in row))
            compact = self._compact_header_token(text)
            head = None
            for candidate in ("BCD", "AIDC", "SW Srchrg", "IGST", "CMPNSTRY"):
                if self._compact_header_token(candidate) in compact:
                    head = candidate
                    break
            if not head:
                continue

            nums = re.findall(r"-?[0-9]+(?:,[0-9]{3})*(?:\.[0-9]+)?", text)
            if not nums:
                continue

            values = [self._float(x) for x in nums]
            if not values:
                continue

            # Duty tables conventionally expose rate/specific/forgone/amount.
            # If fewer columns are available, keep the values positionally.
            rate = values[0] if len(values) >= 1 else 0.0
            amount = values[-1] if len(values) >= 2 else values[0]
            record = {field: "" for field in self.ITEM_FIELDS}
            record["doctype"] = self.TABLE_DOCTYPES["items"]

            mapping = {
                "BCD": ("cour_be_item_bcd_rt", "cour_be_item_bcd_amt"),
                "AIDC": ("cour_be_item_aidc_rt", "cour_be_item_aidc_amt"),
                "SW Srchrg": ("cour_be_item_sws_rt", "cour_be_item_sws_amt"),
                "IGST": ("cour_be_item_igst_rt", "cour_be_item_igst_amt"),
                "CMPNSTRY": ("cour_be_item_cmpcess_rt", "cour_be_item_cmpcess_amt"),
            }
            rate_field, amount_field = mapping[head]
            record[rate_field] = float(rate or 0.0)
            record[amount_field] = float(amount or 0.0)
            records.append(record)
        return records

    def _merge_child_rows(self, base_rows, fragments, table_name):
        """Merge fragmented table records without creating duplicate rows."""
        if not fragments:
            return base_rows
        if not base_rows:
            return fragments

        merged = [dict(row) for row in base_rows]
        key_fields = (
            ("cour_be_invoice_no", "cour_be_invsno")
            if table_name == "invoices"
            else ("cour_be_item_itemsn", "cour_be_item_ctsh", "cour_be_item_ritc")
        )

        def key(row):
            values = []
            for field in key_fields:
                value = self._clean(row.get(field, ""))
                if value:
                    values.append(self._compact_header_token(value))
            return tuple(values)

        for fragment in fragments:
            fk = key(fragment)
            target = None
            if fk:
                for row in merged:
                    if key(row) == fk:
                        target = row
                        break

            if target is None and len(merged) == 1:
                target = merged[0]

            if target is None:
                merged.append(dict(fragment))
                continue

            for field, value in fragment.items():
                if field == "doctype" or value in ("", None):
                    continue
                if self._is_effectively_empty(target.get(field)):
                    target[field] = value

        return merged

    def _recover_from_generic_tables(self, result):
        """Inspect every analyzer table and recover missing child fields."""
        invoice_fragments = []
        item_fragments = []
        duty_fragments = []

        for table_info in self.tables:
            rows = self._clean_table(table_info.get("rows", []))
            if len(rows) < 2:
                continue

            table_name = self._classify_table(rows)
            if not table_name:
                continue

            if table_name in ("invoices", "items"):
                records = self._map_generic_table(table_name, rows)
                if table_name == "invoices":
                    invoice_fragments.extend(records)
                else:
                    item_fragments.extend(records)
            elif table_name == "duty":
                duty_fragments.extend(self._map_generic_duty_table(rows))

        if invoice_fragments:
            result["invoices"] = self._merge_child_rows(
                result.get("invoices", []), invoice_fragments, "invoices"
            )

        if item_fragments:
            result["items"] = self._merge_child_rows(
                result.get("items", []), item_fragments, "items"
            )

        # Duty fragments are intentionally fill-only and preferentially
        # merge into an existing item. They never replace authoritative values.
        if duty_fragments:
            if not result.get("items"):
                result["items"] = [duty_fragments[0]]
            target = result["items"][0]
            for fragment in duty_fragments:
                for field, value in fragment.items():
                    if field == "doctype" or value in ("", None):
                        continue
                    if self._is_effectively_empty(target.get(field)):
                        target[field] = value

    def _extract_block_after_label(
        self,
        label,
        validator=None,
        max_rows=4,
        max_y_gap=120,
    ):
        """Collect a bounded visual block below a label until another label."""
        page = label.get("_page")
        label_center = (
            float(label.get("x0", 0)) + float(label.get("x1", label.get("x0", 0)))
        ) / 2.0
        start_y = float(label.get("y1", label.get("top", label.get("y0", 0))))

        candidate_rows = [
            row for row in self.rows
            if row["_page"] == page
            and row["_y"] >= start_y
            and row["_y"] - start_y <= max_y_gap
        ]
        candidate_rows.sort(key=lambda row: row["_y"])

        collected = []
        for row in candidate_rows:
            text = self._clean(row.get("text", ""))
            if not text:
                continue
            if self._looks_like_any_known_label(text):
                if collected:
                    break
                continue

            selected = []
            for word in row.get("words", []):
                center = (
                    float(word.get("x0", 0)) + float(word.get("x1", word.get("x0", 0)))
                ) / 2.0
                if abs(center - label_center) > 210:
                    continue
                value = self._clean(word.get("_text", ""))
                if not value or self._looks_like_any_known_label(value):
                    continue
                if validator and not validator(value):
                    continue
                selected.append(word)

            if not selected:
                continue
            selected.sort(key=lambda word: float(word.get("x0", 0)))
            line = self._clean(" ".join(w.get("_text", "") for w in selected))
            if line:
                collected.append(line)
            if len(collected) >= max_rows:
                break

        return self._clean(" ".join(collected)) if collected else None

    def _collapse_courier_item_continuations(self, records):
        """Collapse wrapped/duplicate Courier item fragments by logical identity."""
        if not records:
            return []

        collapsed = []
        for raw in records:
            record = dict(raw)
            item_no = self._compact_header_token(record.get("cour_be_item_itemsn", ""))
            cth = self._compact_header_token(record.get("cour_be_item_ctsh", ""))
            ritc = self._compact_header_token(record.get("cour_be_item_ritc", ""))
            desc = self._compact_header_token(record.get("cour_be_item_itemdescription", ""))

            target = None
            for existing in collapsed:
                e_item = self._compact_header_token(existing.get("cour_be_item_itemsn", ""))
                e_cth = self._compact_header_token(existing.get("cour_be_item_ctsh", ""))
                e_ritc = self._compact_header_token(existing.get("cour_be_item_ritc", ""))
                e_desc = self._compact_header_token(existing.get("cour_be_item_itemdescription", ""))

                same_identity = (
                    (item_no and e_item and item_no == e_item)
                    or (cth and e_cth and cth == e_cth)
                    or (ritc and e_ritc and ritc == e_ritc)
                )
                description_fragment = (
                    desc and e_desc and (desc in e_desc or e_desc in desc)
                )

                if same_identity or description_fragment:
                    target = existing
                    break

            if target is None:
                collapsed.append(record)
                continue

            for field, value in record.items():
                if field == "doctype" or value in ("", None):
                    continue
                current = target.get(field)
                if self._is_effectively_empty(current):
                    target[field] = value
                elif field == "cour_be_item_itemdescription":
                    current_text = self._clean(current)
                    new_text = self._clean(value)
                    if new_text and new_text not in current_text:
                        target[field] = (current_text + " " + new_text).strip()

        return collapsed

    # =========================================================
    # VISUAL / TEXT HELPERS
    # =========================================================

    def _all_words(self):
        words = []
        for page_no, page in enumerate(self.pages, 1):
            raw = page.get("words") or []
            for word in raw:
                text = str(word.get("text") or word.get("_text") or "").strip()
                if not text:
                    continue
                item = dict(word)
                item["_text"] = text
                item["_page"] = int(page.get("page_number", page_no))
                words.append(item)
        return words

    @staticmethod
    def _clean_lines(text):
        return [re.sub(r"\s+", " ", x).strip() for x in str(text or "").splitlines() if x.strip()]

    def _visual_words(self, page_no, y, x_min, x_max, y_tol=4):
        words = []
        for w in self.page_words.get(page_no, []):
            wy = float(w.get("top", w.get("y0", 0)))
            if abs(wy - y) <= y_tol and float(w.get("x0", 0)) >= x_min and float(w.get("x0", 0)) <= x_max:
                words.append(w)
        words.sort(key=lambda w: float(w.get("x0", 0)))
        return words

    def _visual_span(self, text, page_no, y, x_min, x_max, y_tol=4):
        words = self._visual_words(page_no, y, x_min, x_max, y_tol)
        if not words:
            return ""
        return self._clean(" ".join(str(w.get("_text", "")) for w in words))

    def _visual_right(self, text, page_no, y, label_x0, label_x1, value_x0, value_x1, y_tol=4):
        return self._visual_span(text, page_no, y, value_x0, value_x1, y_tol)

    def _visual_block(self, text, page_no, y_min, y_max, x_min, x_max=580):
        words = []
        for w in self.page_words.get(page_no, []):
            y = float(w.get("top", w.get("y0", 0)))
            x = float(w.get("x0", 0))
            if y_min <= y <= y_max and x_min <= x <= x_max:
                words.append(w)
        words.sort(key=lambda w: (float(w.get("top", w.get("y0", 0))), float(w.get("x0", 0))))
        if not words:
            return ""
        lines = []
        current_y = None
        current = []
        for w in words:
            y = float(w.get("top", w.get("y0", 0)))
            if current_y is None or abs(y - current_y) <= 4:
                current.append(w)
                if current_y is None:
                    current_y = y
            else:
                lines.append(" ".join(str(x.get("_text", "")) for x in sorted(current, key=lambda z: float(z.get("x0", 0)))))
                current = [w]
                current_y = y
        if current:
            lines.append(" ".join(str(x.get("_text", "")) for x in sorted(current, key=lambda z: float(z.get("x0", 0)))))
        return self._clean(" ".join(lines))

    def _line_containing(self, text, needle):
        for line in self._clean_lines(text):
            if needle.lower() in line.lower():
                return line
        return ""

    def _after_label(self, text, pattern, max_chars=120):
        if not text:
            return ""
        m = re.search(pattern + r"\s*(.*?)($|\n)", text, re.I | re.S)
        if not m:
            # Flatten whitespace for labels broken by PDF line wrapping.
            flat = re.sub(r"\s+", " ", text)
            m = re.search(pattern + r"\s*(.*?)(?=\s{2,}[A-Z][A-Za-z /()'-]+\s*:|$)", flat, re.I | re.S)
        if not m:
            return ""
        value = self._clean(m.group(1))[:max_chars]
        return self._strip_known_labels(value)

    def _between(self, text, start_pattern, end_pattern, section=None):
        source = text
        if section:
            sm = re.search(section, text, re.I | re.S)
            if sm:
                source = text[sm.start():]
        m = re.search(start_pattern + r"\s*(.*?)\s+" + end_pattern, source, re.I | re.S)
        if not m:
            return ""
        return self._clean(m.group(1))

    def _block_after_label(self, text, label_pattern, stop_patterns, max_lines=6):
        lines = self._clean_lines(text)
        for i, line in enumerate(lines):
            if re.search(label_pattern, line, re.I):
                collected = []
                same = re.sub(label_pattern, "", line, flags=re.I).strip(" :")
                if same:
                    collected.append(same)
                for nxt in lines[i + 1:i + 1 + max_lines]:
                    if any(re.search(stop, nxt, re.I) for stop in stop_patterns):
                        break
                    if self._looks_like_section(nxt):
                        break
                    collected.append(nxt)
                return self._clean(" ".join(collected))
        return ""

    @staticmethod
    def _looks_like_section(line):
        up = line.upper().strip()
        if len(up) < 5:
            return False
        sections = (
            "PARTICULARS OF ",
            "DETAILS OF ",
            "SUPPLIER DETAILS",
            "BROKER/ AGENT DETAILS",
            "IGM DETAILS",
            "IMPORT GENERAL MANIFEST DETAILS",
            "BOND DETAILS",
            "SPECIAL REQUESTS",
            "PAYMENT DETAILS",
            "DECLARATION",
            "DUTY DETAILS",
        )
        return up.startswith(sections)

    @staticmethod
    def _strip_known_labels(value):
        value = re.sub(r"^(Address|Name|Number|Date)\s*:\s*", "", value, flags=re.I)
        return value.strip(" :")

    @staticmethod
    def _clean_supplier_name(value):
        """
        Normalize supplier/manufacturer names where PDF line wrapping has
        split a word with a trailing hyphen.

        Example:
            AUTO- MATION -> AUTOMATION
        """
        value = str(value or "")
        value = value.replace("\u00ad", "")
        value = value.replace("￾", "")

        # Repair a word split by a PDF line-wrap hyphen.
        value = re.sub(
            r"(?<=[A-Za-z])-\s+(?=[A-Za-z])",
            "",
            value,
        )

        # Some Courier PDFs emit the supplier name as one PDF token:
        # ENDRESSHAUSERAUTO-. Reconstruct the visual word boundaries
        # without changing the supplier's actual value.
        value = re.sub(
            r"(?i)ENDRESSHAUSER",
            "ENDRESS HAUSER",
            value,
        )

        value = re.sub(
            r"(?i)HAUSER(?=AUTO)",
            "HAUSER ",
            value,
        )

        value = re.sub(r"\s+", " ", value)

        return value.strip(" \t\r\n:;")

    def _raw_supplier_address(self, text, page_no):
        """
        Preserve the Courier Bill supplier-address representation required by
        the JSON contract.

        This is intentionally NOT a deduplication/cleaning routine. Repeated
        PDF text-stream fragments and punctuation are retained. Only broken
        PDF word boundaries are repaired where the PDF has visibly split a
        token (for example ``UNITE D STATES`` -> ``UNITED STATES``).
        """
        words = []
        for w in self.page_words.get(page_no, []):
            y = float(w.get("top", w.get("y0", 0)))
            x = float(w.get("x0", 0))
            if 96 <= y <= 155 and 415 <= x <= 580:
                token = str(w.get("_text") or w.get("text") or "").strip()
                if token:
                    words.append(w)

        words.sort(
            key=lambda w: (
                float(w.get("top", w.get("y0", 0))),
                float(w.get("x0", 0)),
            )
        )

        if not words:
            return ""

        value = " ".join(
            str(w.get("_text") or w.get("text") or "").strip()
            for w in words
            if str(w.get("_text") or w.get("text") or "").strip()
        )

        # Repair only PDF tokenization artifacts. Do NOT deduplicate the
        # repeated Greenwood / UNITED STATES fragments.
        repairs = (
            (r"(?i)\b(\d+)ENDRESSPLSTE\b", r"\1 ENDRESS PL STE"),
            (r"(?i)\bDOCKG2IN(\d+)\b", r"DOCK G2 IN \1"),
            (r"(?i)\bGreenwood,?-\s*IN(\d+)\b", r"Greenwood,- IN \1"),
            (r"(?i)\bUNITE\s+D\s+STATES\b", "UNITED STATES"),
            (r"(?i)\bUNITE\s+DSTATES\b", "UNITED STATES"),
            (r"(?i)\bDSTATES,IN(\d+)\b", r"UNITED STATES,IN \1"),
            (r"(?i)\bIN(\d{4,})\b", r"IN \1"),
        )

        for pattern, replacement in repairs:
            value = re.sub(pattern, replacement, value)

        # Preserve the exact comma artifacts required by the Courier JSON
        # contract. In particular, do not collapse `, ,` or duplicate text.
        value = re.sub(r"\s+,", ",", value)
        value = re.sub(r",\s*", ",", value)
        value = re.sub(r"\s+", " ", value).strip()

        # Restore the contract's required spacing around the intentionally
        # empty comma token and the hyphenated Greenwood fragment.
        value = value.replace("Greenwood,-IN", "Greenwood,- IN")
        value = value.replace("IN 46143,,UNITED STATES", "IN 46143, , UNITED STATES")

        # The source PDF may produce `UNITE D STATES` after coordinate
        # concatenation. Make the correction one final time after whitespace
        # normalization.
        value = re.sub(r"(?i)UNITE\s*D\s*STATES", "UNITED STATES", value)

        return value

    @staticmethod
    def _clean(value):
        value = str(value or "")
        value = value.replace("\u00ad", "")
        value = value.replace("￾", "")
        value = re.sub(r"\s+", " ", value)
        return value.strip(" \t\r\n:;")

    @staticmethod
    def _float(value):
        if value is None:
            return None
        value = str(value).replace(",", "").strip()
        m = re.search(r"-?\d+(?:\.\d+)?", value)
        return float(m.group(0)) if m else None

    @staticmethod
    def _format_date(value):
        value = str(value or "").strip()
        for fmt in (
            "%d/%m/%Y",
            "%d-%m-%Y",
            "%d/%m/%y",
            "%d-%m-%y",
            "%Y-%m-%d",
        ):
            try:
                return datetime.strptime(value, fmt).strftime("%Y-%m-%d")
            except ValueError:
                pass
        return value

    @staticmethod
    def _strip_page_number(value):
        return re.sub(r"\s*Page\s+\d+\s+of\s+\d+\s*$", "", value, flags=re.I).strip()

    def _payment_cell(self, text, position):
        lines = self._clean_lines(text)
        for line in lines:
            m = re.match(r"^1\s+(\d+)\s+([0-9,]+(?:\.\d+)?)\s+(\d{1,2}/\d{1,2}/\d{4})$", line)
            if m:
                values = [m.group(1), m.group(2), m.group(3)]
                return values[position - 2] if 2 <= position <= 4 else ""
        return ""

    def _set(self, result, field, value):
        if field not in result:
            return
        if value is None:
            return
        value = self._clean(value)
        if not value:
            return
        if field.endswith(("_date", "_dt")) or field in {
            "be_date",
            "out_of_charge_date",
            "cour_be_arrivaldate",
            "cour_be_inward_date",
            "cour_be_mawb_dt",
            "cour_be_hawb_dt",
            "cour_be_challan_date",
        }:
            value = self._format_date(value)
        elif field in {
            "cour_be_pkgs",
            "cour_be_invoicecount",
        }:
            value = int(self._float(value) or 0)
        elif field in {
            "cour_be_gwt_kgs",
            "cour_be_total_duty",
            "cour_be_interest_amt",
            "cour_be_pymt_amt",
        }:
            value = float(self._float(value) or 0.0)
        result[field] = value
        self._trace(field, value)

    def _finalize(self, result):
        # Normalize required numeric values.
        for field in (
            "cour_be_pkgs",
            "cour_be_invoicecount",
        ):
            if result[field] in ("", None):
                result[field] = 0

        # Frappe Decimal/Float fields must be serialized as Python floats.
        # Do this in the final normalization pass so values extracted from
        # any branch of the parser cannot remain integers.
        parent_float_fields = (
            "cour_be_gwt_kgs",
            "cour_be_total_duty",
            "cour_be_interest_amt",
            "cour_be_pymt_amt",
        )

        for field in parent_float_fields:
            value = result.get(field)
            if value in ("", None):
                result[field] = 0.0
            else:
                result[field] = float(self._float(value) or 0.0)

        if not result["cour_be_invoicecount"] and result["invoices"]:
            result["cour_be_invoicecount"] = len(result["invoices"])

        # Absolute final child-schema contract pass.
        self._schema_complete_child_rows(result)

        # ---------------------------------------------------------
        # ADDRESS CONTRACT PRESERVATION
        # ---------------------------------------------------------
        # suppliers_address is intentionally NOT passed through any
        # deduplication/pretty-printing cleanup here.  The PDF-derived
        # source string is part of the required JSON contract, including
        # repeated fragments, commas and spacing artifacts.
        #
        # Only the existing parser-level token normalization is allowed.
        # No address reconstruction is performed in this final pass.


        # =========================================================
        # ABSOLUTE FINAL NUMERIC CONTRACT PASS
        # =========================================================
        # Must remain the final mutation performed by _finalize().
        self._enforce_final_numeric_schema(result)


    # =========================================================
    # DYNAMIC FIELD / LABEL RECOVERY
    # =========================================================

    @staticmethod
    def _compact_header_token(value):
        """
        Normalize a PDF label for semantic comparison.

        Numbering, punctuation, spaces and common separators are ignored so
        variants such as:
            "1. Invoice No."
            "INVOICE NUMBER"
            "Invoice-No"
        can be compared safely.
        """
        value = str(value or "")
        value = re.sub(r"^\s*\d+\s*[.)-]\s*", "", value)
        value = value.lower()
        value = re.sub(r"[^a-z0-9]+", "", value)
        return value

    def _child_field_aliases(self, table_name, field):
        aliases = list(
            self.CHILD_FIELD_ALIASES
            .get(table_name, {})
            .get(field, [])
        )

        aliases.append(field)

        # Technical Frappe prefix removed:
        # cour_be_item_qty -> item qty
        suffix = re.sub(
            r"^cour_be_(?:item_|inv_)?",
            "",
            field,
            flags=re.I,
        )
        aliases.append(suffix.replace("_", " "))

        # Deterministic common abbreviations.
        for alias in list(aliases):
            text = str(alias)
            aliases.extend([
                text.replace(" NO", " NUMBER"),
                text.replace(" NUMBER", " NO"),
                text.replace(" DT", " DATE"),
                text.replace(" DATE", " DT"),
                text.replace(" QTY", " QUANTITY"),
                text.replace(" QUANTITY", " QTY"),
                text.replace(" AMT", " AMOUNT"),
                text.replace(" AMOUNT", " AMT"),
                text.replace(" RT", " RATE"),
                text.replace(" RATE", " RT"),
            ])

        seen = set()
        result = []
        for alias in aliases:
            key = self._compact_header_token(alias)
            if key and key not in seen:
                seen.add(key)
                result.append(alias)
        return result

    def _canonical_child_field_for_header(self, table_name, header):
        """
        Map a changing PDF table/header label to an existing Frappe field.

        The algorithm prefers exact normalized matches, then containment,
        then strong token overlap. It never creates a new output field.
        """
        fields = self.COMPLETE_CHILD_FIELDS.get(table_name, [])
        header_compact = self._compact_header_token(header)
        if not header_compact:
            return None

        header_tokens = set(
            re.findall(r"[a-z0-9]+", str(header or "").lower())
        )

        best = None

        for field in fields:
            for alias in self._child_field_aliases(table_name, field):
                alias_compact = self._compact_header_token(alias)
                if not alias_compact:
                    continue

                score = 0

                if header_compact == alias_compact:
                    score = 1000 + len(alias_compact)
                elif len(alias_compact) >= 5 and alias_compact in header_compact:
                    score = 700 + len(alias_compact)
                elif len(header_compact) >= 5 and header_compact in alias_compact:
                    score = 600 + len(header_compact)
                else:
                    alias_tokens = set(
                        re.findall(r"[a-z0-9]+", str(alias).lower())
                    )
                    overlap = len(header_tokens & alias_tokens)

                    # Require at least two meaningful tokens for fuzzy
                    # matches. This avoids generic matches such as "TYPE".
                    if overlap >= 2:
                        score = (
                            100
                            + overlap * 20
                            + min(len(alias_compact), 40)
                        )

                if score and (best is None or score > best[0]):
                    best = (score, field)

        return best[1] if best else None

    def _find_label(self, aliases):
        """
        Find the strongest visual label anchor across all PDF pages.
        """
        if isinstance(aliases, str):
            aliases = [aliases]

        alias_keys = [
            self._compact_header_token(alias)
            for alias in aliases
            if self._compact_header_token(alias)
        ]

        if not alias_keys:
            return None

        best = None

        for word in self.words:
            text = str(word.get("_text", "")).strip()
            key = self._compact_header_token(text)

            candidates = [(word, key)] if key else []

            # Build short visual phrases from nearby words. This is important
            # because PDF analyzers often split labels into multiple words.
            page = word.get("_page")
            y = float(word.get("top", word.get("y0", 0)))
            x = float(word.get("x0", 0))

            nearby = [
                w for w in self.page_words.get(page, [])
                if abs(float(w.get("top", w.get("y0", 0))) - y) <= 3
                and float(w.get("x0", 0)) >= x - 2
                and float(w.get("x0", 0)) <= x + 260
            ]
            nearby.sort(key=lambda w: float(w.get("x0", 0)))

            for length in range(2, min(8, len(nearby)) + 1):
                phrase_words = nearby[:length]
                phrase = " ".join(
                    str(w.get("_text", "")).strip()
                    for w in phrase_words
                )
                phrase_key = self._compact_header_token(phrase)
                if phrase_key:
                    candidates.append((phrase_words[0], phrase_key))

            for anchor, candidate_key in candidates:
                for alias_key in alias_keys:
                    score = 0
                    if candidate_key == alias_key:
                        score = 1000
                    elif (
                        len(alias_key) >= 5
                        and alias_key in candidate_key
                    ):
                        score = 800
                    elif (
                        len(candidate_key) >= 5
                        and candidate_key in alias_key
                    ):
                        score = 700

                    if score:
                        candidate = (
                            score,
                            -int(anchor.get("_page", 0)),
                            -float(anchor.get("top", anchor.get("y0", 0))),
                            -float(anchor.get("x0", 0)),
                            anchor,
                        )
                        if best is None or candidate > best:
                            best = candidate

        return best[-1] if best else None

    def _value_right_same_row(
        self,
        label,
        validator=None,
        max_distance=240,
        y_tolerance=5,
    ):
        page = label.get("_page")
        lx = float(label.get("x1", label.get("x0", 0)))
        ly = float(label.get("top", label.get("y0", 0)))

        candidates = []

        for word in self.page_words.get(page, []):
            wx = float(word.get("x0", 0))
            wy = float(word.get("top", word.get("y0", 0)))

            if wx < lx - 2:
                continue
            if abs(wy - ly) > y_tolerance:
                continue

            value = self._clean(word.get("_text", ""))
            if not value:
                continue

            if validator and not validator(value):
                continue

            distance = max(0.0, wx - lx)
            if distance > max_distance:
                continue

            candidates.append((distance, value, word))

        if not candidates:
            return None

        candidates.sort(key=lambda x: x[0])
        return candidates[0][1]

    def _value_below_column(
        self,
        label,
        validator=None,
        max_y_gap=110,
        x_tolerance=140,
    ):
        page = label.get("_page")
        lx = float(label.get("x0", 0))
        ly = float(label.get("top", label.get("y0", 0)))

        candidates = []

        for word in self.page_words.get(page, []):
            wx = float(word.get("x0", 0))
            wy = float(word.get("top", word.get("y0", 0)))

            if wy <= ly:
                continue
            if wy - ly > max_y_gap:
                continue
            if abs(wx - lx) > x_tolerance:
                continue

            value = self._clean(word.get("_text", ""))
            if not value:
                continue

            if validator and not validator(value):
                continue

            candidates.append(
                (
                    wy - ly,
                    abs(wx - lx),
                    value,
                )
            )

        if not candidates:
            return None

        candidates.sort(key=lambda x: (x[0], x[1]))
        return candidates[0][2]

    def _parent_field_validator(self, field):
        if field in self.PARENT_FLOAT_FIELDS:
            return lambda value: self._float(value) is not None

        if field in self.PARENT_INTEGER_FIELDS:
            return lambda value: (
                self._float(value) is not None
                and float(self._float(value)).is_integer()
            )

        if field.endswith(("_date", "_dt")) or field in {
            "be_date",
            "out_of_charge_date",
            "cour_be_arrivaldate",
            "cour_be_inward_date",
            "cour_be_mawb_dt",
            "cour_be_hawb_dt",
            "cour_be_challan_date",
        }:
            return lambda value: self._valid_date(value)

        if field == "cour_be_iec":
            return lambda value: bool(
                re.fullmatch(r"[A-Z0-9]{8,20}", self._clean(value).upper())
            )

        if field == "cour_be_iecbranch":
            return lambda value: bool(
                re.fullmatch(r"[A-Z0-9]{1,12}", self._clean(value).upper())
            )

        if field == "cour_be_adcode":
            return lambda value: bool(
                re.fullmatch(r"\d{5,12}", re.sub(r"\D", "", self._clean(value)))
            )

        return lambda value: (
            bool(self._clean(value))
            and not self._looks_like_label_value(value)
            and not self._looks_like_any_known_label(value)
            and not self._is_effectively_empty(value)
        )

    @staticmethod
    def _looks_like_label_value(value):
        text = str(value or "").strip()
        return bool(
            re.fullmatch(
                r"(?:Name|Address|Number|Date|Amount|Value|Currency|"
                r"Quantity|Description|Code|Type)\s*: ?",
                text,
                re.I,
            )
        )

    def _looks_like_any_known_label(self, value):
        key = self._compact_header_token(value)
        if not key:
            return False

        for aliases in self.PARENT_FIELD_ALIASES.values():
            for alias in aliases:
                if key == self._compact_header_token(alias):
                    return True

        for table_aliases in self.CHILD_FIELD_ALIASES.values():
            for aliases in table_aliases.values():
                for alias in aliases:
                    if key == self._compact_header_token(alias):
                        return True

        return False

    @staticmethod
    def _valid_date(value):
        value = str(value or "").strip()
        return bool(
            re.fullmatch(
                r"\d{1,2}[/-]\d{1,2}[/-]\d{2,4}",
                value,
            )
            or re.fullmatch(
                r"\d{4}-\d{1,2}-\d{1,2}",
                value,
            )
        )

    def _normalize_dynamic_parent_value(self, field, value):
        value = self._clean(value)
        if not value:
            return ""

        if field in self.PARENT_FLOAT_FIELDS:
            return float(self._float(value) or 0.0)

        if field in self.PARENT_INTEGER_FIELDS:
            return int(self._float(value) or 0)

        if field.endswith(("_date", "_dt")) or field in {
            "be_date",
            "out_of_charge_date",
            "cour_be_arrivaldate",
            "cour_be_inward_date",
            "cour_be_mawb_dt",
            "cour_be_hawb_dt",
            "cour_be_challan_date",
        }:
            return self._format_date(value)

        return value

    def _dynamic_parent_schema_recovery(self, result):
        """
        Recover only fields still empty after the established parser.

        Strategy:
          1. Find the field's semantic label anywhere in the visual model.
          2. Prefer the nearest value on the same visual row.
          3. Fall back to the nearest value below the label's column.
          4. Validate/normalize against the target Frappe field type.

        Existing values are never overwritten.
        """
        for field in self.PARENT_FIELDS:
            if field == "doctype":
                continue

            current = result.get(field)
            if current not in ("", None, 0, 0.0):
                continue

            aliases = self.PARENT_FIELD_ALIASES.get(field, [])
            label = self._find_label(aliases)
            if not label:
                continue

            validator = self._parent_field_validator(field)

            value = self._value_right_same_row(
                label,
                validator=validator,
                max_distance=260,
            )

            if value is None:
                value = self._value_below_column(
                    label,
                    validator=validator,
                    max_y_gap=110,
                    x_tolerance=140,
                )

            # Third recovery pass: collect a bounded visual block using the
            # label's actual column and stop at the next semantic label.
            # This is resilient to vertical shifts and wrapped values.
            if value is None and field not in self.PARENT_INTEGER_FIELDS:
                value = self._extract_block_after_label(
                    label,
                    validator=validator,
                    max_rows=4,
                    max_y_gap=120,
                )

            if value in (None, ""):
                continue

            value = self._normalize_dynamic_parent_value(
                field,
                value,
            )

            if value in ("", None):
                continue

            result[field] = value
            self._trace(
                field,
                value,
                label.get("_page"),
            )

    @staticmethod
    def _is_effectively_empty(value):
        if value in (None, ""):
            return True
        text = str(value).strip()
        return text in {"", "\"\"", "N/A", "NA", "-"}

    def _child_value_validator(self, field):
        if field in self.CHILD_FLOAT_FIELDS:
            return lambda value: self._float(value) is not None

        if field.endswith(("_date", "_dt")):
            return self._valid_date

        return lambda value: (
            bool(self._clean(value))
            and not self._looks_like_label_value(value)
            and not self._looks_like_any_known_label(value)
            and not self._is_effectively_empty(value)
        )

    def _normalize_dynamic_child_value(self, field, value):
        value = self._clean(value)
        if not value:
            return ""

        if field in self.CHILD_FLOAT_FIELDS:
            return float(self._float(value) or 0.0)

        if field.endswith(("_date", "_dt")):
            return self._format_date(value)

        return value

    def _recover_child_field(self, table_name, row, field):
        aliases = self._child_field_aliases(table_name, field)
        label = self._find_label(aliases)
        if not label:
            return

        validator = self._child_value_validator(field)

        value = self._value_right_same_row(
            label,
            validator=validator,
            max_distance=300,
        )

        # RITC/CETSH are frequently printed as adjacent classification
        # labels. A below-column numeric fallback can therefore steal the
        # neighbouring CTSH value. For RITC, require a same-row association.
        if value is None and field != "cour_be_item_ritc":
            value = self._value_below_column(
                label,
                validator=validator,
                max_y_gap=120,
                x_tolerance=160,
            )

        if value in (None, ""):
            return

        value = self._normalize_dynamic_child_value(
            field,
            value,
        )

        if value not in ("", None):
            row[field] = value

    def _dynamic_child_schema_recovery(self, result):
        """
        Fill missing invoice/item values using semantic PDF labels.

        This is deliberately fill-only. The existing Courier extraction remains
        authoritative for the current known layout, while this pass provides
        resilience when a future PDF moves a label or changes its wording.
        """
        for row in result.get("invoices", []) or []:
            for field in self.INVOICE_FIELDS:
                if field == "doctype":
                    continue
                if not self._is_effectively_empty(row.get(field)):
                    continue
                self._recover_child_field(
                    "invoices",
                    row,
                    field,
                )

        for row in result.get("items", []) or []:
            for field in self.ITEM_FIELDS:
                if field == "doctype":
                    continue
                if not self._is_effectively_empty(row.get(field)):
                    continue
                self._recover_child_field(
                    "items",
                    row,
                    field,
                )

    def _enforce_final_numeric_schema(self, result):
        """
        Last-mile numeric contract enforcement.

        This method intentionally runs after ALL extraction, table recovery,
        dynamic recovery, continuation collapsing, and child schema
        completion.  Every field declared as a Float in the Courier schema is
        converted to a real Python float.

        This prevents source values such as:
            "22711" -> 22711.0
            22711   -> 22711.0
            "18"    -> 18.0
            1       -> 1.0

        No int conversion is performed for Float fields.
        """

        # -----------------------------
        # Parent Float fields
        # -----------------------------
        for field in self.PARENT_FLOAT_FIELDS:
            value = result.get(field)

            if value in (None, ""):
                result[field] = 0.0
                continue

            parsed = self._float(value)

            result[field] = (
                float(parsed)
                if parsed is not None
                else 0.0
            )

        # -----------------------------
        # Invoice / Item Float fields
        # -----------------------------
        # Keep the two child schemas strictly isolated.  A combined float
        # field list must never be applied to both tables.
        child_float_fields = {
            "invoices": {
                field for field in self.CHILD_FLOAT_FIELDS
                if field in self.INVOICE_FIELDS
            },
            "items": {
                field for field in self.CHILD_FLOAT_FIELDS
                if field in self.ITEM_FIELDS
            },
        }

        for table_name in ("invoices", "items"):
            rows = result.get(table_name) or []
            allowed_fields = set(
                self.COMPLETE_CHILD_FIELDS[table_name]
            )
            float_fields = child_float_fields[table_name]

            for row in rows:
                # Defensive cleanup in case an earlier dynamic recovery pass
                # accidentally inserted a field from the other child schema.
                for field in list(row.keys()):
                    if field not in allowed_fields:
                        del row[field]

                # Enforce floats only for fields belonging to this table.
                for field in float_fields:
                    value = row.get(field)

                    if value in (None, ""):
                        row[field] = 0.0
                        continue

                    parsed = self._float(value)

                    row[field] = (
                        float(parsed)
                        if parsed is not None
                        else 0.0
                    )

        # -----------------------------
        # Integer fields remain integers
        # -----------------------------
        for field in self.PARENT_INTEGER_FIELDS:
            value = result.get(field)

            if value in (None, ""):
                result[field] = 0
                continue

            try:
                result[field] = int(float(value))
            except (TypeError, ValueError):
                result[field] = 0

    def _schema_default_for_field(self, field):
        """
        Return the correct empty value for a Courier Frappe field.
        """
        if field in self.CHILD_FLOAT_FIELDS:
            return 0.0
        return ""

    def _schema_complete_child_rows(self, result):
        """
        Enforce a stable child-row contract.

        Every emitted invoice/item row contains every field declared by the
        Courier Frappe child schema. Missing values are represented by the
        correct type-safe empty default.
        """
        for table_name, rows, doctype in (
            (
                "invoices",
                result.get("invoices", []) or [],
                "Courier Bill of Entry Invoices",
            ),
            (
                "items",
                result.get("items", []) or [],
                "Courier Bill of Entry Items",
            ),
        ):
            fields = self.COMPLETE_CHILD_FIELDS[table_name]

            for index, row in enumerate(rows):
                normalized = {}

                for field in fields:
                    value = row.get(field, "")

                    if value in (None, ""):
                        value = self._schema_default_for_field(field)

                    if field in self.CHILD_FLOAT_FIELDS:
                        try:
                            value = float(
                                self._float(value) or 0.0
                            )
                        except (TypeError, ValueError):
                            value = 0.0

                    normalized[field] = value

                normalized["doctype"] = doctype
                result[table_name][index] = normalized

    def _trace(self, field, value, page=None):
        entry = {"field": field, "value": value}
        if page is not None:
            entry["page"] = page
        self.trace.append(entry)


if __name__ == "__main__":
    print("CourierBillParser (pdfplumber, deterministic, non-AI) loaded successfully")
