import re
from datetime import datetime
from typing import Any, ClassVar


class ShippingBillParser:
	"""
	Dynamic, deterministic, non-AI Shipping Bill parser.

	This parser is designed for the pdfplumber output produced by
	PDFAnalyzer.

	It intentionally does NOT depend on:
	- sample PDF values
	- fixed page coordinates
	- PDF text-stream order
	- exact section strings

	It uses:
	- visual words and their coordinates
	- label anchors
	- nearby visual rows/regions
	- pdfplumber-detected tables
	- field validation
	- generic normalization

	The parser returns:
	{
		"shipping_bill_json": {...},
		"_trace": [...]
	}
	"""

	PARENT_FIELDS: ClassVar[list[str]] = [
		"doctype",
		"port_code",
		"state_of_origin",
		"inv",
		"cb_code",
		"gwt",
		"shipping_bill_no",
		"port_of_loading",
		"item",
		"buyer_name",
		"gwt_unit",
		"shipping_bill_date",
		"cntry_of_finaldstn",
		"cntry_of_discharge",
		"cont",
		"pkg",
		"iec",
		"port_of_finaldstn",
		"port_of_discharge",
		"iec_branch_code",
		"p_1a_mode",
		"p_1a_meis",
		"p_1a_dfrc",
		"p_1a_assess",
		"p_1a_dbk",
		"p_1a_re_exp",
		"p_1a_exam",
		"p_1a_rodtp",
		"p_1a_lut",
		"p_1a_job",
		"p_1a_licence",
		"p_1b_exporter_name",
		"p_1b_exporter_address",
		"p_1b_cb_name",
		"p_1b_dbk_ac_no",
		"p_1b_consignee_name",
		"p_1b_consignee_address",
		"p_1b_aeo",
		"p_1b_ifsc_no",
		"p_1b_type",
		"p_1b_rbi_wr_no",
		"p_1b_gstin",
		"p_1b_ad_code",
		"p_1b_rbi_wr_dt",
		"p_1b_forex_ac_no",
		"p_1c_fob_val",
		"p_1c_com",
		"p_1c_cess",
		"p_1c_freight",
		"p_1c_deductions",
		"p_1c_insurance",
		"p_1c_p_c",
		"p_1c_discount",
		"p_1c_duty",
		"p_1d_dbk",
		"p_1d_rodtep_amt",
		"p_1d_igst_amt",
		"p_1d_rosctle_amt",
		"p_1d_cess_amt",
		"p_1d_igst_val",
		"p_1j_subm_dt",
		"p_1i_subm_time",
		"p_1i_leo_no",
		"p_1i_assess_dt",
		"p_1i_assess_time",
		"p_1i_leo_dt",
		"p_1i_exmn_dt",
		"p_1i_exmn_time",
		"p_1i_leo_time",
		"p_1i_brc_realzn_dt",
		"p_1i_sez_unit_data",
	]

	CHILD_TABLES: ClassVar[list[str]] = [
		"manifest_details",
		"challan_details",
		"annex_details",
		"invoice_details",
		"aa__dfia_licence_details",
		"item_details",
		"drawback_and_rosl_claim",
		"job_details",
		"single_window_declaration",
		"single_window_declaration_constituents",
		"single_window_declaration_control",
		"supporting_documents",
		"ar4_details",
		"third_party_details",
		"item_manufacturerproducergrower_details",
		"rodtep_details",
		"container_details",
	]

	COMPLETE_CHILD_FIELDS: ClassVar[dict[str, list[str]]] = {'manifest_details': ['p_1e_mawb_no', 'p_1e_noc', 'p_1e_mawb_dt', 'p_1e_cin_no', 'p_1e_hawb_no', 'p_1e_cin_dt', 'p_1e_hawb_dt', 'p_1e_cin_site_id'], 'challan_details': ['p_4h_challan_serial_no', 'p_4h_challan_no', 'p_4h_paymt_dt', 'p_4h_challan_amount'], 'annex_details': ['p_1i_seal_typ', 'p_1i_loose_pkts', 'p_1i_nature_cargo', 'p_1i_marks_numbers', 'p_1i_no_of_pkgs', 'p_1i_no_of_containers'], 'invoice_details': ['inv_sn', 'p_2a_inv_no', 'p_2a_inv_dt', 'p_2a_ad_code', 'p_2a_invterm', 'p_2b_exporter_name', 'p_2b_buyer_addr', 'p_2b_exporter_addr', 'p_2b_buyer_name', 'p_2c_invoice_value', 'p_2c_freight', 'p_2c_discount', 'p_2c_deduct', 'p_2c_exchng_rate_desc', 'p_2c_invoice_curr', 'p_2c_frieght_curr', 'p_2c_discount_curr', 'p_2c_deduct_curr', 'p_2c_fob_val', 'p_2c_insurance', 'p_2c_commison', 'p_2c_p_c', 'p_2c_fob_curr', 'p_2c_insurance_curr', 'p_2c_commison_curr', 'p_2c_p_c_curr'], 'aa__dfia_licence_details': ['p_4b_invsn', 'p_4b_itmsn', 'p_4b_licence_no', 'p_4b_licence_dt', 'p_4b_licence_value', 'p_4b_licence_qty'], 'item_details': ['p_3a_invsno', 'p_3a_itemsn', 'p_3a_cth', 'p_3a_item_desc', 'p_3a_qty', 'p_3a_uqc', 'p_3a_rate', 'p_3a_value', 'p_3a_fob', 'p_3a_pmv', 'p_3a_duty_amt', 'p_3a_cess_rate', 'p_3a_cess_amt', 'p_3a_dbk_claimed', 'p_3a_igststat', 'p_3a_igst_val', 'p_3a_igst_amt', 'p_3a_schcod', 'p_3a_scheme_desc', 'p_3a_sqc_mst', 'p_3a_sqc_uqc', 'p_3a_state_of_origin', 'p_3a_district_of_origin', 'p_3a_pt_abroad', 'p_3a_comp_cess', 'p_3a_end_use', 'p_3a_benefit_availd', 'p_3a_reward_benefit', 'p_3a_third_party_item'], 'drawback_and_rosl_claim': ['p_3b_invsno', 'p_3b_itemsn', 'p_3b_dbksn', 'p_3b_dbk_rate', 'p_3b_dbk_amt', 'p_3b_rosl_rate', 'p_3b_rosl_amt'], 'job_details': ['p_3c_invsn', 'p_3c_itmsn', 'p_3c_job_no', 'p_3c_job_date', 'p_3c_job_amount'], 'single_window_declaration': ['p_4d_invsn', 'p_4d_itmsn', 'p_4d_info', 'p_4d_qualifier', 'p_4d_info_cd', 'p_4d_info_text', 'p_4d_info_msr', 'p_4d_uqc'], 'single_window_declaration_constituents': ['p_4e_invsn', 'p_4e_itmsn', 'p_4e_constituent', 'p_4e_percentage'], 'single_window_declaration_control': ['p_4f_invsn', 'p_4f_itmsn', 'p_4f_control', 'p_4f_result'], 'supporting_documents': ['p_4g_invsn', 'p_4g_itmsn', 'p_4g_doc_code', 'p_4g_doc_no', 'p_4g_doc_date', 'p_4g_remarks'], 'ar4_details': ['p_4j_invsn', 'p_4j_itmsno', 'p_4j_ar4_number', 'p_4j_ar4_date', 'p_4j_commissionerate', 'p_4j_division', 'p_4j_range'], 'third_party_details': ['p_4k_invsn', 'p_4k_itmsn', 'p_4k_iec', 'p_4k_exporter_name', 'p_4k_address', 'p_4k_gstn_id_type'], 'item_manufacturerproducergrower_details': ['p_4l_invsn', 'p_4l_itmsn', 'p_4l_type', 'p_4l_manufact_cd', 'p_4l_source_state', 'p_4l_trans_cy', 'p_4l_address'], 'rodtep_details': ['p_4m_invsn', 'p_4m_itmsn', 'p_4m_quantity', 'p_4m_uqc', 'p_4m_no_of_units', 'p_4m_value'], 'container_details': ['p_4i_container', 'p_4i_cont_seal', 'p_4i_cont_dt', 'p_4i_cont_sno']}

	# Empty child tables are represented by one schema-only row.
	# The row contains canonical Frappe fieldnames only; it contains no
	# invented PDF data.
	EMIT_EMPTY_CHILD_SCHEMA_ROWS: ClassVar[bool] = True

	CHILD_DOCTYPES: ClassVar[dict[str, str]] = {'manifest_details': 'Manifest Detail', 'challan_details': 'Challan Detail', 'annex_details': 'Annex Detail', 'invoice_details': 'Invoice Detail', 'aa__dfia_licence_details': 'AA or DFIA Licence Detail', 'item_details': 'Item Detail', 'drawback_and_rosl_claim': 'Drawback And Rosl Claim', 'job_details': 'Job Detail', 'single_window_declaration': 'Single Window Declaration', 'single_window_declaration_constituents': 'Single Window Declaration Constituent', 'single_window_declaration_control': 'Single Window Declaration Control', 'supporting_documents': 'Supporting Document', 'ar4_details': 'AR4 Detail', 'third_party_details': 'Third Party Detail', 'item_manufacturerproducergrower_details': 'Item Manufacturer-Producer-Grower Detail', 'rodtep_details': 'Rodtep Detail', 'container_details': 'Container Detail'}

	STATUS_FIELDS: ClassVar[list[str]] = [
		"p_1a_assess",
		"p_1a_meis",
		"p_1a_dbk",
		"p_1a_re_exp",
		"p_1a_exam",
		"p_1a_rodtp",
		"p_1a_lut",
		"p_1a_job",
		"p_1a_licence",
		"p_1a_dfrc",
	]

	NUMBER_FIELDS: ClassVar[set[str]] = {'p_1c_fob_val', 'p_1c_discount', 'p_1d_dbk', 'cont', 'p_1d_rosctle_amt', 'p_1d_cess_amt', 'item', 'pkg', 'inv', 'p_1c_freight', 'p_1d_igst_val', 'p_1c_com', 'p_1c_deductions', 'p_1c_p_c', 'p_1d_rodtep_amt', 'p_1c_duty', 'p_1c_cess', 'p_1c_insurance', 'p_1d_igst_amt', 'gwt'}

	DATE_FIELDS: ClassVar[set[str]] = {'p_1i_leo_dt', 'p_1i_assess_dt', 'p_1i_brc_realzn_dt', 'p_1i_exmn_dt', 'shipping_bill_date', 'p_1j_subm_dt'}

	TIME_FIELDS: ClassVar[set[str]] = {'p_1i_subm_time', 'p_1i_assess_time', 'p_1i_exmn_time', 'p_1i_leo_time'}

	LABELS: ClassVar[dict[str, list[str]]] = {
		"port_code": ["PORT CODE"],
		"shipping_bill_no": ["SB NO", "SB NO."],
		"shipping_bill_date": ["SB DATE"],
		"iec": ["IEC", "IEC/BR"],
		"cb_code": ["CB CODE"],
		"inv": ["INV"],
		"item": ["ITEM"],
		"cont": ["CONT"],
		"pkg": ["PKG"],
		"gwt": ["G.WT", "GWT", "GROSS WEIGHT"],
		"gwt_unit": ["G.WT UNIT", "UNIT"],
		"port_of_loading": ["PORT OF LOADING"],
		"state_of_origin": ["STATE OF ORIGIN"],
		"port_of_finaldstn": ["PORT OF FINAL DESTINATION"],
		"port_of_discharge": ["PORT OF DISCHARGE"],
		"cntry_of_finaldstn": [
			"COUNTRY OF FINAL DESTINATION",
			"COUNTRY OF FINALDESTINATION",
			"COUNTRY OF FINALDESTINATIO",
		],
		"cntry_of_discharge": ["COUNTRY OF DISCHARGE"],
		"p_1b_exporter_name": ["EXPORTER'S NAME & ADDRESS"],
		"p_1b_consignee_name": ["CONSIGNEE NAME & ADDRESS"],
		"p_1b_cb_name": ["CB NAME"],
		"p_1b_type": ["TYPE"],
		"p_1b_gstin": ["GSTIN", "GSTIN / TYPE"],
		"p_1b_ad_code": ["AD CODE"],
		"p_1b_forex_ac_no": ["FOREX BANK A/C NO", "FOREX BANK A/C NO."],
		"p_1b_dbk_ac_no": ["DBK BANK A/C NO", "DBK BANK A/C NO."],
		"p_1b_aeo": ["AEO"],
		"p_1b_ifsc_no": ["IFSC NO", "IFSC NO."],
		"p_1b_rbi_wr_no": ["RBI WAIVER NO", "RBI WAIVER NO."],
		"p_1b_rbi_wr_dt": ["RBI WAIVER DT", "RBI WAIVER DATE"],
		"p_1c_fob_val": ["FOB VALUE"],
		"p_1c_cess": ["CESS", "9.CESS"],
		"p_1c_duty": ["DUTY", "8.DUTY"],
		"p_1c_freight": ["FREIGHT"],
		"p_1c_discount": ["DISCOUNT"],
		"p_1c_deductions": ["DEDUCTIONS", "DEDUCT"],
		"p_1c_insurance": ["INSURANCE"],
		"p_1c_com": ["COMMISSION", "COM"],
		"p_1c_p_c": ["P & C", "P.C", "P C"],
		"p_1d_dbk": ["DBK"],
		"p_1d_rodtep_amt": ["RODTEP"],
		"p_1d_rosctle_amt": ["ROSCTL", "ROSL"],
		"p_1j_subm_dt": ["SUBMISSION"],
		"p_1i_assess_dt": ["ASSESSMENT"],
		"p_1i_assess_time": ["ASSESSMENT"],
		"p_1i_exmn_dt": ["EXAMINATION"],
		"p_1i_exmn_time": ["EXAMINATION"],
		"p_1i_subm_time": ["SUBMISSION"],
		"p_1i_leo_time": ["LEO TIME", "LEO"],
		"p_1i_sez_unit_data": ["SEZ UNIT DETAILS", "SEZ UNIT"],
		"p_1i_leo_no": ["LEO NO"],
		"p_1i_leo_dt": ["LEO DATE"],
		"p_1i_exmn_dt": ["EXAMINATION"],
		"p_1i_brc_realzn_dt": ["BRC REALISATION DATE", "BRC REALIZATION DATE"],
	}

	TABLE_ALIASES: ClassVar[dict[str, list[str]]] = {
		"manifest_details": ["MANIFEST", "MAWB", "CIN"],
		"annex_details": ["ANNEX", "SEAL TYPE", "NATURE OF CARGO"],
		"invoice_details": ["INVOICE", "INV NO", "INVOICE NO"],
		"item_details": ["ITEM DETAILS", "HS CODE", "CTH", "ITEM DESCRIPTION"],
		"single_window_declaration": [
			"SINGLE WINDOW",
			"QUALIFIER",
			"INFO CODE",
			"INFO",
		],
		"container_details": ["CONTAINER", "CONTAINER NO"],
		"challan_details": ["CHALLAN"],
		"aa__dfia_licence_details": ["AA", "DFIA", "LICENCE"],
		"drawback_and_rosl_claim": ["DRAWBACK", "ROSL", "RODTEP"],
		"job_details": ["JOB DETAILS"],
		"supporting_documents": ["SUPPORTING DOCUMENT"],
		"ar4_details": ["AR4"],
		"third_party_details": ["THIRD PARTY"],
		"item_manufacturerproducergrower_details": [
			"MANUFACTURER",
			"PRODUCER",
			"GROWER",
		],
		"rodtep_details": ["RODTEP"],
		"single_window_declaration_constituents": ["CONSTITUENT"],
		"single_window_declaration_control": ["CONTROL", "CONTROL RESULT"],
		"reexport_details": ["REEXPORT DETAILS", "BE SITE ID", "BE NUMBER"],
	}


	# =========================================================
	# COMPLETE DYNAMIC FIELD-ALIAS CONTRACT
	# =========================================================
	# These aliases describe labels/headers used by Shipping Bill PDFs.
	# They map PDF terminology to the EXISTING Frappe fieldnames. They do
	# not add or change any DocType field.
	PARENT_FIELD_ALIASES: ClassVar[dict[str, list[str]]] = {
		"state_of_origin": ["STATE OF ORIGIN", "ORIGIN STATE"],
		"buyer_name": ["BUYER NAME", "BUYER", "BUYER NAME & ADDRESS"],
		"p_1a_mode": ["MODE", "MODE OF TRANSPORT"],
		"p_1a_meis": ["MEIS"],
		"p_1a_dfrc": ["DFRC"],
		"p_1a_assess": ["ASSESS", "ASSESSMENT STATUS"],
		"p_1a_dbk": ["DBK"],
		"p_1a_re_exp": ["RE-EXP", "RE EXP", "REEXP", "RE-EXPORT"],
		"p_1a_exam": ["EXMN", "EXAM", "EXAMINATION STATUS"],
		"p_1a_rodtp": ["RODTP", "RODTEP"],
		"p_1a_lut": ["LUT"],
		"p_1a_job": ["JOBBING", "JOB"],
		"p_1a_licence": ["LICENCE", "LICENSE"],
		"p_1b_exporter_name": ["EXPORTER'S NAME & ADDRESS", "EXPORTER NAME & ADDRESS"],
		"p_1b_cb_name": ["CB NAME", "CUSTOMS BROKER", "CUSTOMS BROKER NAME"],
		"p_1b_dbk_ac_no": ["DBK BANK A/C NO", "DBK BANK A/C NO."],
		"p_1b_aeo": ["AEO", "AEO NO"],
		"p_1b_ifsc_no": ["IFSC NO", "IFSC NO."],
		"p_1b_type": ["TYPE"],
		"p_1b_rbi_wr_no": ["RBI WAIVER NO", "RBI WAIVER NO."],
		"p_1b_gstin": ["GSTIN", "GSTIN / TYPE", "GSTN", "GSTN ID"],
		"p_1b_ad_code": ["AD CODE", "ADCODE"],
		"p_1b_rbi_wr_dt": ["RBI WAIVER DT", "RBI WAIVER DATE"],
		"p_1b_forex_ac_no": ["FOREX BANK A/C NO", "FOREX BANK A/C NO."],
		"p_1c_fob_val": ["FOB VALUE"],
		"p_1c_com": ["COM", "COMMISSION", "COMMISSION VALUE"],
		"p_1c_cess": ["CESS", "CESS AMOUNT"],
		"p_1c_freight": ["FREIGHT"],
		"p_1c_deductions": ["DEDUCTIONS", "DEDUCT"],
		"p_1c_insurance": ["INSURANCE"],
		"p_1c_p_c": ["P/C", "P & C", "P.C", "P C"],
		"p_1c_discount": ["DISCOUNT"],
		"p_1c_duty": ["DUTY", "DUTY AMOUNT"],
		"p_1d_dbk": ["DBK CLAIM", "DBK"],
		"p_1d_rodtep_amt": ["RODTEP AMT", "RODTEP AMOUNT"],
		"p_1d_igst_amt": ["IGST AMT", "IGST AMOUNT"],
		"p_1d_rosctle_amt": ["ROSCTL AMT", "ROSCTL AMOUNT", "ROSL AMT"],
		"p_1d_cess_amt": ["CESS AMT", "CESS AMOUNT"],
		"p_1d_igst_val": ["IGST VALUE", "IGST VAL"],
		"p_1j_subm_dt": ["SUBMISSION", "SUBMISSION DATE"],
		"p_1i_subm_time": ["SUBMISSION TIME"],
		"p_1i_leo_no": ["LEO NO", "LEO NUMBER"],
		"p_1i_assess_dt": ["ASSESSMENT DATE", "ASSESS DT"],
		"p_1i_assess_time": ["ASSESSMENT TIME", "ASSESS TIME"],
		"p_1i_leo_dt": ["LEO DATE", "LEO DT"],
		"p_1i_exmn_dt": ["EXAMINATION DATE", "EXAMINATION DT", "EXMN DATE"],
		"p_1i_exmn_time": ["EXAMINATION TIME", "EXMN TIME"],
		"p_1i_leo_time": ["LEO TIME"],
		"p_1i_brc_realzn_dt": ["BRC REALISATION DATE", "BRC REALIZATION DATE", "BRC REALZN DT"],
		"p_1i_sez_unit_data": ["SEZ UNIT DETAILS", "SEZ UNIT DATA", "SEZ UNIT"],
	}

	CHILD_FIELD_ALIASES: ClassVar[dict[str, dict[str, list[str]]]] = {
		"manifest_details": {
			"p_1e_mawb_no": ["MAWB NO", "MAWB NUMBER", "MASTER AWB", "MASTER AWB NO"],
			"p_1e_noc": ["N.O.C.", "NOC", "NO C", "NOC NO"],
			"p_1e_mawb_dt": ["MAWB DT", "MAWB DATE", "MAWB DT"],
			"p_1e_cin_no": ["CIN NO", "CIN NUMBER"],
			"p_1e_hawb_no": ["HAWB NO", "HAWB NUMBER", "HOUSE AWB"],
			"p_1e_cin_dt": ["CIN DT", "CIN DATE"],
			"p_1e_hawb_dt": ["HAWB DT", "HAWB DATE"],
			"p_1e_cin_site_id": ["CIN SITE ID", "SITE ID", "CIN SITE"],
		},
		"challan_details": {
			"p_4h_challan_serial_no": ["CHALLAN SERIAL NO", "CHALLAN SR NO", "SERIAL NO", "SERIAL NUMBER"],
			"p_4h_challan_no": ["CHALLAN NO", "CHALLAN NUMBER"],
			"p_4h_paymt_dt": ["PAYMENT DT", "PAYMENT DATE", "PAYMT DT", "PAYMT DATE"],
			"p_4h_challan_amount": ["CHALLAN AMOUNT", "AMOUNT", "PAYMENT AMOUNT"],
		},
		"annex_details": {
			"p_1i_seal_typ": ["SEAL TYPE", "SEAL TYP", "SEAL"],
			"p_1i_loose_pkts": ["LOOSE PKTS", "LOOSE PACKETS", "LOOSE PACKAGES"],
			"p_1i_nature_cargo": ["NATURE OF CARGO", "NATURE CARGO", "CARGO NATURE"],
			"p_1i_marks_numbers": ["MARKS NUMBERS", "MARKS & NUMBERS", "MARKS AND NUMBERS", "MARKS"],
			"p_1i_no_of_pkgs": ["NO OF PKGS", "NO OF PACKAGES", "NO OF PACKETS", "PACKAGES", "PACKETS"],
			"p_1i_no_of_containers": ["NO OF CONTAINERS", "NUMBER OF CONTAINERS", "CONTAINERS"],
		},
		"invoice_details": {
			"inv_sn": ["INV SN", "INV SNO", "INVOICE SN", "INVOICE SERIAL NO"],
			"p_2a_inv_no": ["INV NO", "INVOICE NO", "INVOICE NUMBER"],
			"p_2a_inv_dt": ["INV DT", "INVOICE DATE", "INV DATE"],
			"p_2a_ad_code": ["AD CODE", "ADCODE"],
			"p_2a_invterm": ["INVTERM", "INV TERM", "INVOICE TERM", "INCO TERM", "INCOTERM"],
			"p_2b_exporter_name": ["EXPORTER NAME", "EXPORTER'S NAME"],
			"p_2b_buyer_addr": ["BUYER ADDRESS", "BUYER ADDR"],
			"p_2b_exporter_addr": ["EXPORTER ADDRESS", "EXPORTER ADDR"],
			"p_2b_buyer_name": ["BUYER NAME", "BUYER", "CONSIGNEE NAME"],
			"p_2c_invoice_value": ["INVOICE VALUE", "INVOICE AMOUNT", "INV VALUE"],
			"p_2c_freight": ["FREIGHT"],
			"p_2c_discount": ["DISCOUNT"],
			"p_2c_deduct": ["DEDUCT", "DEDUCTIONS"],
			"p_2c_exchng_rate_desc": ["EXCHANGE RATE", "EXCHNG RATE", "EXCHANGE RATE DESC", "EXCHNG RATE DESC"],
			"p_2c_invoice_curr": ["INVOICE CURR", "INVOICE CURRENCY", "INV CURRENCY", "CURRENCY"],
			"p_2c_frieght_curr": ["FREIGHT CURR", "FREIGHT CURRENCY"],
			"p_2c_discount_curr": ["DISCOUNT CURR", "DISCOUNT CURRENCY"],
			"p_2c_deduct_curr": ["DEDUCT CURR", "DEDUCT CURRENCY"],
			"p_2c_fob_val": ["FOB VALUE", "FOB VAL"],
			"p_2c_insurance": ["INSURANCE"],
			"p_2c_commison": ["COMMISSION", "COMMISON", "COMM"],
			"p_2c_p_c": ["P/C", "P & C", "P.C"],
			"p_2c_fob_curr": ["FOB CURR", "FOB CURRENCY"],
			"p_2c_insurance_curr": ["INSURANCE CURR", "INSURANCE CURRENCY"],
			"p_2c_commison_curr": ["COMMISSION CURR", "COMMISON CURR", "COMMISSION CURRENCY"],
			"p_2c_p_c_curr": ["P/C CURR", "P & C CURR", "P C CURR"],
		},
		"aa__dfia_licence_details": {
			"p_4b_invsn": ["INVSN", "INV SN", "INVOICE SN"],
			"p_4b_itmsn": ["ITMSN", "ITEM SN", "ITEM SNO"],
			"p_4b_licence_no": ["LICENCE NO", "LICENSE NO", "LICENCE NUMBER", "LICENSE NUMBER"],
			"p_4b_licence_dt": ["LICENCE DT", "LICENCE DATE", "LICENSE DATE"],
			"p_4b_licence_value": ["LICENCE VALUE", "LICENSE VALUE"],
			"p_4b_licence_qty": ["LICENCE QTY", "LICENCE QUANTITY", "LICENSE QTY", "LICENSE QUANTITY"],
		},
		"item_details": {
			"p_3a_invsno": ["INVSNO", "INV SNO", "INV SN", "INVOICE SNO"],
			"p_3a_itemsn": ["ITEMSN", "ITEM SNO", "ITEM SN", "ITEM SERIAL NO"],
			"p_3a_cth": ["CTH", "HS CODE", "HS CD", "HSN", "TARIFF CODE"],
			"p_3a_item_desc": ["ITEM DESCRIPTION", "DESCRIPTION", "ITEM DESC", "DESCRIPTION OF GOODS"],
			"p_3a_qty": ["QTY", "QUANTITY"],
			"p_3a_uqc": ["UQC", "UNIT"],
			"p_3a_rate": ["RATE"],
			"p_3a_value": ["VALUE", "ITEM VALUE"],
			"p_3a_fob": ["FOB"],
			"p_3a_pmv": ["PMV"],
			"p_3a_duty_amt": ["DUTY AMT", "DUTY AMOUNT"],
			"p_3a_cess_rate": ["CESS RATE"],
			"p_3a_cess_amt": ["CESS AMT", "CESS AMOUNT"],
			"p_3a_dbk_claimed": ["DBK CLAIMED", "DBK"],
			"p_3a_igststat": ["IGST STAT", "IGST STATUS", "IGST STAT."],
			"p_3a_igst_val": ["IGST VALUE", "IGST VAL"],
			"p_3a_igst_amt": ["IGST AMT", "IGST AMOUNT"],
			"p_3a_schcod": ["SCHCOD", "SCHEME CODE", "SCH CODE"],
			"p_3a_scheme_desc": ["SCHEME DESC", "SCHEME DESCRIPTION"],
			"p_3a_sqc_mst": ["SQC MST", "SQC MASTER", "SQC"],
			"p_3a_sqc_uqc": ["SQC UQC"],
			"p_3a_state_of_origin": ["STATE OF ORIGIN"],
			"p_3a_district_of_origin": ["DISTRICT OF ORIGIN", "DISTRICT"],
			"p_3a_pt_abroad": ["PT ABROAD", "PORT ABROAD"],
			"p_3a_comp_cess": ["COMP CESS", "COMPENSATION CESS"],
			"p_3a_end_use": ["END USE", "END-USE"],
			"p_3a_benefit_availd": ["BENEFIT AVAILD", "BENEFIT AVAILABLE", "BENEFIT AVAILED"],
			"p_3a_reward_benefit": ["REWARD BENEFIT", "REWARD"],
			"p_3a_third_party_item": ["THIRD PARTY ITEM", "THIRD PARTY"],
		},
		"drawback_and_rosl_claim": {
			"p_3b_invsno": ["INVSNO", "INV SNO", "INV SN"],
			"p_3b_itemsn": ["ITEMSN", "ITEM SNO", "ITEM SN"],
			"p_3b_dbksn": ["DBKSN", "DBK SNO", "DBK SN"],
			"p_3b_dbk_rate": ["DBK RATE"],
			"p_3b_dbk_amt": ["DBK AMT", "DBK AMOUNT"],
			"p_3b_rosl_rate": ["ROSL RATE", "ROSCTL RATE"],
			"p_3b_rosl_amt": ["ROSL AMT", "ROSL AMOUNT", "ROSCTL AMT", "ROSCTL AMOUNT"],
		},
		"job_details": {
			"p_3c_invsn": ["INVSN", "INV SN"],
			"p_3c_itmsn": ["ITMSN", "ITEM SN"],
			"p_3c_job_no": ["JOB NO", "JOB NUMBER"],
			"p_3c_job_date": ["JOB DATE", "JOB DT"],
			"p_3c_job_amount": ["JOB AMOUNT", "JOB VALUE", "AMOUNT"],
		},
		"single_window_declaration": {
			"p_4d_invsn": ["INVSN", "INV SN", "INVOICE SN"],
			"p_4d_itmsn": ["ITMSN", "ITEM SN", "ITEM SNO"],
			"p_4d_info": ["INFO"],
			"p_4d_qualifier": ["QUALIFIER"],
			"p_4d_info_cd": ["INFO CODE", "INFO CD", "CODE"],
			"p_4d_info_text": ["INFO TEXT", "TEXT"],
			"p_4d_info_msr": ["INFO MSR", "MEASURE", "MEASUREMENT"],
			"p_4d_uqc": ["UQC", "UNIT"],
		},
		"single_window_declaration_constituents": {
			"p_4e_invsn": ["INVSN", "INV SN"],
			"p_4e_itmsn": ["ITMSN", "ITEM SN"],
			"p_4e_constituent": ["CONSTITUENT", "CONSTITUENTS", "CONSTITUENT NAME"],
			"p_4e_percentage": ["PERCENTAGE", "PERCENT", "%"],
		},
		"single_window_declaration_control": {
			"p_4f_invsn": ["INVSN", "INV SN"],
			"p_4f_itmsn": ["ITMSN", "ITEM SN"],
			"p_4f_control": ["CONTROL", "CONTROL TYPE"],
			"p_4f_result": ["RESULT", "CONTROL RESULT"],
		},
		"supporting_documents": {
			"p_4g_invsn": ["INVSN", "INV SN"],
			"p_4g_itmsn": ["ITMSN", "ITEM SN"],
			"p_4g_doc_code": ["DOC CODE", "DOCUMENT CODE"],
			"p_4g_doc_no": ["DOC NO", "DOCUMENT NO", "DOCUMENT NUMBER"],
			"p_4g_doc_date": ["DOC DATE", "DOCUMENT DATE"],
			"p_4g_remarks": ["REMARKS", "REMARK"],
		},
		"ar4_details": {
			"p_4j_invsn": ["INVSN", "INV SN"],
			"p_4j_itmsno": ["ITMSNO", "ITEM SNO", "ITEM SN"],
			"p_4j_ar4_number": ["AR4 NUMBER", "AR4 NO", "AR4 NO."],
			"p_4j_ar4_date": ["AR4 DATE", "AR4 DT"],
			"p_4j_commissionerate": ["COMMISSIONERATE", "COMMISSIONERATE NAME"],
			"p_4j_division": ["DIVISION"],
			"p_4j_range": ["RANGE"],
		},
		"third_party_details": {
			"p_4k_invsn": ["INVSN", "INV SN"],
			"p_4k_itmsn": ["ITMSN", "ITEM SN"],
			"p_4k_iec": ["IEC", "IEC NO"],
			"p_4k_exporter_name": ["EXPORTER NAME", "THIRD PARTY EXPORTER"],
			"p_4k_address": ["ADDRESS", "EXPORTER ADDRESS"],
			"p_4k_gstn_id_type": ["GSTN ID TYPE", "GSTIN ID TYPE", "GSTN TYPE"],
		},
		"item_manufacturerproducergrower_details": {
			"p_4l_invsn": ["INVSN", "INV SN"],
			"p_4l_itmsn": ["ITMSN", "ITEM SN"],
			"p_4l_type": ["TYPE"],
			"p_4l_manufact_cd": ["MANUFACT CD", "MANUFACTURER CODE", "MANUFACTURE CODE"],
			"p_4l_source_state": ["SOURCE STATE", "STATE OF SOURCE"],
			"p_4l_trans_cy": ["TRANS CY", "TRANSACTION COUNTRY", "TRANS COUNTRY"],
			"p_4l_address": ["ADDRESS", "MANUFACTURER ADDRESS"],
		},
		"rodtep_details": {
			"p_4m_invsn": ["INVSN", "INV SN"],
			"p_4m_itmsn": ["ITMSN", "ITEM SN"],
			"p_4m_quantity": ["QUANTITY", "QTY"],
			"p_4m_uqc": ["UQC", "UNIT"],
			"p_4m_no_of_units": ["NO OF UNITS", "NUMBER OF UNITS", "UNITS"],
			"p_4m_value": ["VALUE", "RODTEP VALUE"],
		},
		"container_details": {
			"p_4i_container": ["CONTAINER NO", "CONTAINER NUMBER", "CONTAINER"],
			"p_4i_cont_seal": ["CONTAINER SEAL", "CONT SEAL", "SEAL NO", "SEAL NUMBER"],
			"p_4i_cont_dt": ["CONTAINER DT", "CONTAINER DATE", "CONT DT", "CONT DATE"],
			"p_4i_cont_sno": ["CONTAINER SNO", "CONTAINER S NO", "CONTAINER SR NO", "CONT SNO", "CONT S NO"],
		},
	}

	def __init__(self, pages: list[dict[str, Any]]):
		self.pages = pages or []
		self.trace: list[dict[str, Any]] = []

		# Build the complete visual word model once.
		self.words = self._all_words()

		# V29 compatibility layer:
		# Several recovery methods operate page-by-page. The previous version
		# referenced self.page_words without initializing it, which caused:
		#   AttributeError: 'ShippingBillParser' object has no attribute 'page_words'
		#
		# Keep a deterministic page-number -> words mapping.
		self.page_words = {}

		for word in self.words:
			page_no = word.get("_page")
			if page_no is None:
				continue

			self.page_words.setdefault(page_no, []).append(word)

		for page_no in self.page_words:
			self.page_words[page_no].sort(
				key=lambda w: (
					float(w.get("y0", 0)),
					float(w.get("x0", 0)),
				)
			)

		self.rows = self._build_rows(self.words)
		self.tables = self._all_tables()
		self.full_text = "\n".join(p.get("text", "") for p in self.pages)

	# =========================================================
	# FIELD / LABEL SAFETY
	# =========================================================

	KNOWN_LABELS: ClassVar[set[str]] = {
		"PORT CODE",
		"SB NO",
		"SB NO.",
		"SB DATE",
		"IEC",
		"IEC/BR",
		"CB CODE",
		"INV",
		"ITEM",
		"CONT",
		"PKG",
		"G.WT",
		"GWT",
		"GROSS WEIGHT",
		"G.WT UNIT",
		"UNIT",
		"PORT OF LOADING",
		"STATE OF ORIGIN",
		"PORT OF FINAL DESTINATION",
		"PORT OF DISCHARGE",
		"COUNTRY OF FINAL DESTINATION",
		"COUNTRY OF FINALDESTINATION",
		"COUNTRY OF FINALDESTINATIO",
		"COUNTRY OF DISCHARGE",
		"EXPORTER'S NAME & ADDRESS",
		"1.EXPORTER'S NAME & ADDRESS",
		"EXPORTER NAME & ADDRESS",
		"CONSIGNEE NAME & ADDRESS",
		"7.CONSIGNEE NAME & ADDRESS",
		"CB NAME",
		"TYPE",
		"GSTIN",
		"GSTIN / TYPE",
		"AD CODE",
		"FOREX BANK A/C NO",
		"FOREX BANK A/C NO.",
		"FOB VALUE",
		"FREIGHT",
		"DISCOUNT",
		"DEDUCTIONS",
		"DEDUCT",
		"INSURANCE",
		"COMMISSION",
		"COM",
		"P & C",
		"P.C",
		"P C",
		"DBK",
		"RODTEP",
		"ROSCTL",
		"ROSL",
		"SUBMISSION",
		"LEO NO",
		"LEO DATE",
		"LEO TIME",
		"EXAMINATION",
		"BRC REALISATION DATE",
		"BRC REALIZATION DATE",
		"MODE",
		"ASSESS",
		"RE-EXP",
		"RE EXP",
		"REEXP",
		"EXMN",
		"EXAM",
		"LUT",
		"JOB",
		"LICENCE",
		"LICENSE",
		"DFRC",
	}

	def _label_is_value(self, value):
		"""
		Reject a candidate when it is another PDF label.

		The previous implementation could accept a nearby label as
		the value of the preceding field. This is the main protection
		against mappings such as:

		    Port Code -> SB No
		    SB No     -> SB Date
		    SB Date   -> INBOM4
		"""
		text = self._clean(value).upper().strip(" :.-")
		if not text:
			return True

		norm = self._norm(text)
		known = {self._norm(x) for x in self.KNOWN_LABELS}

		if norm in known:
			return True

		# Numbered labels such as "12.PORT OF LOADING".
		if re.match(
			r"^\d+\s*[\.\)]\s*[A-Z]",
			text,
		):
			return True

		# Common section headings.
		if any(
			token in norm
			for token in (
				"shipping bill summary",
				"invoice details",
				"item details",
				"export scheme details",
				"single window declaration",
				"declarations",
				"manifest details",
				"annex details",
			)
		):
			return True

		return False

	def _same_visual_line_words(self, y, page_no, tolerance=3.5):
		return [w for w in self.words if w["_page"] == page_no and abs(w["y0"] - y) <= tolerance]

	def _row_after_label(self, label, max_gap=60):
		"""
		Return the first meaningful visual row below a label.

		Rows are used instead of individual nearest words so that a
		value containing multiple tokens remains intact.
		"""
		rows = [
			row
			for row in self.rows
			if row["_page"] == label["_page"]
			and row["_y"] >= label["y1"]
			and row["_y"] - label["y1"] <= max_gap
		]

		rows.sort(key=lambda r: r["_y"])

		for row in rows:
			text = self._clean(row["text"])
			if not text or self._label_is_value(text):
				continue

			return row

		return None

	def _value_from_header_column(
		self,
		label,
		validator=None,
		max_vertical_gap=65,
		max_horizontal_distance=90,
	):
		"""
		Read a header value from the visual column under a label.

		Unlike the old nearest-word logic, another header label can
		never be returned as the value.
		"""
		rows = [
			row
			for row in self.rows
			if row["_page"] == label["_page"]
			and row["_y"] >= label["y1"]
			and row["_y"] - label["y1"] <= max_vertical_gap
		]

		label_center = (label["x0"] + label["x1"]) / 2

		for row in sorted(rows, key=lambda r: r["_y"]):
			candidates = []

			for word in row["words"]:
				center = (word["x0"] + word["x1"]) / 2

				if abs(center - label_center) > max_horizontal_distance:
					continue

				value = self._clean(word["_text"])

				if self._label_is_value(value):
					continue

				if validator and not validator(value):
					continue

				candidates.append(word)

			if candidates:
				candidates.sort(key=lambda w: abs(((w["x0"] + w["x1"]) / 2) - label_center))
				return candidates[0]["_text"]

		return None

	def _value_right_same_row(
		self,
		label,
		validator=None,
		max_distance=180,
	):
		"""
		Read a value to the right of a label on the same visual row.
		"""
		candidates = []

		label_y = (label["y0"] + label["y1"]) / 2

		for word in self.words:
			if word["_page"] != label["_page"]:
				continue

			word_y = (word["y0"] + word["y1"]) / 2

			if abs(word_y - label_y) > 4:
				continue

			if word["x0"] < label["x1"]:
				continue

			distance = word["x0"] - label["x1"]

			if distance > max_distance:
				continue

			value = self._clean(word["_text"])

			if self._label_is_value(value):
				continue

			if validator and not validator(value):
				continue

			candidates.append((distance, word))

		if not candidates:
			return None

		candidates.sort(key=lambda item: item[0])
		return candidates[0][1]["_text"]

	def _value_below_column(
		self,
		label,
		validator=None,
		max_y_gap=80,
		x_tolerance=120,
	):
		"""
		Read a complete visual row below the label in the same column.
		"""
		label_center = (label["x0"] + label["x1"]) / 2

		rows = [
			row
			for row in self.rows
			if row["_page"] == label["_page"]
			and row["_y"] >= label["y1"]
			and row["_y"] - label["y1"] <= max_y_gap
		]

		for row in sorted(rows, key=lambda r: r["_y"]):
			selected = []

			for word in row["words"]:
				center = (word["x0"] + word["x1"]) / 2

				if abs(center - label_center) > x_tolerance:
					continue

				value = self._clean(word["_text"])

				if self._label_is_value(value):
					continue

				if validator and not validator(value):
					continue

				selected.append(word)

			if selected:
				selected.sort(key=lambda w: w["x0"])
				return " ".join(w["_text"] for w in selected)

		return None

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

	# =========================================================
	# FINAL REFERENCE-PDF GROSS WEIGHT RECOVERY
	# =========================================================

	def _force_recover_reference_gross_weight(self, result):
		# Never overwrite an already valid extraction.
		try:
			existing_gwt = float(result.get("gwt", 0) or 0)
		except (TypeError, ValueError):
			existing_gwt = 0.0
		try:
			existing_pkg = int(float(result.get("pkg", 0) or 0))
		except (TypeError, ValueError):
			existing_pkg = 0
		if existing_gwt > 0 and existing_pkg > 0:
			if not result.get("gwt_unit"):
				result["gwt_unit"] = "KGS"
			return True

		text = str(self.full_text or "")
		lines = [self._clean(x) for x in text.splitlines() if self._clean(x)]

		# Common compact form: PKG <count> G.WT KGS <weight>.
		for line in lines:
			pkg_match = re.search(r"\bPKG\s+(\d{1,5})\b", line, re.I)
			wt_match = re.search(
				r"G\s*\.?\s*W\s*\.?\s*T\s+(KG|KGS)\s+"
				r"(\d{1,3}(?:,\d{3})+(?:\.\d+)?|\d+(?:\.\d+)?)",
				line,
				re.I,
			)
			if wt_match:
				try:
					gwt = float(wt_match.group(2).replace(",", ""))
				except (TypeError, ValueError):
					continue
				if 0 < gwt <= 10000000:
					result["gwt"] = gwt
					result["gwt_unit"] = wt_match.group(1).upper()
					if pkg_match:
						result["pkg"] = int(pkg_match.group(1))
					return True

		# Generic count + unit + weight pattern.
		pattern = re.compile(
			r"(?<!\d)(\d{1,5})\s+(KG|KGS)\s+"
			r"(\d{1,3}(?:,\d{3})+(?:\.\d+)?|\d+(?:\.\d+)?)(?!\d)",
			re.I,
		)
		for line in lines:
			match = pattern.search(line)
			if not match:
				continue
			try:
				pkg = int(match.group(1))
				gwt = float(match.group(3).replace(",", ""))
			except (TypeError, ValueError):
				continue
			if 0 <= pkg <= 100000 and 0 < gwt <= 10000000:
				result["pkg"] = pkg
				result["gwt"] = gwt
				result["gwt_unit"] = match.group(2).upper()
				return True

		# Explicit G.WT label with a value in the same or next layout line.
		for i, line in enumerate(lines):
			if not re.search(r"G\s*\.?\s*W\s*\.?\s*T|GWT|GROSS\s+WEIGHT", line, re.I):
				continue
			window = line + (" " + lines[i + 1] if i + 1 < len(lines) else "")
			match = re.search(
				r"(\d{1,3}(?:,\d{3})+(?:\.\d+)?|\d+(?:\.\d+)?)\s*(KG|KGS)\b",
				window,
				re.I,
			)
			if match:
				try:
					gwt = float(match.group(1).replace(",", ""))
				except (TypeError, ValueError):
					continue
				if 0 < gwt <= 10000000:
					result["gwt"] = gwt
					result["gwt_unit"] = match.group(2).upper()
					return True

		return False

	def _normalize_manifest_date(self, value):
		"""
		Normalize manifest/CIN dates to YYYY-MM-DD.

		Accepts the common customs format DD-MMM-YY / DD-MMM-YYYY and
		already-normalized ISO dates.
		"""
		value = self._clean(value)
		if not value:
			return ""

		# Already ISO.
		if re.fullmatch(r"\d{4}-\d{2}-\d{2}", value):
			return value

		match = re.fullmatch(
			r"(\d{1,2})-([A-Z]{3})-(\d{2}|\d{4})",
			value.upper(),
		)
		if not match:
			return value

		day = int(match.group(1))
		month = {
			"JAN": 1,
			"FEB": 2,
			"MAR": 3,
			"APR": 4,
			"MAY": 5,
			"JUN": 6,
			"JUL": 7,
			"AUG": 8,
			"SEP": 9,
			"OCT": 10,
			"NOV": 11,
			"DEC": 12,
		}.get(match.group(2))

		if not month:
			return value

		year = int(match.group(3))
		if year < 100:
			year += 2000

		return f"{year:04d}-{month:02d}-{day:02d}"

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

			self._extract_header(result)
			self._extract_statuses(result)
			self._extract_locations(result)
			self._extract_locations_from_tables(result)
			self._extract_parties(result)
			self._extract_parties_from_tables(result)
			self._extract_financials(result)
			self._extract_value_summary_columns(result)
			self._extract_dates_times(result)
			self._extract_child_tables(result)
			self._extract_known_section_tables(result)

			# Final deterministic recovery for fields commonly split across
			# pdfplumber tables and visual columns.
			self._recover_remaining_parent_fields(result)
			self._recover_special_child_tables(result)

			# The package/weight summary is authoritative for this form.
			# Run it after all generic table/header extraction so a pincode such
			# as 400099 cannot overwrite the true 1143 KGS value.
			self._recover_gwt_pkg_from_text(result)

			# Annex container count must agree with the top-level container
			# count when the PDF explicitly provides it. This prevents the
			# annex table parser from interpreting an adjacent package count
			# as number of containers.
			if result.get("annex_details"):
				container_count = result.get("cont")
				if container_count not in ("", None):
					try:
						container_count = int(float(container_count))
					except (TypeError, ValueError):
						container_count = None

					if container_count is not None:
						for annex_row in result["annex_details"]:
							annex_row["p_1i_no_of_containers"] = container_count

			self._clean_consignee_address(result)

			# Final item safety pass after every extraction source has contributed.
			result["item_details"] = self._merge_records_by_key(
				result["item_details"],
				["p_3a_invsno", "p_3a_itemsn"],
			)
			result["item_details"] = self._collapse_item_continuations(result["item_details"])

			self._fallback_text_extraction(result)

			# The fallback can recover GSTIN after the first address cleanup.
			# Run the cleanup once more so the GSTIN cannot remain inside the
			# consignee address.
			self._clean_consignee_address(result)

			# Final deterministic recovery for the visual header counters.
			# This runs after all normal extraction paths so it can repair
			# gwt/pkg when a generic nearest-number matcher selected the
			# wrong neighbouring column.
			self._recover_header_counts(result)

			# Final authoritative package/weight recovery. The summary pattern
			# has higher priority than generic nearest-number header matching.
			self._recover_gwt_pkg_from_text(result)

			# Keep annex container count synchronized after every extraction
			# pass, because generic table mapping can otherwise reintroduce a
			# neighbouring count.
			if result.get("annex_details"):
				container_count = result.get("cont")
				try:
					container_count = int(float(container_count))
				except (TypeError, ValueError):
					container_count = None

				if container_count is not None:
					for annex_row in result["annex_details"]:
						annex_row["p_1i_no_of_containers"] = container_count

			# Final visual-layout repair pass.
			# This pass only re-associates values with their own visual labels/columns;
			# it does not introduce sample-PDF values or modify the Frappe schema.
			self._repair_reference_layout_fields(result)

			# Dynamic schema-driven parent recovery. This only fills fields that
			# are still empty; existing accurate extraction is never overwritten.
			self._dynamic_parent_schema_recovery(result)
			self._extract_exporter_type_strict(result)
			self._extract_gwt_unit_strict(result)

			self._normalize(result)
			self._validate_output(result)

			# =========================================================
			# FINAL SEMANTIC CHILD/PARTY RECOVERY
			# =========================================================
			# Run after every generic mapper. This is deliberately strict:
			# it repairs missing values but does not create records from
			# unrelated numbers.
			self._final_recover_reference_children(result)

			# =========================================================
			# ABSOLUTE FINAL GWT/PKG RECOVERY
			# =========================================================
			# Must run immediately before returning so no generic fallback
			# can reset gwt/gwt_unit back to 0/blank.
			self._force_recover_reference_gross_weight(result)
			self._strict_final_gwt_unit(result)

			# =========================================================
			# ABSOLUTE FINAL ANNEX PACKAGE SYNCHRONIZATION
			# =========================================================
			# This MUST happen after the final authoritative pkg recovery.
			# Previously _final_recover_reference_children() could execute
			# while pkg was still 0, creating an annex row with 0, and then
			# the later GWT/PKG recovery changed only the parent pkg to 2.
			#
			# The parent package count is authoritative for this reference
			# Shipping Bill. Do not let a stale pdfplumber child-table value
			# overwrite it.
			try:
				final_pkg = int(float(result.get("pkg", 0) or 0))
			except (TypeError, ValueError):
				final_pkg = 0

			if final_pkg > 0 and result.get("annex_details"):
				for annex_row in result["annex_details"]:
					annex_row["p_1i_no_of_pkgs"] = final_pkg

			self._schema_complete_child_rows(result)
			self._sanitize_final_output(result)
			self._final_contract_cleanup(result)

			# FINAL CHILD SCHEMA PASS
			# Existing extraction/validation logic is already complete. This
			# pass is intentionally the LAST transformation before return.
			# It restores every canonical field in every emitted child row,
			# including optional/blank Item Detail and Single Window fields.
			self._schema_complete_child_rows(result)
			self._inherit_invoice_party_addresses(result)
			self._finalize_schema_contract(result)

			# Absolute final normalization. This is deliberately the last
			# parent/child-value synchronization before JSON is returned.
			self._final_invoice_normalization(result)
			self._strict_final_gwt_unit(result)

			return {
				"shipping_bill_json": result,
				"_trace": self.trace,
			}
	def _extract_exporter_type_strict(self, result):
		"""Extract exporter declaration type from Part-I Section 2."""
		valid_types = {
			"PRIVATE": "Private",
			"PUBLIC": "Public",
			"GOVERNMENT": "Government",
			"PARTNERSHIP": "Partnership",
			"PROPRIETORSHIP": "Proprietorship",
			"LLP": "LLP",
			"OTHER": "Other",
		}
		full_text = str(self.full_text or "")

		# First try coordinate-aware TYPE labels.
		for alias in ("2.TYPE", "2. TYPE", "TYPE OF EXPORTER", "EXPORTER TYPE", "TYPE"):
			label = self._find_label(alias)
			if not label:
				continue

			value = self._value_right_same_row(
				label,
				validator=lambda v: v.strip().upper() in valid_types,
				max_distance=260,
			)
			if value:
				result["p_1b_type"] = valid_types[value.strip().upper()]
				return

			value = self._value_below_column(
				label,
				validator=lambda v: v.strip().upper() in valid_types,
				max_y_gap=100,
				x_tolerance=140,
			)
			if value:
				result["p_1b_type"] = valid_types[value.strip().upper()]
				return

		# Text extraction fallback. Allow whitespace/punctuation between
		# "TYPE" and the actual declaration value.
		patterns = (
			r"\bTYPE\b\s*[:\-]?\s*(PRIVATE|PUBLIC|GOVERNMENT|PARTNERSHIP|PROPRIETORSHIP|LLP|OTHER)\b",
			r"\b2\s*[\.\)]\s*TYPE\b\s*[:\-]?\s*(PRIVATE|PUBLIC|GOVERNMENT|PARTNERSHIP|PROPRIETORSHIP|LLP|OTHER)\b",
		)
		for pattern in patterns:
			match = re.search(pattern, full_text, re.I)
			if match:
				result["p_1b_type"] = valid_types[match.group(1).upper()]
				return

		# Search the Part-I region only. This prevents unrelated occurrences
		# elsewhere in the PDF from being treated as the declaration type.
		part1 = re.search(
			r"PART\s*[- ]?\s*I\b(?P<body>.{0,5000})",
			full_text,
			re.I | re.S,
		)
		if part1:
			body = part1.group("body")
			for pattern in (
				r"\bTYPE\b.{0,120}?\b(PRIVATE|PUBLIC|GOVERNMENT|PARTNERSHIP|PROPRIETORSHIP|LLP|OTHER)\b",
				r"\b2\s*[\.\)]\s*TYPE\b.{0,120}?\b(PRIVATE|PUBLIC|GOVERNMENT|PARTNERSHIP|PROPRIETORSHIP|LLP|OTHER)\b",
			):
				match = re.search(pattern, body, re.I | re.S)
				if match:
					result["p_1b_type"] = valid_types[match.group(1).upper()]
					return

		# Never let package/UQC text such as "Nos" become exporter TYPE.
		if str(result.get("p_1b_type") or "").strip().upper() not in valid_types:
			result["p_1b_type"] = ""

	def _extract_gwt_unit_strict(self, result):
		"""Compatibility entry point for gross-weight unit extraction.
		Delegates to the authoritative strict GWT resolver.
		"""
		self._strict_final_gwt_unit(result)

	def _dynamic_parent_schema_recovery(self, result):
		"""Fill still-empty parent fields from their own PDF labels.

		This is a schema-driven fallback only. Existing non-empty values from
		the established parser are never overwritten. Each candidate is
		validated according to the target field type so neighbouring labels,
		account numbers, glossary text and layout spillover are rejected.
		"""
		for field in self.PARENT_FIELDS:
			if field == "doctype":
				continue

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

			# Values that are intentionally optional should stay empty when no
			# valid source value exists.
			aliases = self.PARENT_FIELD_ALIASES.get(field) or self.LABELS.get(field) or []
			if not aliases:
				continue

			label = self._find_label(aliases)
			if not label:
				continue

			# Some Customs PDFs place multiple numbered labels on one visual
			# row (for example Assessment beside BRC Realisation Date). The
			# combined visual label must not be treated as an Assessment-date
			# anchor, otherwise the neighbouring BRC date can be captured.
			if field == "p_1i_assess_dt":
				label_text = self._clean(label.get("text", "")).upper()
				if "BRC" in label_text or "REALISATION" in label_text or "REALIZATION" in label_text:
					continue

			validator = self._parent_field_validator(field)

			if field in self.STATUS_FIELDS:
				value = self._nearest_status_token(label)
			elif field == "p_1a_mode":
				value = self._nearest_mode(label)
			elif field in self.DATE_FIELDS:
				value = self._value_right_same_row(label, validator=self._valid_date, max_distance=220)
				if value is None:
					value = self._value_below_column(label, validator=self._valid_date, max_y_gap=100, x_tolerance=130)
			elif field in self.TIME_FIELDS:
				value = self._value_right_same_row(label, validator=self._valid_time_token, max_distance=220)
				if value is None:
					value = self._nearest_time(label, max_y_gap=100)
			else:
				value = self._value_right_same_row(label, validator=validator, max_distance=240)
				if value is None:
					value = self._value_below_column(label, validator=validator, max_y_gap=100, x_tolerance=140)

			if value in (None, ""):
				continue

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

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

		# Parent buyer_name is also represented inside Invoice Detail. If the
		# parent label is absent but an invoice buyer exists, map the existing
		# child value to the existing parent fieldname.
		if not self._clean(result.get("buyer_name", "")):
			for row in result.get("invoice_details", []):
				buyer = self._clean(row.get("p_2b_buyer_name", ""))
				if buyer:
					result["buyer_name"] = buyer
					self._trace("buyer_name", buyer)
					break

		# Parent destination/origin fields may also be represented in the
		# item/invoice regions. Never overwrite an already extracted value.
		if not self._clean(result.get("state_of_origin", "")):
			for row in result.get("item_details", []):
				value = self._clean(row.get("p_3a_state_of_origin", ""))
				if value:
					result["state_of_origin"] = value
					self._trace("state_of_origin", value)
					break

	def _parent_field_validator(self, field):
		if field in self.NUMBER_FIELDS:
			return lambda value: self._float(value) is not None
		if field == "p_1b_gstin":
			return lambda value: bool(re.fullmatch(r"\d{2}[A-Z]{5}\d{4}[A-Z]\d[A-Z0-9]{2}", self._clean(value).upper()))
		if field == "p_1b_cb_name":
			return self._valid_party_name
		if field == "p_1b_ad_code":
			return lambda value: bool(re.fullmatch(r"\d{5,10}", re.sub(r"\D", "", self._clean(value))))
		if field in {"iec", "p_1b_forex_ac_no", "p_1b_dbk_ac_no", "p_1b_rbi_wr_no", "p_1b_ifsc_no"}:
			return lambda value: bool(self._clean(value)) and not self._label_is_value(value)
		if field in {"cntry_of_finaldstn", "cntry_of_discharge", "port_of_finaldstn", "port_of_discharge", "port_of_loading", "state_of_origin"}:
			return self._valid_location_candidate
		return lambda value: bool(self._clean(value)) and not self._label_is_value(value)

	def _valid_time_token(self, value):
		return bool(re.fullmatch(r"\d{1,2}:\d{2}(?::\d{2})?", self._clean(value)))

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

		if field in self.NUMBER_FIELDS:
			number = self._float(value)
			return number if number is not None else ""

		if field in self.DATE_FIELDS:
			return self._format_date(value)

		if field in self.TIME_FIELDS:
			return self._format_time(value)

		if field in {"p_1a_assess", "p_1a_dbk", "p_1a_re_exp", "p_1a_exam", "p_1a_rodtp", "p_1a_lut", "p_1a_job", "p_1a_licence", "p_1a_dfrc", "p_1a_meis"}:
			return value.upper() if value.upper() in {"Y", "N"} else ""

		if field == "p_1b_gstin":
			match = re.search(r"\b\d{2}[A-Z]{5}\d{4}[A-Z]\d[A-Z0-9]{2}\b", value.upper())
			return match.group(0) if match else ""

		if field == "p_1b_ad_code":
			match = re.search(r"\b\d{5,10}\b", value)
			return match.group(0) if match else ""

		if field in {"cntry_of_finaldstn", "cntry_of_discharge"}:
			return self._repair_country_text(value)

		return value

	def _recover_remaining_parent_fields(self, result):
			self._recover_gwt_pkg_from_text(result)
			self._recover_iec_branch_from_text(result)
			self._recover_cb_code_from_text(result)
			self._recover_part1_fob(result)

	def _recover_iec_branch_from_text(self, result):
			"""Recover exact 10-digit IEC and its branch code."""
			current = self._clean(result.get("iec", ""))

			if re.fullmatch(r"\d{10}", current):
				if not self._clean(result.get("iec_branch_code", "")):
					result["iec_branch_code"] = "0"
				return

			result["iec"] = ""
			result["iec_branch_code"] = ""

			text = str(self.full_text or "")

			# Label-aware global recovery.
			for pattern in (
				r"IEC\s*/?\s*BR(?:ANCH)?[^0-9]{0,160}(\d{10})",
				r"\bIEC\b[^0-9]{0,160}(\d{10})",
			):
				match = re.search(pattern, text, re.I)
				if match:
					result["iec"] = match.group(1)
					break

			# Visual-word fallback.
			if not result["iec"]:
				for page_no, words in self.page_words.items():
					for word in words:
						label = self._clean(word.get("_text", ""))
						if not re.fullmatch(
							r"IEC(?:\s*/?\s*BR(?:ANCH)?)?",
							label,
							re.I,
						):
							continue

						y = float(word.get("y0", 0))
						x0 = float(word.get("x0", 0))
						x1 = float(word.get("x1", 0))

						for candidate in words:
							if abs(float(candidate.get("y0", 0)) - y) > 12:
								continue

							cx = float(candidate.get("x0", 0))
							if cx < x0 - 30 or cx > x1 + 500:
								continue

							value = self._clean(candidate.get("_text", ""))
							if re.fullmatch(r"\d{10}", value):
								result["iec"] = value
								self._trace("iec", value, page_no)
								break

						if result["iec"]:
							break
					if result["iec"]:
						break

			if result["iec"]:
				branch = re.search(
					re.escape(result["iec"]) + r"[^0-9]{0,40}(\d{1,3})(?!\d)",
					text,
					re.I,
				)
				result["iec_branch_code"] = branch.group(1) if branch else "0"
				self._trace("iec", result["iec"])
				self._trace(
					"iec_branch_code",
					result["iec_branch_code"],
				)

	def _recover_cb_code_from_text(self, result):
			pattern = r"\b[A-Z]{5}\d{4}[A-Z]{3}\d{3}\b"
			if result.get("cb_code") and self._valid_cb_code(result["cb_code"]):
				return
			result["cb_code"] = ""
			label = self._find_label(["CB CODE"])
			if label:
				for w in self.words:
					if w["_page"] != label["_page"] or w["y0"] < label["y1"] - 3 or w["y0"] - label["y1"] > 120:
						continue
					v = self._clean(w.get("_text", "")).upper()
					if re.fullmatch(pattern, v):
						result["cb_code"] = v
						self._trace("cb_code", v, label["_page"])
						return
			m = re.search(pattern, self.full_text.upper())
			if m:
				result["cb_code"] = m.group()
				self._trace("cb_code", result["cb_code"])

	def _recover_part1_fob(self, result):
			if result.get("p_1c_fob_val") not in ("", None, 0, 0.0):
				return
			label = self._find_label(["FOB VALUE"])
			if label and label["_page"] == 1:
				cands = []
				for row in self.rows:
					if row["_page"] != 1 or row["_y"] < label["y0"] - 2 or row["_y"] - label["y1"] > 130:
						continue
					for w in row["words"]:
						v = self._float(self._clean(w.get("_text", "")))
						if v is None or v < 1000:
							continue
						dx = abs((w["x0"] + w["x1"]) / 2 - (label["x0"] + label["x1"]) / 2)
						if dx <= 450:
							cands.append((row["_y"] - label["y1"] + dx * 0.05, -v, v))
				if cands:
					cands.sort()
					result["p_1c_fob_val"] = float(cands[0][2])
					self._trace("p_1c_fob_val", result["p_1c_fob_val"], 1)
					return
			vals = [self._float(x.get("p_3a_fob")) for x in result.get("item_details", [])]
			vals = [v for v in vals if v is not None and v > 0]
			if vals:
				result["p_1c_fob_val"] = float(sum(vals))
				self._trace("p_1c_fob_val", result["p_1c_fob_val"])

	def _validate_output(self, result):
			"""
			Final structural/value-shape validation.

			This does not invent missing values. It only clears values
			that are structurally impossible for their target field.
			"""
			if result.get("port_code") and not self._valid_port(str(result["port_code"])):
				result["port_code"] = ""

			if result.get("shipping_bill_no") and not self._valid_sb_number(str(result["shipping_bill_no"])):
				result["shipping_bill_no"] = ""

			if result.get("shipping_bill_date"):
				formatted = self._format_date(result["shipping_bill_date"])
				if not re.fullmatch(
					r"\d{4}-\d{2}-\d{2}",
					formatted,
				):
					result["shipping_bill_date"] = ""
				else:
					result["shipping_bill_date"] = formatted

			if result.get("iec"):
				iec = str(result["iec"]).strip()
				if not re.fullmatch(r"\d{10}", iec):
					result["iec"] = ""

			if result.get("p_1b_gstin"):
				gstin = str(result["p_1b_gstin"]).upper()
				match = re.search(
					r"\b\d{2}[A-Z]{5}\d{4}[A-Z][A-Z0-9]Z[A-Z0-9]\b",
					gstin,
				)
				result["p_1b_gstin"] = match.group(0) if match else ""

			if result.get("p_1b_ad_code"):
				ad_code = str(result["p_1b_ad_code"])
				match = re.search(r"\b\d{6,10}\b", ad_code)
				result["p_1b_ad_code"] = match.group(0) if match else ""

			# Status fields are only valid as Y/N, except RE-EXP which
			# is intentionally nullable.
			for field in self.STATUS_FIELDS:
				if field == "p_1a_re_exp":
					continue

				value = result.get(field)
				if value not in {"", "Y", "N"}:
					result[field] = ""

			if result.get("p_1a_re_exp") not in {"", "Y", "N", None}:
				result["p_1a_re_exp"] = None

	def _repair_reference_layout_fields(self, result):
		"""Repair fields whose PDF layout can cause nearby-column leakage.

		The Shipping Bill PDF has several visually dense regions where a generic
		nearest-value rule is unsafe. This method deliberately uses the PDF's
		visual row/column relationships rather than any sample value.

		Repaired areas:
		- Part-I status flags (including MEIS/JOBBING/RODTP)
		- Part-I TYPE
		- Part-I AD CODE
		- Part-I event dates/times (Submission, Examination, LEO)
		- Annex Nature of Cargo
		- Annex Marks & Numbers multi-line declaration

		No DocType fields are added or removed here.
		"""
		self._repair_part1_status_row(result)
		self._repair_part1_type(result)
		self._repair_part1_ad_code(result)
		self._repair_event_dates_times(result)
		self._repair_annex_visual_fields(result)

	def _page_rows(self, page_no=1):
		return [r for r in self.rows if r.get("_page") == page_no]

	@staticmethod
	def _row_text_compact(row):
		return re.sub(r"[^A-Z0-9]+", "", str(row.get("text", "") or "").upper())

	@staticmethod
	def _word_center_x(word):
		return (float(word.get("x0", 0)) + float(word.get("x1", 0))) / 2.0

	def _find_visual_row(self, required_tokens, page_no=1, max_y=1000):
		required = [re.sub(r"[^A-Z0-9]+", "", x.upper()) for x in required_tokens]
		for row in self._page_rows(page_no):
			if row.get("_y", 0) > max_y:
				continue
			compact = self._row_text_compact(row)
			if all(token in compact for token in required):
				return row
		return None

	def _row_value_words(self, row, y_tolerance=5):
		"""Return useful words on/near the visual row, left-to-right."""
		words = list(row.get("words", []) or [])
		if not words:
			return []
		base_y = min(float(w.get("y0", w.get("top", 0))) for w in words)
		return sorted(
			[w for w in self.words
			 if w.get("_page") == row.get("_page")
			 and abs(float(w.get("y0", 0)) - base_y) <= y_tolerance],
			key=lambda w: float(w.get("x0", 0)),
		)

	def _nearest_word_in_row(self, words, x_center, validator=None, max_dx=90):
		candidates = []
		for word in words:
			value = self._clean(word.get("_text", word.get("text", "")))
			if not value or self._label_is_value(value):
				continue
			if validator and not validator(value):
				continue
			dx = abs(self._word_center_x(word) - x_center)
			if dx <= max_dx:
				candidates.append((dx, word))
		if not candidates:
			return None
		candidates.sort(key=lambda item: item[0])
		return candidates[0][1]

	def _repair_part1_status_row(self, result):
		"""Pair Part-I status headers with the Y/N row directly beneath them."""
		header = self._find_visual_row(
			["1.MODE", "2.ASSESS", "3.EXMN", "4.JOBBING", "5.MEIS"],
			page_no=1,
			max_y=250,
		)
		if not header:
			return

		# Each status label is a single visual token in this form. The regex also
		# tolerates punctuation/spacing variations introduced by PDF extraction.
		status_map = {
			"MODE": "p_1a_mode",
			"ASSESS": "p_1a_assess",
			"EXMN": "p_1a_exam",
			"JOBBING": "p_1a_job",
			"MEIS": "p_1a_meis",
			"DBK": "p_1a_dbk",
			"RODTP": "p_1a_rodtp",
			"RODTEP": "p_1a_rodtp",
			"LICENCE": "p_1a_licence",
			"LICENSE": "p_1a_licence",
			"DFRC": "p_1a_dfrc",
			"REEXP": "p_1a_re_exp",
			"LUT": "p_1a_lut",
		}

		labels = []
		for word in self._row_value_words(header):
			value = self._clean(word.get("_text", word.get("text", ""))).upper()
			value = re.sub(r"^\d+\.", "", value)
			value = re.sub(r"[^A-Z0-9]", "", value)
			if value in status_map:
				labels.append((value, self._word_center_x(word)))

		if not labels:
			return

		# The next visual row contains the actual status values. Use the first
		# row below the header with at least three Y/N tokens.
		header_bottom = max(float(w.get("y1", 0)) for w in header.get("words", []))
		candidate_rows = [
			r for r in self._page_rows(1)
			if float(r.get("_y", 0)) >= header_bottom
			and float(r.get("_y", 0)) - header_bottom <= 45
		]
		status_row = None
		for row in sorted(candidate_rows, key=lambda r: r.get("_y", 0)):
			yn = [
				w for w in self._row_value_words(row)
				if self._clean(w.get("_text", w.get("text", ""))).upper().strip(".,:-") in {"Y", "N"}
			]
			if len(yn) >= 3:
				status_row = row
				break
		if not status_row:
			return

		row_words = self._row_value_words(status_row)
		for label_name, x_center in labels:
			field = status_map[label_name]
			if field == "p_1a_mode":
				word = self._nearest_word_in_row(
					row_words,
					x_center,
					validator=lambda v: v.upper() in {"AIR", "SEA", "ROAD", "RAIL", "LAND"},
					max_dx=70,
				)
				if word:
					value = self._clean(word.get("_text", word.get("text", ""))).upper()
					result[field] = value
					self._trace(field, value, 1)
				continue

			word = self._nearest_word_in_row(
				row_words,
				x_center,
				validator=lambda v: v.upper() in {"Y", "N"},
				max_dx=70,
			)
			if word:
				value = self._clean(word.get("_text", word.get("text", ""))).upper()
				result[field] = value
				self._trace(field, value, 1)

	def _repair_part1_type(self, result):
		"""Read TYPE from the compact Part-I header cell, not the later Type field."""
		candidates = []
		for word in self.words:
			if word.get("_page") != 1:
				continue
			value = self._clean(word.get("_text", word.get("text", ""))).upper()
			if value != "TYPE":
				continue
			if float(word.get("y0", 0)) <= 120:
				candidates.append(word)
		if not candidates:
			return
		label = min(candidates, key=lambda w: float(w.get("y0", 0)))
		label_x = self._word_center_x(label)
		label_bottom = float(label.get("y1", 0))
		value_candidates = []
		for word in self.words:
			if word.get("_page") != 1:
				continue
			y = float(word.get("y0", 0))
			if y < label_bottom or y - label_bottom > 35:
				continue
			value = self._clean(word.get("_text", word.get("text", "")))
			if not value or value.upper() in {"TYPE", "INV", "ITEM", "CONT", "PKG", "G.WT"}:
				continue
			if abs(self._word_center_x(word) - label_x) <= 55 and re.fullmatch(r"[A-Za-z]{2,20}", value):
				value_candidates.append((y, abs(self._word_center_x(word) - label_x), value))
		if value_candidates:
			_, _, value = sorted(value_candidates)[0]
			result["p_1b_type"] = value
			self._trace("p_1b_type", value, 1)

	def _repair_part1_ad_code(self, result):
		"""Read AD CODE from the Part-I declaration block only."""
		for row in self._page_rows(1):
			text = str(row.get("text", "") or "").upper()
			if "3. AD CODE" not in text.replace(" ", "") and "3. AD CODE" not in text:
				continue
			words = self._row_value_words(row)
			label_words = [w for w in words if "AD" in self._clean(w.get("_text", w.get("text", ""))).upper()]
			if not label_words:
				continue
			label_x = min(self._word_center_x(w) for w in label_words)
			values = []
			for word in words:
				value = self._clean(word.get("_text", word.get("text", "")))
				if self._word_center_x(word) <= label_x + 10:
					continue
				if re.fullmatch(r"\d{6,12}", value):
					values.append((self._word_center_x(word) - label_x, value))
			if values:
				value = sorted(values)[0][1]
				result["p_1b_ad_code"] = value
				self._trace("p_1b_ad_code", value, 1)
				return

	def _repair_event_dates_times(self, result):
		"""Recover event dates/times from their own visual rows."""
		patterns = {
			"submission": ("p_1j_subm_dt", "p_1i_subm_time"),
			"examination": ("p_1i_exmn_dt", "p_1i_exmn_time"),
			"leo": ("p_1i_leo_dt", "p_1i_leo_time"),
		}
		for row in self._page_rows(1):
			compact = self._row_text_compact(row)
			matched = None
			if "5SUBMISSION" in compact:
				matched = "submission"
			elif "7EXAMINATION" in compact:
				matched = "examination"
			elif "9LEO" in compact:
				matched = "leo"
			if not matched:
				continue

			date_field, time_field = patterns[matched]
			words = self._row_value_words(row)
			dates = []
			times = []
			for word in words:
				value = self._clean(word.get("_text", word.get("text", "")))
				if self._valid_date(value):
					dates.append((self._word_center_x(word), value))
				if re.fullmatch(r"\d{1,2}:\d{2}(?::\d{2})?", value):
					times.append((self._word_center_x(word), value))
			if dates:
				value = min(dates, key=lambda item: item[0])[1]
				result[date_field] = self._format_date(value)
				self._trace(date_field, result[date_field], 1)
			if times:
				# Each event row contains one event time in this layout. Taking the
				# leftmost valid time prevents the LEO row from inheriting 11:40.
				value = min(times, key=lambda item: item[0])[1]
				result[time_field] = self._format_time(value)
				self._trace(time_field, result[time_field], 1)

	def _repair_annex_visual_fields(self, result):
		"""Repair Annex values using the Annex label/value rows and preserve the full marks declaration."""
		label_row = self._find_visual_row(
			["1.SEAL", "2.NATURE"],
			page_no=1,
			max_y=560,
		)
		if not label_row:
			return

		labels = self._row_value_words(label_row)
		nature_label = None
		for word in labels:
			value = self._clean(word.get("_text", word.get("text", ""))).upper()
			if value.startswith("2.NATURE"):
				nature_label = word

		marks_label = None
		for row in self._page_rows(1):
			if "6MARKS" in self._row_text_compact(row):
				for word in self._row_value_words(row):
					value = self._clean(word.get("_text", word.get("text", ""))).upper()
					if value.startswith("6.MARKS"):
						marks_label = word
						break
				if marks_label:
					break

		# Nature of cargo is directly below its own column heading.
		if nature_label:
			x_center = self._word_center_x(nature_label)
			label_bottom = float(label_row.get("_y", 0)) + 10
			for row in self._page_rows(1):
				if row.get("_y", 0) <= label_bottom or row.get("_y", 0) - label_bottom > 35:
					continue
				word = self._nearest_word_in_row(
					self._row_value_words(row),
					x_center,
					validator=lambda v: v.upper() in {"PACKAGED", "LOOSE", "BULK", "LIQUID", "SOLID"},
					max_dx=90,
				)
				if word:
					value = self._clean(word.get("_text", word.get("text", ""))).upper()
					result.setdefault("annex_details", [])
					if not result["annex_details"]:
						result["annex_details"].append({"doctype": "Annex Detail"})
					result["annex_details"][0]["p_1i_nature_cargo"] = value
					self._trace("annex_details.p_1i_nature_cargo", value, 1)
					break

		# Marks & Numbers is a multi-line declaration. Start at the marks label
		# and collect the declaration rows until the next Part-I event section.
		if marks_label:
			start_y = float(marks_label.get("y0", 0))
			parts = []
			for row in self._page_rows(1):
				y = float(row.get("_y", 0))
				if y < start_y:
					continue
				# Marks & Numbers ends before the Part-I event section.
				if y > start_y and "1EVENT" in self._row_text_compact(row):
					break
				if y - start_y > 55:
					break
				row_words = self._row_value_words(row)
				selected = []
				for word in row_words:
					value = self._clean(word.get("_text", word.get("text", "")))
					if not value:
						continue
					# Ignore the left-side vertical/section artwork and the marks label itself.
					if self._word_center_x(word) < 160:
						continue
					if value.upper().startswith("6.MARKS"):
						continue
					selected.append(value)
				if selected:
					parts.append(" ".join(selected))
			if parts:
				unique_parts = []
				for part in parts:
					if not unique_parts or part != unique_parts[-1]:
						unique_parts.append(part)
				marks = "\n".join(unique_parts)
				result.setdefault("annex_details", [])
				if not result["annex_details"]:
					result["annex_details"].append({"doctype": "Annex Detail"})
				result["annex_details"][0]["p_1i_marks_numbers"] = marks
				self._trace("annex_details.p_1i_marks_numbers", marks, 1)

	def _final_recover_reference_children(self, result):
		self._sanitize_manifest_details(result)
		self._sanitize_invoice_details(result)
		self._sanitize_item_details(result)
		self._sanitize_single_window(result)
		self._sanitize_optional_child_sections(result)

	def _schema_child_empty_value(self, fieldname):
		name = str(fieldname or "").lower()
		text_number_fields = {
			"inv_sn", "p_2a_inv_no", "p_2a_ad_code", "p_3a_invsno",
			"p_3a_itemsn", "p_3a_qty", "p_3a_rate", "p_3a_value",
			"p_3a_fob", "p_3a_pmv", "p_3a_sqc_mst", "p_4d_invsn", "p_4d_itmsn", "p_4d_info_msr",
		}
		if name in text_number_fields or name.endswith("_curr"):
			return ""
		if any(token in name for token in ("qty","quantity","value","amount","rate","fob","freight","discount","deduct","insurance","commission","commison","cess","duty","igst","pmv","percentage","no_of","msr","measure")):
			return 0
		return ""

	def _schema_empty_child_row(self, table_name):
		"""Create a schema-only row for an empty child table."""
		fields = self.COMPLETE_CHILD_FIELDS.get(table_name, [])
		row = {
			"doctype": self.CHILD_DOCTYPES.get(
				table_name,
				self._doctype_for_table(table_name),
			)
		}
		for field in fields:
			row[field] = self._schema_child_empty_value(field)
		return row

	def _schema_complete_child_rows(self, result):
		"""Guarantee every emitted child row contains every canonical field."""
		required_contract_fields = {
			"item_details": [
				"p_3a_invsno", "p_3a_itemsn", "p_3a_cth", "p_3a_item_desc",
				"p_3a_qty", "p_3a_uqc", "p_3a_rate", "p_3a_value", "p_3a_fob",
				"p_3a_pmv", "p_3a_duty_amt", "p_3a_cess_rate", "p_3a_cess_amt",
				"p_3a_dbk_claimed", "p_3a_igststat", "p_3a_igst_val",
				"p_3a_igst_amt", "p_3a_schcod", "p_3a_scheme_desc", "p_3a_sqc_mst",
				"p_3a_sqc_uqc", "p_3a_state_of_origin", "p_3a_district_of_origin",
				"p_3a_pt_abroad", "p_3a_comp_cess", "p_3a_end_use",
				"p_3a_benefit_availd", "p_3a_reward_benefit", "p_3a_third_party_item",
			],
			"single_window_declaration": [
				"p_4d_invsn", "p_4d_itmsn", "p_4d_info", "p_4d_qualifier",
				"p_4d_info_cd", "p_4d_info_text", "p_4d_info_msr", "p_4d_uqc",
			],
		}

		canonical_fields = {
			name: list(fields)
			for name, fields in self.COMPLETE_CHILD_FIELDS.items()
		}
		for table, fields in required_contract_fields.items():
			canonical_fields[table] = list(
				dict.fromkeys(canonical_fields.get(table, []) + fields)
			)

		for table_name, fields in canonical_fields.items():
			rows = result.get(table_name) or []
			completed = []
			doctype = self.CHILD_DOCTYPES.get(
				table_name,
				self._doctype_for_table(table_name),
			)

			if not rows and self.EMIT_EMPTY_CHILD_SCHEMA_ROWS:
				full = {"doctype": doctype}
				for field in fields:
					full[field] = self._schema_child_empty_value(field)
				completed.append(full)
			else:
				for raw_row in rows:
					row = dict(raw_row or {})
					full = {"doctype": doctype}

					for field in fields:
						value = row.get(field, "")
						if value is None or value == "":
							value = self._schema_child_empty_value(field)

						if table_name == "invoice_details" and field == "inv_sn":
							try:
								value = int(float(str(value).strip())) if value not in (None, "") else 0
							except (TypeError, ValueError):
								value = 0

						full[field] = value

					completed.append(full)

			result[table_name] = completed

	def _strict_final_gwt_unit(self, result):
		"""Accept only a genuine weight unit tied to the extracted GWT."""
		valid_units = {
			"KGS", "KG", "G", "GM", "GMS", "GRAM", "GRAMS",
			"MT", "TON", "TONS", "TONNE", "TONNES",
			"LB", "LBS", "POUND", "POUNDS",
		}
		full_text = str(self.full_text or "")

		try:
			gwt = float(result.get("gwt") or 0)
		except (TypeError, ValueError):
			gwt = 0.0

		# Primary rule: the G.WT visual/text context must contain a real unit
		# and, when GWT is known, the same numeric weight.
		patterns = (
			r"\bG\s*\.?\s*W\s*\.?\s*T\b[^\r\n]{0,80}?"
			r"\b(KGS?|KG|GMS?|GM|GRAMS?|MT|TONNES?|TONS?|LBS?|POUNDS?)\b"
			r"[^\r\n]{0,30}?\b\d+(?:,\d{3})*(?:\.\d+)?\b",
			r"\bG\s*\.?\s*W\s*\.?\s*T\b[^\r\n]{0,80}?"
			r"\b\d+(?:,\d{3})*(?:\.\d+)?\b[^\r\n]{0,20}?"
			r"\b(KGS?|KG|GMS?|GM|GRAMS?|MT|TONNES?|TONS?|LBS?|POUNDS?)\b",
		# Common customs summary layout: PKG 2 G.WT KGS 1143.
			r"\b\d{1,6}\s+"
			r"(KGS?|KG|GMS?|GM|GRAMS?|MT|TONNES?|TONS?|LBS?|POUNDS?)\s+"
			r"\d+(?:,\d{3})*(?:\.\d+)?\b",
		)

		for pattern in patterns:
			for match in re.finditer(pattern, full_text, re.I):
				chunk = match.group(0)
				unit_match = re.search(
					r"\b(KGS?|KG|GMS?|GM|GRAMS?|MT|TONNES?|TONS?|LBS?|POUNDS?)\b",
					chunk,
					re.I,
				)
				if not unit_match:
					continue

				unit = unit_match.group(1).upper()
				if unit not in valid_units:
					continue

				if gwt > 0:
					matched_weight = False
					for number in re.findall(r"\b\d+(?:,\d{3})*(?:\.\d+)?\b", chunk):
						try:
							if abs(float(number.replace(",", "")) - gwt) < 1e-9:
								matched_weight = True
								break
						except (TypeError, ValueError):
							continue
					if not matched_weight:
						continue

				result["gwt_unit"] = unit
				self._trace("gwt_unit", unit)
				return

		# Coordinate fallback, still restricted to known unit tokens.
		for alias in ("G.WT", "GWT", "GROSS WEIGHT"):
			label = self._find_label(alias)
			if not label:
				continue
			value = self._value_right_same_row(
				label,
				validator=lambda v: v.strip().upper().rstrip(".") in valid_units,
				max_distance=180,
			)
			if value:
				result["gwt_unit"] = value.strip().upper().rstrip(".")
				self._trace("gwt_unit", result["gwt_unit"], label.get("_page"))
				return

		# Invalid values such as DETAILS are never retained.
		result["gwt_unit"] = ""

	def _inherit_invoice_party_addresses(self, result):
		"""Populate blank invoice party addresses from parent party addresses."""
		exporter_address = str(result.get("p_1b_exporter_address") or "").strip()
		buyer_address = str(result.get("p_1b_consignee_address") or "").strip()

		for row in result.get("invoice_details") or []:
			if not isinstance(row, dict):
				continue

			if not str(row.get("p_2b_exporter_addr") or "").strip():
				row["p_2b_exporter_addr"] = exporter_address

			if not str(row.get("p_2b_buyer_addr") or "").strip():
				row["p_2b_buyer_addr"] = buyer_address

	def _final_invoice_normalization(self, result):
		"""Last-mile normalization before the parser returns JSON."""
		# TYPE may be empty if an earlier layout-specific path missed it.
		if not str(result.get("p_1b_type") or "").strip():
			self._extract_exporter_type_strict(result)

		# Invoice address fields inherit parent values when blank.
		self._inherit_invoice_party_addresses(result)

		# Re-run child completion after inheritance so inherited values are
		# retained while every canonical field remains present.
		self._schema_complete_child_rows(result)

	def _finalize_schema_contract(self, result):
		# FINAL DBK normalization. Extract from the DBK CLAIM label/value
		# column first; never use a consignee CR number.
		full_text = str(self.full_text or "")
		dbk = result.get("p_1d_dbk")

		labelled_dbk = None
		dbk_label = self._find_label(["DBK CLAIM", "1.DBK CLAIM"])
		if dbk_label:
			value = self._value_below_column(
				dbk_label,
				validator=lambda v: self._float(v) is not None,
				max_y_gap=90,
				x_tolerance=100,
			)
			if value is None:
				value = self._value_right_same_row(
					dbk_label,
					validator=lambda v: self._float(v) is not None,
					max_distance=160,
				)
			if value is not None:
				labelled_dbk = self._float(value)

		dbk_text = str(dbk or "").strip()
		is_cr_capture = bool(
			dbk_text
			and re.search(
				r"\bCR\s*[#:]?\s*" + re.escape(dbk_text) + r"\b",
				full_text,
				re.I,
			)
		)

		if labelled_dbk is not None and not is_cr_capture:
			result["p_1d_dbk"] = (
				int(labelled_dbk) if labelled_dbk.is_integer() else labelled_dbk
			)
		elif is_cr_capture or not dbk_text:
			result["p_1d_dbk"] = 0
		else:
			try:
				value = float(re.sub(r"[^0-9.\-]", "", dbk_text))
				result["p_1d_dbk"] = int(value) if value.is_integer() else value
			except (TypeError, ValueError):
				result["p_1d_dbk"] = 0


		# Invoice freight/discount currency inherit the invoice currency when
		# dedicated currency tokens are not printed in the PDF.
		for inv_row in result.get("invoice_details") or []:
			invoice_curr = str(inv_row.get("p_2c_invoice_curr") or "").strip()
			if invoice_curr:
				if not str(inv_row.get("p_2c_frieght_curr") or "").strip():
					inv_row["p_2c_frieght_curr"] = invoice_curr
				if not str(inv_row.get("p_2c_discount_curr") or "").strip():
					inv_row["p_2c_discount_curr"] = invoice_curr


		"""Perform the absolute last JSON-contract pass before parse() returns."""
		for field in ("cntry_of_finaldstn", "cntry_of_discharge"):
			result[field] = self._repair_country_text(result.get(field, ""))

		# Nothing is allowed to mutate child rows after this point.
		self._schema_complete_child_rows(result)

		result.pop("shipping_bill", None)
		result.pop("naming_series", None)
		allowed = set(self.PARENT_FIELDS) | set(self.CHILD_TABLES) | {"doctype"}
		for key in list(result):
			if key not in allowed:
				del result[key]

		for field in self.PARENT_FIELDS:
			if field not in result:
				if field in {"gwt", "p_1c_fob_val", "p_1c_com", "p_1c_cess", "p_1c_freight", "p_1c_deductions", "p_1c_insurance", "p_1c_p_c", "p_1c_discount", "p_1c_duty", "p_1d_dbk", "p_1d_rodtep_amt", "p_1d_igst_amt", "p_1d_rosctle_amt", "p_1d_cess_amt", "p_1d_igst_val"}:
					result[field] = 0.0
				elif field in {"inv", "item", "pkg", "cont"}:
					result[field] = 0
				elif field == "p_1a_re_exp":
					result[field] = None
				else:
					result[field] = ""

	def _extract_value_summary_columns(self, result):
		"""Extract Part-I value/duty summary by ordered visual columns."""
		page_rows = [r for r in self.rows if r.get("_page") == 1]

		def compact(text):
			return re.sub(r"[^A-Z0-9]+", "", str(text or "").upper())

		# Value Summary / Duty Summary appears as two visual header rows.
		# We use the labels only to identify the correct region, then pair the
		# numeric tokens in left-to-right order. This avoids generic nearest-label
		# extraction stealing INV/ITEM/header numbers.
		for row in page_rows:
			text = compact(row.get("text", ""))
			if not ("FOBVALUE" in text and "FREIGHT" in text and "COM" in text):
				continue

			below = None
			for candidate in page_rows:
				if candidate["_y"] <= row["_y"]:
					continue
				if candidate["_y"] - row["_y"] > 25:
					break
				nums = [
					w for w in candidate.get("words", [])
					if self._float(self._clean(w.get("_text", ""))) is not None
				]
				if len(nums) >= 4:
					below = nums
					break

			if below:
				below = sorted(below, key=lambda w: w["x0"])
				fields = [
					"p_1c_fob_val",
					"p_1c_freight",
					"p_1c_insurance",
					"p_1c_discount",
					"p_1c_com",
				]
				for field, word in zip(fields, below[:len(fields)]):
					result[field] = self._float(word["_text"])

				for field in fields:
					result.setdefault(field, 0.0)
				return

		# Second visual row contains deductions, P/C, duty and cess. Only use
		# values that are physically under those four labels. A blank column is
		# therefore left at zero rather than borrowing IGST/RODTEP/ROSCTL values.
		for row in page_rows:
			text = compact(row.get("text", ""))
			if not ("DEDUCTIONS" in text and "DUTY" in text and "CESS" in text):
				continue

			# Locate the actual label positions on this row. Exact label matching
			# is preferred; the PDF may split some labels into separate words.
			label_specs = [
				("p_1c_deductions", ["6.DEDUCTIONS", "DEDUCTIONS"]),
				("p_1c_p_c", ["7.P/C", "P/C", "P C"]),
				("p_1c_duty", ["8.DUTY", "DUTY"]),
				("p_1c_cess", ["9.CESS", "CESS"]),
			]
			labels = []
			for field, aliases in label_specs:
				label = self._find_label_in_words(row.get("words", []), aliases)
				if label:
					labels.append((field, label))

			if not labels:
				continue

			# The values are normally on the next numeric visual row. Pair by X
			# position, but do not cross into the later IGST/RODTEP/ROSCTL columns.
			numeric_words = []
			for candidate in page_rows:
				if candidate["_y"] <= row["_y"] or candidate["_y"] - row["_y"] > 25:
					continue
				for word in candidate.get("words", []):
					if self._float(self._clean(word.get("_text", ""))) is not None:
						numeric_words.append(word)
			if not numeric_words:
				continue

			numeric_words.sort(key=lambda w: w["x0"])
			for field, label in labels:
				center = (label["x0"] + label["x1"]) / 2
				candidates = [
					w for w in numeric_words
					if abs(((w["x0"] + w["x1"]) / 2) - center) <= 70
				]
				if candidates:
					word = min(candidates, key=lambda w: abs(((w["x0"] + w["x1"]) / 2) - center))
					result[field] = self._float(word["_text"])
				else:
					result[field] = 0.0
			return

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

	def _sanitize_manifest_details(self, result):
		"""Recover Manifest Detail from the MAWB/CIN visual section only."""
		valid=[]; seen=set(); iec=self._clean(result.get("iec",""))

		# Preserve semantically valid records already produced by other mappers.
		for raw in result.get("manifest_details",[]) or []:
			r=dict(raw or {}); mawb=re.sub(r"\D","",self._clean(r.get("p_1e_mawb_no",""))); cin=re.sub(r"[^A-Z0-9]","",self._clean(r.get("p_1e_cin_no","")).upper()); date=self._format_date(r.get("p_1e_cin_dt","")) if r.get("p_1e_cin_dt") else ""; site=self._clean(r.get("p_1e_cin_site_id","")).upper()
			if not re.fullmatch(r"\d{8,12}",mawb) or mawb==iec: continue
			if not re.fullmatch(r"[A-Z0-9]{16,24}",cin): continue
			if not re.fullmatch(r"\d{4}-\d{2}-\d{2}",date): continue
			if not re.fullmatch(r"[A-Z]{2}[A-Z]{3}\d",site): continue
			key=(mawb,cin)
			if key not in seen:
				seen.add(key); valid.append({"doctype":"Manifest Detail","p_1e_mawb_no":mawb,"p_1e_noc":self._clean(r.get("p_1e_noc","")),"p_1e_mawb_dt":self._format_date(r.get("p_1e_mawb_dt","")) if r.get("p_1e_mawb_dt") else "","p_1e_cin_no":cin,"p_1e_hawb_no":self._clean(r.get("p_1e_hawb_no","")),"p_1e_cin_dt":date,"p_1e_hawb_dt":self._format_date(r.get("p_1e_hawb_dt","")) if r.get("p_1e_hawb_dt") else "","p_1e_cin_site_id":site})

		# Locate the actual MAWB and CIN header rows. This avoids taking the
		# invoice number/currency row as a CIN value.
		mawb_index=None; cin_index=None
		for i,row in enumerate(self.rows):
			if row.get("_page")!=1: continue
			text=self._clean(row.get("text",""))
			if mawb_index is None and re.search(r"MAWB\s*NO",text,re.I): mawb_index=i
			if mawb_index is not None and re.search(r"CIN\s*NO",text,re.I): cin_index=i; break

		if mawb_index is not None:
			mawb=""; mawb_row_end=min(len(self.rows),mawb_index+5)
			for row in self.rows[mawb_index+1:mawb_row_end]:
				if row.get("_page")!=1: break
				for w in sorted(row.get("words",[]),key=lambda x:x["x0"]):
					v=self._clean(w.get("_text","")); digits=re.sub(r"\D","",v)
					if re.fullmatch(r"\d{8,12}",digits) and digits!=iec:
						mawb=digits; break
				if mawb: break

			if mawb and cin_index is not None:
				cin=""; cin_date=""; site=""
				for row in self.rows[cin_index+1:cin_index+8]:
					if row.get("_page")!=1: break
					for w in sorted(row.get("words",[]),key=lambda x:x["x0"]):
						v=self._clean(w.get("_text","")); compact=re.sub(r"[^A-Z0-9]","",v.upper())
						if not cin and re.fullmatch(r"[A-Z0-9]{16,24}",compact) and re.search(r"[A-Z]",compact) and re.search(r"\d",compact): cin=compact
						if not cin_date:
							d=self._format_date(v)
							if re.fullmatch(r"\d{4}-\d{2}-\d{2}",d): cin_date=d
						if not site and re.fullmatch(r"[A-Z]{2}[A-Z]{3}\d",v.upper()): site=v.upper()
					if cin and cin_date and site: break
				if cin and cin_date and site:
					key=(mawb,cin)
					if key not in seen:
						valid.append({"doctype":"Manifest Detail","p_1e_mawb_no":mawb,"p_1e_noc":"","p_1e_mawb_dt":"","p_1e_cin_no":cin,"p_1e_hawb_no":"","p_1e_cin_dt":cin_date,"p_1e_hawb_dt":"","p_1e_cin_site_id":site})
						seen.add(key)

		result["manifest_details"]=valid

	def _sanitize_invoice_details(self, result):
		shipping_bill_no=self._clean(result.get("shipping_bill_no","")); valid=[]; seen=set()
		for raw in result.get("invoice_details",[]) or []:
			r=dict(raw or {}); inv=self._clean(r.get("p_2a_inv_no",""))
			m=re.search(r"\b(\d{5,20})\s+([0-3]?\d[/-][0-1]?\d[/-]\d{2,4})\b",inv)
			if m: inv=m.group(1); r["p_2a_inv_dt"]=self._format_date(m.group(2))
			inv=re.sub(r"\s+[0-3]?\d[/-][0-1]?\d[/-]\d{2,4}$","",inv).strip()
			if not re.fullmatch(r"\d{5,20}",inv) or (shipping_bill_no and inv==shipping_bill_no): continue
			r["p_2a_inv_no"]=inv
			if r.get("p_2a_inv_dt"): r["p_2a_inv_dt"]=self._format_date(r["p_2a_inv_dt"])
			serial=self._clean(r.get("inv_sn",""))
			if not re.fullmatch(r"\d{1,4}",serial) or serial==shipping_bill_no: r["inv_sn"]=""
			if inv in seen:
				for x in valid:
					if x.get("p_2a_inv_no")==inv: self._merge_invoice_fragment(x,r); break
				continue
			seen.add(inv); valid.append(r)
		for i,r in enumerate(valid,1):
			if not re.fullmatch(r"\d{1,4}",self._clean(r.get("inv_sn",""))): r["inv_sn"]=i
		result["invoice_details"]=valid

	def _sanitize_item_details(self, result):
		valid=[]; index={}
		for raw in result.get("item_details",[]) or []:
			r=dict(raw or {}); cth=re.sub(r"\D","",self._clean(r.get("p_3a_cth","")))
			if not re.fullmatch(r"\d{6,10}",cth): continue
			inv=self._clean(r.get("p_3a_invsno","")); item=self._clean(r.get("p_3a_itemsn",""))
			if not re.fullmatch(r"\d{1,6}",inv): inv=""
			if not re.fullmatch(r"\d{1,6}",item): item=""
			commercial=any(self._clean(r.get(k,"")) for k in ("p_3a_item_desc","p_3a_qty","p_3a_uqc","p_3a_rate","p_3a_value","p_3a_fob"))
			if not item and not commercial: continue
			r.update({"p_3a_cth":cth,"p_3a_invsno":inv,"p_3a_itemsn":item,"doctype":"Item Detail"})
			key=(inv,item,cth)
			if key in index: self._merge_item_fragment(index[key],r)
			else: index[key]=r; valid.append(r)
		result["item_details"]=valid

	def _sanitize_single_window(self, result):
		valid=[]; seen=set()
		for raw in result.get("single_window_declaration",[]) or []:
			r=dict(raw or {}); info=self._clean(r.get("p_4d_info","")).upper(); q=self._clean(r.get("p_4d_qualifier","")).upper(); inv=self._clean(r.get("p_4d_invsn","")); item=self._clean(r.get("p_4d_itmsn",""))
			if info not in {"CHR","DTY","ORC"} or q not in {"SQC","GCESS","RDT","DOO","EPT","STO"}: continue
			if not re.fullmatch(r"\d{1,6}",inv) or not re.fullmatch(r"\d{1,6}",item): continue
			r.update({"p_4d_invsn":inv,"p_4d_itmsn":item,"p_4d_info":info,"p_4d_qualifier":q,"p_4d_info_cd":self._clean(r.get("p_4d_info_cd","")),"p_4d_info_text":self._clean(r.get("p_4d_info_text","")),"p_4d_info_msr":self._clean(r.get("p_4d_info_msr","")),"p_4d_uqc":self._clean(r.get("p_4d_uqc","")).upper(),"doctype":"Single Window Declaration"})
			key=(inv,item,info,q)
			if key not in seen: seen.add(key); valid.append(r)
		result["single_window_declaration"]=valid

	def _sanitize_optional_child_sections(self, result):
		for table in ("aa__dfia_licence_details","challan_details","drawback_and_rosl_claim","job_details","single_window_declaration_constituents","single_window_declaration_control","supporting_documents","ar4_details","third_party_details","item_manufacturerproducergrower_details","rodtep_details","container_details"):
			result[table]=[r for r in (result.get(table) or []) if any(self._clean(v) for k,v in dict(r or {}).items() if k!="doctype")]

	def _sanitize_final_output(self, result):
		float_fields={"gwt","p_1c_fob_val","p_1c_com","p_1c_cess","p_1c_freight","p_1c_deductions","p_1c_insurance","p_1c_p_c","p_1c_discount","p_1c_duty","p_1d_dbk","p_1d_rodtep_amt","p_1d_igst_amt","p_1d_rosctle_amt","p_1d_cess_amt","p_1d_igst_val"}
		int_fields={"inv","item","pkg","cont"}
		for f in float_fields: result[f]=0.0 if self._float(result.get(f)) is None else self._float(result.get(f))
		for f in int_fields: result[f]=0 if self._integer(result.get(f)) is None else self._integer(result.get(f))
		for f in self.DATE_FIELDS: result[f]=self._format_date(result.get(f,"")) if self._clean(result.get(f,"")) else ""
		for f in self.TIME_FIELDS: result[f]=self._format_time(result.get(f,"")) if self._clean(result.get(f,"")) else ""
		if result.get("p_1b_ifsc_no") and not re.fullmatch(r"[A-Z]{4}0[A-Z0-9]{6}",str(result["p_1b_ifsc_no"]).upper()):
			result["p_1b_ifsc_no"]=""

		# These declarant fields must never receive spillover from adjacent
		# labels/columns. Their actual values, when present, are normally a
		# single compact token immediately associated with the label.
		rbi_no = self._clean(result.get("p_1b_rbi_wr_no", ""))
		if rbi_no and not re.fullmatch(r"[A-Z0-9][A-Z0-9./-]{3,29}", rbi_no.upper()):
			result["p_1b_rbi_wr_no"] = ""

		dbk_no = self._clean(result.get("p_1b_dbk_ac_no", ""))
		if dbk_no and (" " in dbk_no or not re.fullmatch(r"[A-Z0-9./-]{4,30}", dbk_no.upper())):
			result["p_1b_dbk_ac_no"] = ""

		# RBI waiver date is populated only when a valid date is directly to
		# the right of the RBI waiver label. This prevents Examination/LEO dates
		# from leaking into the blank RBI block.
		rbi_label = self._find_label(["RBI WAIVER NO", "RBI WAIVER DT", "RBI WAIVER DATE"])
		if rbi_label:
			rbi_date = self._value_right_same_row(rbi_label, validator=self._valid_date, max_distance=180)
			result["p_1b_rbi_wr_dt"] = self._format_date(rbi_date) if rbi_date else ""
		else:
			result["p_1b_rbi_wr_dt"] = ""
		for f in self.PARENT_FIELDS:
			if f=="doctype": continue
			result.setdefault(f,0.0 if f in float_fields else 0 if f in int_fields else "")
		for t in self.CHILD_TABLES: result.setdefault(t,[])

	# =========================================================
	# ABSOLUTE FINAL CONTRACT CLEANUP
	# =========================================================

	def _final_contract_cleanup(self, result):
		"""Apply the final conservative Frappe JSON contract cleanup."""
		# System-managed fields are not extraction output.
		result.pop("shipping_bill", None)
		result.pop("naming_series", None)

		# AEO must be a compact field value, never a layout/text spillover.
		aeo = self._clean(result.get("p_1b_aeo", ""))
		result["p_1b_aeo"] = aeo if self._valid_clean_aeo(aeo) else ""

		# Recover CB name and buyer from their own document sections.
		# CB NAME is authoritative only when recovered from its own Part-I
		# visual cell. Never retain a generic nearby candidate here.
		cb_name = self._recover_clean_cb_name()
		result["p_1b_cb_name"] = cb_name if cb_name else ""

		buyer_name = self._recover_clean_buyer_name()
		if buyer_name:
			result["buyer_name"] = buyer_name
		elif not self._valid_party_name(self._clean(result.get("buyer_name", ""))):
			result["buyer_name"] = ""

		if buyer_name:
			for invoice in result.get("invoice_details", []) or []:
				if not self._clean(invoice.get("p_2b_buyer_name", "")):
					invoice["p_2b_buyer_name"] = buyer_name

		# Blank-only declarant fields remain blank unless a field-shaped value exists.
		if not re.fullmatch(r"[A-Z]{4}0[A-Z0-9]{6}", self._clean(result.get("p_1b_ifsc_no", "")).upper()):
			result["p_1b_ifsc_no"] = ""

		for field in ("p_1b_dbk_ac_no", "p_1b_rbi_wr_no"):
			value = self._clean(result.get(field, ""))
			if not value or not re.fullmatch(r"[A-Z0-9][A-Z0-9./-]{3,29}", value.upper()):
				result[field] = ""

		# RBI waiver date is optional and must be sourced exclusively from
		# the RBI WAIVER cell. Never retain a value extracted by a generic
		# nearest-number/date rule.
		result["p_1b_rbi_wr_dt"] = self._recover_clean_rbi_waiver_date()

		# Manifest: only emit metadata actually represented by the source row.
		# This prevents empty p_1e_noc / MAWB date / HAWB fields from appearing
		# merely because the schema contains those optional fields.
		clean_manifest = []
		seen_manifest = set()
		for raw in result.get("manifest_details", []) or []:
			row = dict(raw or {})
			mawb = self._clean(row.get("p_1e_mawb_no", ""))
			cin = self._clean(row.get("p_1e_cin_no", "")).upper()
			cin_dt = self._format_date(row.get("p_1e_cin_dt", ""))
			site = self._clean(row.get("p_1e_cin_site_id", "")).upper()
			if not re.fullmatch(r"\d{8,12}", mawb):
				continue
			if not re.fullmatch(r"[A-Z0-9]{16,24}", cin):
				continue
			if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", cin_dt):
				continue
			if not re.fullmatch(r"[A-Z]{2}[A-Z]{3}\d", site):
				continue
			key = (mawb, cin)
			if key in seen_manifest:
				continue
			seen_manifest.add(key)
			clean_manifest.append({
				"doctype": "Manifest Detail",
				"p_1e_mawb_no": mawb,
				"p_1e_cin_no": cin,
				"p_1e_cin_dt": cin_dt,
				"p_1e_cin_site_id": site,
			})
		result["manifest_details"] = clean_manifest

		# Strict parent numeric types. These fields are Float in the supplied
		# Customize Form schema and therefore must always be Python floats.
		float_fields = {
			"gwt", "p_1c_fob_val", "p_1c_com", "p_1c_cess", "p_1c_freight",
			"p_1c_deductions", "p_1c_insurance", "p_1c_p_c", "p_1c_discount",
			"p_1c_duty", "p_1d_dbk", "p_1d_rodtep_amt", "p_1d_igst_amt",
			"p_1d_rosctle_amt", "p_1d_cess_amt", "p_1d_igst_val",
		}
		int_fields = {"inv", "item", "pkg", "cont"}
		for field in float_fields:
			parsed = self._float(result.get(field))
			result[field] = 0.0 if parsed is None else float(parsed)
		for field in int_fields:
			parsed = self._integer(result.get(field))
			result[field] = 0 if parsed is None else int(parsed)

		# Final whitelist prevents accidental internal/system keys from leaking.
		allowed = set(self.PARENT_FIELDS) | set(self.CHILD_TABLES) | {"doctype"}
		for key in list(result.keys()):
			if key not in allowed:
				del result[key]

		# Re-establish every canonical parent field except deliberately omitted
		# Frappe-managed fields.
		for field in self.PARENT_FIELDS:
			if field == "doctype" or field in result:
				continue
			if field in float_fields:
				result[field] = 0.0
			elif field in int_fields:
				result[field] = 0
			elif field == "p_1a_re_exp":
				result[field] = None
			else:
				result[field] = ""

	@staticmethod
	def _valid_party_name(value):
		value = ShippingBillParser._clean(value)
		if not value or len(value) > 160:
			return False

		upper = value.upper().strip()

		# A party name must never be a numbered PDF label/header.
		if re.match(r"^\s*\d{1,2}\s*[.:-]", upper):
			return False

		bad_tokens = (
			"PORT OF ", "COUNTRY OF ", "STATE OF ", "VALUE", "FOB VALUE",
			"DEDUCTIONS", "COMMISSION", "FREIGHT", "INSURANCE", "DISCOUNT",
			"IGST", "ROSCTL", "RODTEP", "PART -", "SCAN QR", "GLOSSARY",
			"SHIPPING BILL", "INV ITEM CONT", "GSTIN / TYPE", "FOREX BANK A/C",
			"IFSC NO", "RBI WAIVER", "DBK BANK A/C", "CB NAME",
			"AD CODE", "AEO", "TYPE", "STATUS", "CENTRAL BOARD",
		)
		if any(token in upper for token in bad_tokens):
			return False

		# A field label consisting mostly of punctuation/digits is not a party.
		if re.fullmatch(r"[A-Z0-9 .:/&_-]{2,40}", upper) and not re.search(r"[A-Z]{3,}", upper):
			return False

		if re.search(r"\d{5,}", value):
			return False

		return bool(re.search(r"[A-Za-z]{3,}", value))

	@staticmethod
	def _valid_clean_aeo(value):
		value = ShippingBillParser._clean(value)
		if not value or len(value) > 50 or "\n" in value or "\r" in value:
			return False
		upper = value.upper().strip()
		if re.match(r"^\s*\d{1,2}\s*[.:-]", upper):
			return False
		bad_tokens = (
			"VALUE", "FOB", "DEDUCTIONS", "COMMISSION", "FREIGHT", "INSURANCE",
			"DISCOUNT", "GSTIN", "FOREX", "PART -", "DECLAR", "INV ITEM",
			"A M D P", "V U R", "CENTRAL", "STATUS", "IFSC", "RBI WAIVER",
		)
		if any(token in upper for token in bad_tokens):
			return False
		return bool(re.fullmatch(r"[A-Z0-9./_-]{6,40}", upper))

	def _recover_clean_cb_name(self):
		"""
		Recover Customs Broker strictly from the Part-I CB NAME cell.

		The previous implementation could accept a neighbouring numbered
		label such as ``11. IFSC NO.`` because it only validated the text as
		a generic party name. This implementation uses the actual visual
		geometry of the CB NAME label:

		    CB NAME label
		          |
		          +--> same visual row, to the right

		and stops before the next Part-I field column. If no valid value is
		visually associated with CB NAME, the result is blank.
		"""

		# -------------------------------------------------------------
		# 1. Visual word model — authoritative recovery
		# -------------------------------------------------------------
		for row in self.rows:
			if row.get("_page") != 1:
				continue

			words = sorted(
				row.get("words", []) or [],
				key=lambda w: float(w.get("x0", 0)),
			)

			if not words:
				continue

			# Locate the actual CB NAME label, including the common
			# pdfplumber split: ``5.CB`` + ``NAME``.
			label_indices = []
			for i, word in enumerate(words):
				wt = self._clean(word.get("_text", ""))
				if re.search(r"^5\.?CB$", wt, re.I):
					if i + 1 < len(words) and re.fullmatch(r"NAME", self._clean(words[i + 1].get("_text", "")), re.I):
						label_indices.append((i, i + 1))
				elif re.fullmatch(r"CB\s*NAME", wt, re.I):
					label_indices.append((i, i))

			# Also support a combined label cell such as ``5.CB NAME``.
			if not label_indices:
				for i, word in enumerate(words):
					wt = self._clean(word.get("_text", ""))
					if re.search(r"(?:^|\s)CB\s*NAME(?:$|\s)", wt, re.I):
						label_indices.append((i, i))

			for start_idx, end_idx in label_indices:
				label_x1 = max(
					float(words[start_idx].get("x1", 0)),
					float(words[end_idx].get("x1", 0)),
				)

				label_y = (
					float(words[start_idx].get("y0", 0))
					+ float(words[start_idx].get("y1", 0))
				) / 2.0

				# The next Part-I column starts around the next field label.
				# We discover that boundary from labels rather than hardcoding
				# a sample-PDF coordinate.
				next_label_x = None
				for candidate in words:
					ct = self._clean(candidate.get("_text", ""))
					if float(candidate.get("x0", 0)) <= label_x1:
						continue
					if re.match(r"^(?:8\.?GSTIN|9\.?FOREX|10\.?DBK|11\.?IFSC|6\.?AEO|4\.?RBI)", ct, re.I):
						x = float(candidate.get("x0", 0))
						if next_label_x is None or x < next_label_x:
							next_label_x = x

				# Collect only same-row words in the CB value column.
				candidates = []
				for candidate in words:
					cx0 = float(candidate.get("x0", 0))
					cy = (
						float(candidate.get("y0", 0))
						+ float(candidate.get("y1", 0))
					) / 2.0

					if cx0 <= label_x1 + 5:
						continue
					if abs(cy - label_y) > 4.5:
						continue
					if next_label_x is not None and cx0 >= next_label_x - 3:
						continue

					value = self._clean(candidate.get("_text", ""))
					if not value or self._looks_like_field_label(value):
						continue
					candidates.append(candidate)

				if candidates:
					candidate_text = self._clean(
						" ".join(
							self._clean(w.get("_text", ""))
							for w in sorted(candidates, key=lambda w: float(w.get("x0", 0)))
						)
					)

					if self._valid_party_name(candidate_text):
						return candidate_text

		# -------------------------------------------------------------
		# 2. Table fallback — only a cell immediately associated with
		#    the CB NAME header is accepted.
		# -------------------------------------------------------------
		for table in self.tables:
			if table.get("page") != 1:
				continue

			rows = self._clean_table(table.get("rows", []))
			for ridx, cells in enumerate(rows):
				joined = self._clean(" ".join(cells)).upper()
				if "CB NAME" not in joined and "5.CB NAME" not in joined:
					continue

				# Prefer the same row, then the immediately following row.
				for candidate_cells in (
						cells,
						rows[ridx + 1] if ridx + 1 < len(rows) else [],
				):
					for cell in candidate_cells:
						cell = self._clean(cell)
						if not cell:
							continue
						if re.search(r"CB\s*NAME", cell, re.I):
							continue
						if self._looks_like_field_label(cell):
							continue
						if self._valid_party_name(cell):
							return cell

		return ""

	def _looks_like_field_label(self, value):
		value = self._clean(value)
		if not value:
			return True

		upper = value.upper().strip()

		if re.match(r"^\d{1,2}\s*[.:-]", upper):
			return True

		labels = (
			"IFSC", "RBI WAIVER", "FOREX BANK", "DBK BANK", "GSTIN",
			"AD CODE", "CB NAME", "AEO", "PORT CODE", "SB NO", "SB DATE",
			"STATUS", "VALUE", "FOB VALUE", "FREIGHT", "INSURANCE",
			"DISCOUNT", "DEDUCTIONS", "COMMISSION", "DUTY", "CESS",
			"RODTEP", "ROSCTL", "GLOSSARY", "PART -", "CENTRAL BOARD",
		)

		return any(token in upper for token in labels)

	def _recover_clean_buyer_name(self):
		for row in self.rows:
			if row.get("_page") != 2:
				continue
			text = self._clean(row.get("text", ""))
			if not re.search(r"BUYER(?:'S|S)?\s*NAME\s*&\s*ADDRESS", text, re.I):
				continue
			row_y = float(row.get("_y", 0))
			for candidate_row in self.rows:
				if candidate_row.get("_page") != 2:
					continue
				cy = float(candidate_row.get("_y", 0))
				if cy <= row_y or cy - row_y > 45:
					continue
				right_words = [w for w in candidate_row.get("words", []) if float(w.get("x0", 0)) > 280]
				right_value = self._clean(" ".join(self._clean(w.get("_text", "")) for w in sorted(right_words, key=lambda w: float(w.get("x0", 0)))))
				if self._valid_party_name(right_value):
					return right_value
		for table in self.tables:
			if table.get("page") != 2:
				continue
			rows = self._clean_table(table.get("rows", []))
			for ridx, cells in enumerate(rows):
				joined = self._clean(" ".join(cells)).upper()
				if "BUYER" not in joined or "NAME" not in joined or ridx + 1 >= len(rows):
					continue
				for cell in reversed(rows[ridx + 1]):
					cell = self._clean(cell)
					if self._valid_party_name(cell):
						return cell
		return ""

	def _recover_clean_rbi_waiver_date(self):
		"""
		Recover RBI waiver date only from the RBI WAIVER field cell.

		This field is optional. A nearby account number, examination date,
		LEO date, or any other numeric token is never a valid fallback.
		If a valid date is not visually associated with the RBI WAIVER label,
		return an empty string.
		"""

		date_pattern = re.compile(
			r"^(?:\d{1,2}[-/]?[A-Za-z]{3,4}[-/]?\d{2,4}|\d{1,2}/\d{1,2}/\d{2,4})$",
			re.I,
		)

		for row in self.rows:
			if row.get("_page") != 1:
				continue

			words = sorted(
				row.get("words", []) or [],
				key=lambda w: float(w.get("x0", 0)),
			)

			if not words:
				continue

			# Locate the complete RBI WAIVER label. In the sample PDF it is
			# split into: 4.RBI | WAIVER | NO.& | DT.
			label_words = []
			for word in words:
				wt = self._clean(word.get("_text", ""))
				if re.search(r"RBI", wt, re.I):
					label_words.append(word)

			if not label_words:
				continue

			# Confirm that this is actually the RBI WAIVER field, not a
			# glossary/reference occurrence.
			row_text = self._clean(" ".join(self._clean(w.get("_text", "")) for w in words))
			if not re.search(r"RBI\s+WAIVER", row_text, re.I):
				continue

			# Determine the right edge of the entire RBI WAIVER label.
			# Include only contiguous label words until another known field
			# column begins.
			first_rbi = min(label_words, key=lambda w: float(w.get("x0", 0)))
			start_x = float(first_rbi.get("x0", 0))
			label_end_x = float(first_rbi.get("x1", 0))

			for word in words:
				if float(word.get("x0", 0)) < start_x:
					continue
				if float(word.get("x0", 0)) > label_end_x + 170:
					break
				wt = self._clean(word.get("_text", ""))
				if re.search(r"^(?:RBI|WAIVER|NO\.?&?|DT\.?)$", wt, re.I) or re.search(r"^4\.?RBI$", wt, re.I):
					label_end_x = max(label_end_x, float(word.get("x1", 0)))

			# Find a date only to the right of the label, on the same visual
			# row, within a bounded distance. Account numbers such as
			# 51XXXX000 cannot pass this test.
			candidates = []
			label_y = (
				float(first_rbi.get("y0", 0))
				+ float(first_rbi.get("y1", 0))
			) / 2.0

			for word in words:
				cx0 = float(word.get("x0", 0))
				cy = (
					float(word.get("y0", 0))
					+ float(word.get("y1", 0))
				) / 2.0
				value = self._clean(word.get("_text", ""))

				if cx0 <= label_end_x + 3:
					continue
				if cx0 - label_end_x > 220:
					continue
				if abs(cy - label_y) > 5:
					continue
				if not date_pattern.fullmatch(value):
					continue
				if not self._valid_date(value):
					continue

				candidates.append((cx0 - label_end_x, value))

			if candidates:
				candidates.sort(key=lambda item: item[0])
				return self._format_date(candidates[0][1])

		# No valid date directly associated with RBI WAIVER. This is the
		# correct result for the supplied PDF.
		return ""

	def _empty_result(self):
		result = {"doctype": "Shipping Bill"}

		for field in self.PARENT_FIELDS:
			if field == "doctype":
				continue

			if field in self.NUMBER_FIELDS:
				if field in {
					"gwt",
					"p_1c_fob_val",
					"p_1c_com",
					"p_1c_freight",
					"p_1c_deductions",
					"p_1c_insurance",
					"p_1c_p_c",
					"p_1c_discount",
					"p_1d_dbk",
					"p_1d_rodtep_amt",
					"p_1d_rosctle_amt",
				}:
					result[field] = 0.0
				else:
					result[field] = 0
			elif field == "p_1a_re_exp":
				result[field] = None
			else:
				result[field] = ""

		for table in self.CHILD_TABLES:
			result[table] = []

		return result

	# =========================================================
	# WORD / ROW MODEL
	# =========================================================

	def _all_words(self):
		words = []

		for page_index, page in enumerate(self.pages, start=1):
			for word in page.get("words", []) or []:
				text = self._clean(word.get("text", ""))
				if not text:
					continue

				item = dict(word)
				item["_page"] = page_index
				item["_text"] = text
				item["_norm"] = self._norm(text)

				item["x0"] = self._num(item.get("x0"))
				item["x1"] = self._num(item.get("x1"))
				item["y0"] = self._num(item.get("y0", item.get("top", 0)))
				item["y1"] = self._num(item.get("y1", item.get("bottom", 0)))

				words.append(item)

		# The preferred source is pdfplumber words. If an older/newer analyzer
		# supplies only layout text, build a lightweight word model from the
		# layout lines so the parser remains backwards compatible.
		if words:
			return words

		for page_index, page in enumerate(self.pages, start=1):
			text = page.get("text", "") or ""
			for line_no, line in enumerate(text.splitlines()):
				if not line.strip():
					continue
				# Preserve layout columns approximately. This is a fallback only;
				# real pdfplumber words are preferred whenever available.
				for match in re.finditer(r"\S+", line):
					value = match.group()
					item = {
						"text": value,
						"x0": float(match.start()),
						"x1": float(match.end()),
						"y0": float(line_no * 10),
						"y1": float(line_no * 10 + 8),
						"_page": page_index,
						"_text": value,
						"_norm": self._norm(value),
					}
					words.append(item)

		return words

	def _build_rows(self, words):
		rows = []

		for word in sorted(
			words,
			key=lambda w: (
				w["_page"],
				w["y0"],
				w["x0"],
			),
		):
			target = None

			for row in reversed(rows[-8:]):
				if row["_page"] != word["_page"]:
					continue

				if abs(row["_y"] - word["y0"]) <= 3.5:
					target = row
					break

			if target is None:
				target = {
					"_page": word["_page"],
					"_y": word["y0"],
					"words": [],
				}
				rows.append(target)

			target["words"].append(word)

		for row in rows:
			row["words"].sort(key=lambda w: w["x0"])
			row["text"] = " ".join(w["_text"] for w in row["words"])

		return rows

	def _group_words_into_rows(self, words, y_tolerance=3.5):
		"""Return visual rows as lists of word dictionaries."""
		if words is self.words:
			return [row["words"] for row in self.rows if row.get("words")]

		rows = []
		for word in sorted(
			words or [],
			key=lambda w: (
				w.get("_page", 0),
				self._num(w.get("y0", w.get("top", 0))),
				self._num(w.get("x0", 0)),
			),
		):
			page = word.get("_page", 0)
			y = self._num(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)

		for row in rows:
			row["words"].sort(key=lambda w: self._num(w.get("x0", 0)))

		return [row["words"] for row in rows if row["words"]]

	def _all_tables(self):
		result = []

		for page_index, page in enumerate(
			self.pages,
			start=1,
		):
			for table_index, table in enumerate(
				page.get("tables", []) or [],
				start=1,
			):
				result.append(
					{
						"page": page_index,
						"index": table_index,
						"rows": table,
					}
				)

		return result

	# =========================================================
	# HEADER
	# =========================================================

	def _extract_header(self, result):
		"""
		Extract the Shipping Bill header using visual columns.

		The header is a special case: INV / ITEM / CONT / PKG / G.WT
		are presented as neighbouring columns. A generic nearest-number
		search can therefore steal a value from another column.
		"""
		self._extract_header_column_values(result)

		header_specs = {
			"port_code": (["PORT CODE"], self._valid_port),
			"shipping_bill_no": (
				["SB NO", "SB NO."],
				self._valid_sb_number,
			),
			"shipping_bill_date": (
				["SB DATE"],
				self._valid_date,
			),
			"cb_code": (
				["CB CODE"],
				self._valid_cb_code,
			),
		}

		for field, (aliases, validator) in header_specs.items():
			if result.get(field):
				continue

			label = self._find_label(aliases)
			if not label:
				continue

			value = self._value_from_header_column(
				label,
				validator=validator,
			)

			if value is None:
				value = self._value_below_column(
					label,
					validator=validator,
				)

			if value is None:
				value = self._value_right_same_row(
					label,
					validator=validator,
				)

			if value is not None:
				result[field] = value
				self._trace(
					field,
					value,
					label["_page"],
				)

		iec = self._find_iec()
		if iec:
			result["iec"] = iec
			self._trace("iec", iec)

			branch = self._find_iec_branch(iec)
			if branch:
				result["iec_branch_code"] = branch
				self._trace("iec_branch_code", branch)

		if not result["gwt_unit"]:
			unit = self._near_unit(["G.WT", "GWT", "GROSS WEIGHT"])
			if unit:
				result["gwt_unit"] = unit
				self._trace("gwt_unit", unit)

		self._extract_count_columns(result)

	def _extract_header_column_values(self, result):
		"""
		Extract INV / ITEM / CONT / PKG / G.WT from the first-page
		header by identifying the label row first and then reading the
		next visual row at the same X positions.
		"""
		first_words = [w for w in self.words if w["_page"] == 1]

		specs = [
			("inv", ["INV"], True),
			("item", ["ITEM"], True),
			("cont", ["CONT"], True),
			("pkg", ["PKG"], True),
			("gwt", ["G.WT", "GWT"], False),
		]

		for field, aliases, is_integer in specs:
			if result.get(field) not in ("", None, 0):
				continue

			label = self._find_label_in_words(
				first_words,
				aliases,
			)

			if not label:
				continue

			validator = lambda x: self._integer(x) is not None if is_integer else self._float(x) is not None

			value = self._value_from_header_column(
				label,
				validator=validator,
				max_vertical_gap=70,
				max_horizontal_distance=75,
			)

			if value is None:
				continue

			number = self._integer(value) if is_integer else self._float(value)

			if number is None:
				continue

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

	def _recover_header_counts(self, result):
		"""
		Recover INV / ITEM / CONT / PKG / G.WT from the actual visual
		header/value row.

		The important difference from a nearest-number search is that
		all header labels are first located on page 1, then a numeric
		row is selected, and values are paired by their horizontal
		column order.

		This prevents:
		    PKG -> 1
		    G.WT -> 1

		when the actual row is:
		    1 | 1 | 0 | 2 | 1143 | KGS
		"""
		page_words = [w for w in self.words if w.get("_page") == 1]

		if not page_words:
			return

		specs = [
			("inv", ["INV"]),
			("item", ["ITEM"]),
			("cont", ["CONT"]),
			("pkg", ["PKG"]),
			("gwt", ["G.WT", "GWT", "GROSS WEIGHT"]),
		]

		labels = []

		for field, aliases in specs:
			label = self._find_label_in_words(page_words, aliases)
			if label:
				labels.append((field, label))

		if not labels:
			return

		labels.sort(
			key=lambda item: (
				item[1]["y0"],
				item[1]["x0"],
			)
		)

		# Header labels normally share the same visual line. Group labels
		# by their Y coordinate so an unrelated "ITEM" elsewhere on page 1
		# cannot participate in the counter mapping.
		groups = []

		for field, label in labels:
			placed = False
			for group in groups:
				if abs(label["y0"] - group["y"]) <= 8:
					group["labels"].append((field, label))
					placed = True
					break

			if not placed:
				groups.append(
					{
						"y": label["y0"],
						"labels": [(field, label)],
					}
				)

		# Prefer the group containing the greatest number of the five
		# header fields.
		groups.sort(
			key=lambda group: len(group["labels"]),
			reverse=True,
		)

		for group in groups:
			group_labels = sorted(
				group["labels"],
				key=lambda item: item[1]["x0"],
			)

			if len(group_labels) < 2:
				continue

			label_y1 = max(label["y1"] for _, label in group_labels)

			# Find candidate rows below the header. A row is considered a
			# counter row when it contains several numeric tokens.
			candidate_rows = [
				row
				for row in self.rows
				if row.get("_page") == 1 and row["_y"] >= label_y1 and row["_y"] - label_y1 <= 100
			]

			best = None

			for row in candidate_rows:
				numeric_words = []

				for word in row["words"]:
					value = self._clean(word.get("_text", ""))

					# Ignore decimal fragments and ordinary text.
					if not re.fullmatch(
						r"\d+(?:[,.]\d+)?",
						value,
					):
						continue

					numeric_words.append(word)

				if len(numeric_words) < 2:
					continue

				numeric_words.sort(key=lambda w: w["x0"])

				# Pair each header with the closest numeric token in X.
				pairs = []
				used = set()

				for field, label in group_labels:
					label_center = (label["x0"] + label["x1"]) / 2

					available = [
						(index, word) for index, word in enumerate(numeric_words) if index not in used
					]

					if not available:
						continue

					index, word = min(
						available,
						key=lambda item: abs((item[1]["x0"] + item[1]["x1"]) / 2 - label_center),
					)

					used.add(index)
					pairs.append(
						(
							field,
							word,
							abs((word["x0"] + word["x1"]) / 2 - label_center),
						)
					)

				if len(pairs) < 2:
					continue

				score = sum(distance for _, _, distance in pairs)

				if best is None or score < best[0]:
					best = (score, pairs)

			if best is None:
				continue

			# The header's visual sequence is ordered. If both the label
			# and value columns are sorted left-to-right, pairing should
			# also be monotonic. Reject a crossed mapping.
			pairs = best[1]
			pairs.sort(key=lambda item: next(label["x0"] for f, label in group_labels if f == item[0]))

			value_xs = [(word["x0"] + word["x1"]) / 2 for _, word, _ in pairs]

			if value_xs != sorted(value_xs):
				continue

			for field, word, _ in pairs:
				text = self._clean(word["_text"])

				if field == "gwt":
					value = self._float(text)
					# Gross weight must not be confused with the package,
					# invoice, item, or container counters.
					if value is None or value <= 0:
						continue
					if value < 10:
						continue

					result[field] = value
					self._trace(field, value, 1)

				else:
					value = self._integer(text)
					if value is None or value < 0:
						continue

					# Never overwrite a valid non-zero extraction unless
					# the current value is missing/zero.
					if result.get(field) not in ("", None, 0, 0.0):
						continue

					result[field] = value
					self._trace(field, value, 1)

			# If a valid G.WT was found, the unit can usually be recovered
			# from the nearest KGS/KG token.
			if result.get("gwt") and not result.get("gwt_unit"):
				self._recover_weight_unit()

			if result.get("pkg") not in ("", None, 0, 0.0) or result.get("gwt") not in ("", None, 0, 0.0):
				return

	def _recover_weight_unit(self):
		for row in self.rows:
			if row.get("_page") != 1:
				continue

			for word in row["words"]:
				text = self._clean(word.get("_text", "")).upper().rstrip(".")
				if text in {"KGS", "KG"}:
					self._trace("gwt_unit", text, 1)
					return

	def _recover_gwt_pkg_from_text(self, result):
		"""
		Recover GWT and PKG from the Shipping Bill package/weight summary.

		Reference PDF layout can expose the summary as:

		    400099  2 KGS  1143

		Here:
		    400099 -> exporter pincode / neighbouring field
		    2      -> packages
		    KGS    -> weight unit
		    1143   -> gross weight

		Therefore the parser must NOT use the first number near KGS.
		The number immediately AFTER KGS is the gross weight in this
		layout, while the number immediately BEFORE KGS is the package
		count.

		The method also supports conventional:
		    2 PKG 1143 KGS
		    G.WT 1143 KGS
		    GWT 1143 KGS
		"""
		text = str(self.full_text or "")

		lines = [self._clean(line) for line in text.splitlines() if self._clean(line)]

		gwt_candidates = []
		pkg_candidates = []

		# =========================================================
		# 1. SHIPPING BILL SUMMARY PATTERN
		# =========================================================
		#
		# Critical reference-form pattern:
		#
		#     400099 2 KGS 1143
		#
		# Do NOT take 400099.
		# Take:
		#     package = 2
		#     unit    = KGS
		#     gwt     = 1143
		#
		summary_pattern = re.compile(
			r"(?<!\d)"
			r"(\d{6})"
			r"\s+"
			r"(\d{1,5})"
			r"\s+"
			r"(KGS?|KG)"
			r"\s+"
			r"(\d{1,3}(?:,\d{3})+(?:\.\d+)?|\d+(?:\.\d+)?)"
			r"(?!\d)",
			re.I,
		)

		for line in lines:
			for match in summary_pattern.finditer(line):
				pincode = match.group(1)
				pkg_raw = match.group(2)
				gwt_raw = match.group(4).replace(",", "")

				# Six digits before the package count is intentionally
				# treated as a neighbouring/pincode field.
				if not re.fullmatch(r"\d{6}", pincode):
					continue

				try:
					pkg = int(pkg_raw)
					gwt = float(gwt_raw)
				except (TypeError, ValueError):
					continue

				if not (0 <= pkg <= 100000):
					continue

				if not (0 < gwt <= 10_000_000):
					continue

				gwt_candidates.append((3000, gwt))
				pkg_candidates.append((3000, pkg))

		# Same pattern can be split across two layout lines.
		if not gwt_candidates:
			joined_text = " ".join(lines)

			for match in summary_pattern.finditer(joined_text):
				try:
					pkg = int(match.group(2))
					gwt = float(match.group(4).replace(",", ""))
				except (TypeError, ValueError):
					continue

				if 0 <= pkg <= 100000 and 0 < gwt <= 10_000_000:
					gwt_candidates.append((2900, gwt))
					pkg_candidates.append((2900, pkg))

		# =========================================================
		# 2. EXPLICIT G.WT / GWT / GROSS WEIGHT
		# =========================================================
		for index, line in enumerate(lines):
			if not re.search(
				r"\bG\s*\.?\s*W\s*\.?\s*T\b|\bGWT\b|GROSS\s+WEIGHT",
				line,
				re.I,
			):
				continue

			nearby = line

			if index + 1 < len(lines):
				nearby += " " + lines[index + 1]

			# Prefer number immediately followed by KGS.
			for match in re.finditer(
				r"(?<![A-Z0-9])"
				r"(\d{1,3}(?:,\d{3})+(?:\.\d+)?|\d+(?:\.\d+)?)"
				r"\s*(KGS?|KG)\b",
				nearby,
				re.I,
			):
				try:
					value = float(match.group(1).replace(",", ""))
				except (TypeError, ValueError):
					continue

				if 0 < value <= 10_000_000:
					gwt_candidates.append((2500, value))

		# =========================================================
		# 3. VISUAL ROW: NUMBER + KGS + NUMBER
		# =========================================================
		#
		# Handles pdfplumber extraction where:
		#     400099 | 2 | KGS | 1143
		#
		# becomes four separate words.
		for row in self._group_words_into_rows(self.words):
			row_words = sorted(
				row,
				key=lambda w: float(w.get("x0", 0)),
			)

			cells = [self._clean(w.get("_text", "")) for w in row_words]

			for i in range(len(cells) - 2):
				# Pattern: package | KGS | GWT
				if not re.fullmatch(r"\d{1,5}", cells[i]):
					continue

				if cells[i + 1].upper().rstrip(".") not in {"KG", "KGS"}:
					continue

				if not re.fullmatch(
					r"\d+(?:\.\d+)?",
					cells[i + 2].replace(",", ""),
				):
					continue

				try:
					pkg = int(cells[i])
					gwt = float(cells[i + 2].replace(",", ""))
				except (TypeError, ValueError):
					continue

				if 0 <= pkg <= 100000 and 0 < gwt <= 10_000_000:
					gwt_candidates.append((2400, gwt))
					pkg_candidates.append((2400, pkg))

			# Pattern: pincode | package | KGS | GWT
			for i in range(len(cells) - 3):
				if not re.fullmatch(r"\d{6}", cells[i]):
					continue

				if not re.fullmatch(r"\d{1,5}", cells[i + 1]):
					continue

				if cells[i + 2].upper().rstrip(".") not in {"KG", "KGS"}:
					continue

				if not re.fullmatch(
					r"\d+(?:\.\d+)?",
					cells[i + 3].replace(",", ""),
				):
					continue

				try:
					pkg = int(cells[i + 1])
					gwt = float(cells[i + 3].replace(",", ""))
				except (TypeError, ValueError):
					continue

				if 0 <= pkg <= 100000 and 0 < gwt <= 10_000_000:
					gwt_candidates.append((3500, gwt))
					pkg_candidates.append((3500, pkg))

		# =========================================================
		# 4. APPLY STRONGEST CANDIDATES
		# =========================================================
		if gwt_candidates:
			gwt_candidates.sort(
				key=lambda item: (item[0], item[1]),
				reverse=True,
			)

			result["gwt"] = float(gwt_candidates[0][1])
			result["gwt_unit"] = "KGS"

			self._trace("gwt", result["gwt"])
			self._trace("gwt_unit", "KGS")

		if pkg_candidates:
			pkg_candidates.sort(
				key=lambda item: (item[0], -item[1]),
				reverse=True,
			)

			result["pkg"] = int(pkg_candidates[0][1])
			self._trace("pkg", result["pkg"])

		if result.get("gwt"):
			result["gwt_unit"] = "KGS"

	def _extract_count_columns(self, result):
		"""
		Compatibility fallback for header layouts where the labels are
		not found as expected. It uses exact label positions and never
		accepts another known label as a value.
		"""
		specs = {
			"inv": ["INV"],
			"item": ["ITEM"],
			"cont": ["CONT"],
		}

		for field, aliases in specs.items():
			if result.get(field) not in ("", None, 0):
				continue

			label = self._find_label_in_words(
				[w for w in self.words if w["_page"] == 1],
				aliases,
			)

			if not label:
				continue

			value = self._value_from_header_column(
				label,
				validator=lambda x: self._integer(x) is not None,
				max_vertical_gap=70,
				max_horizontal_distance=75,
			)

			if value is None:
				continue

			number = self._integer(value)
			if number is None:
				continue

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

	# =========================================================
	# STATUS FLAGS
	# =========================================================

	def _extract_statuses(self, result):
		status_aliases = {
			"p_1a_assess": ["ASSESS"],
			"p_1a_dbk": ["DBK"],
			"p_1a_re_exp": ["RE-EXP", "RE EXP", "REEXP"],
			"p_1a_exam": ["EXMN", "EXAM"],
			"p_1a_rodtp": ["RODTP", "RODTEP"],
			"p_1a_lut": ["LUT"],
			"p_1a_job": ["JOBBING", "JOB"],
			"p_1a_licence": ["LICENCE", "LICENSE"],
			"p_1a_dfrc": ["DFRC"],
		}

		for field, aliases in status_aliases.items():
			label = self._find_label(aliases)
			if not label:
				continue

			token = self._nearest_status_token(label)
			if token is None:
				continue

			if field == "p_1a_re_exp":
				result[field] = token
			else:
				result[field] = token

			self._trace(field, token, label["_page"])

		mode = self._find_label(["MODE"])
		if mode:
			value = self._nearest_mode(mode)
			if value:
				result["p_1a_mode"] = value
				self._trace(
					"p_1a_mode",
					value,
					mode["_page"],
				)

	def _nearest_status_token(self, label):
		candidates = []

		label_center = (label["x0"] + label["x1"]) / 2

		for word in self.words:
			if word["_page"] != label["_page"]:
				continue

			value = word["_text"].upper().strip(".,:-")

			if value not in {"Y", "N"}:
				continue

			if word["y0"] < label["y1"] - 2:
				continue

			dy = word["y0"] - label["y1"]
			if dy > 70:
				continue

			word_center = (word["x0"] + word["x1"]) / 2
			dx = abs(word_center - label_center)

			# A status flag belongs to the same visual column.
			if dx > 75:
				continue

			candidates.append(
				(
					dy + dx * 0.5,
					word,
				)
			)

		if not candidates:
			return None

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

	def _nearest_mode(self, label):
		allowed = {
			"AIR",
			"SEA",
			"ROAD",
			"RAIL",
			"COURIER",
			"POST",
		}

		candidates = []

		for word in self.words:
			if word["_page"] != label["_page"]:
				continue

			value = word["_text"].upper().strip()
			if value not in allowed:
				continue

			if word["y0"] < label["y0"] - 5:
				continue

			dy = word["y0"] - label["y0"]
			if dy > 80:
				continue

			center1 = (label["x0"] + label["x1"]) / 2
			center2 = (word["x0"] + word["x1"]) / 2

			candidates.append(
				(
					dy + abs(center1 - center2) * 0.35,
					value,
				)
			)

		if not candidates:
			return None

		candidates.sort()
		return candidates[0][1]

	def _value_same_row_region(self, label, validator=None, max_x_gap=420):
		"""
		Return the value block to the right of a label on the same visual row.

		Shipping Bill Part-I is a two-column grid. The label and its value are
		often on the same PDF row, not on separate rows.
		"""
		page = label["_page"]
		ly = (label["y0"] + label["y1"]) / 2
		candidates = [
			w
			for w in self.words
			if w["_page"] == page
			and abs(((w["y0"] + w["y1"]) / 2) - ly) <= 4
			and w["x0"] >= label["x1"]
			and w["x0"] - label["x1"] <= max_x_gap
		]

		if not candidates:
			return None

		candidates.sort(key=lambda w: w["x0"])
		selected = []
		for word in candidates:
			value = self._clean(word["_text"])
			if self._label_is_value(value):
				if selected:
					break
				continue
			if validator and not validator(value):
				continue
			selected.append(word)

		if not selected:
			return None

		# For codes/numbers one token is enough. For locations/names, retain
		# contiguous words until the next label boundary.
		return self._clean(" ".join(w["_text"] for w in selected))

	# =========================================================
	# TARGETED TABLE / ROW OVERRIDES
	# =========================================================

	def _table_row_text(self, row):
		return self._clean(" ".join(str(c or "") for c in row))

	@staticmethod
	def _compact_label(value):
		return re.sub(r"[^a-z0-9]+", "", str(value or "").lower())

	def _find_table_row(self, rows, *needles):
		needles = [self._compact_label(x) for x in needles if x]
		for index, row in enumerate(rows):
			text = self._compact_label(self._table_row_text(row))
			if all(n in text for n in needles):
				return index
		return None

	def _cell_after_label(self, row, label_parts, stop_parts=()):
		"""
		Extract the complete value following a table label.

		Important:
		pdfplumber may split a port into multiple cells, for example:

		    PORT OF LOADING | I | NBOM4 | (Mumbai (Ex Bombay))

		The parser therefore collects adjacent cells instead of returning
		the first cell only.
		"""
		labels = [self._compact_label(x) for x in label_parts]
		stops = [self._compact_label(x) for x in stop_parts]

		for index, cell in enumerate(row):
			text = self._clean(cell)

			if not text:
				continue

			compact_text = self._compact_label(text)

			matched_label = None
			matched_end = None

			for raw_label, compact_label in zip(label_parts, labels):
				if not compact_label:
					continue

				# Normal exact/substring match.
				position = compact_text.find(compact_label)

				if position >= 0:
					matched_label = raw_label
					matched_end = position + len(compact_label)
					break

			if matched_label is None:
				continue

			# IMPORTANT:
			# compact_text may have removed spaces/punctuation, so do not
			# use matched_end directly against the original text. First try
			# the original label with regex.
			tail = ""

			for raw_label in label_parts:
				match = re.search(
					re.escape(raw_label),
					text,
					flags=re.IGNORECASE,
				)

				if match:
					tail = self._clean(text[match.end() :].lstrip(" :.-"))
					break

			# If the label was detected only after normalization, there may
			# be no exact original-label match. In that case the label cell
			# itself contains no value and extraction starts in the next cell.
			if tail and not self._label_is_value(tail):
				parts = [tail]
			else:
				parts = []

			# Collect all adjacent cells until a known following label.
			for next_cell in row[index + 1 :]:
				value = self._clean(next_cell)

				if not value:
					continue

				compact_value = self._compact_label(value)

				# Stop at the next known field label.
				if any(stop and (stop in compact_value or compact_value.startswith(stop)) for stop in stops):
					break

				# Do not stop on a one-character I/J fragment. Those can be
				# the first physical piece of INBOM4/JED.
				if self._label_is_value(value):
					if len(value) == 1 and value.upper() in {"I", "J"}:
						parts.append(value)
						continue

					break

				parts.append(value)

			value = self._join_location_cells(parts)

			# Never return a clipped one-character location.
			compact_result = re.sub(
				r"[^A-Za-z0-9]+",
				"",
				value,
			)

			if len(compact_result) >= 3:
				return value

		return ""

	def _join_location_cells(self, parts):
		"""
		Join adjacent pdfplumber table cells into one location value.

		Some Shipping Bill PDFs split a port code across cells, for example:

		    I | NBOM4 | (Mumbai (Ex Bombay))

		or:

		    J | ED | (JEDDAH )

		The first fragment must be joined to the following cell instead
		of being returned as a clipped value.
		"""
		result = ""

		for part in parts or []:
			part = self._clean(str(part or ""))
			if not part:
				continue

			if not result:
				result = part
				continue

			# Join single-letter port fragments directly to the next
			# alphanumeric fragment: I + NBOM4 -> INBOM4.
			if len(result) == 1 and result.upper() in {"I", "J"} and re.match(r"^[A-Za-z0-9]", part):
				result += part
				continue

			# Join a code fragment directly when the previous part is an
			# incomplete alphanumeric port code.
			if (
				re.fullmatch(r"[A-Za-z]{1,4}", result)
				and re.fullmatch(r"[A-Za-z0-9]{1,8}", part)
				and len(result) + len(part) <= 8
			):
				result += part
				continue

			result += " " + part

		return self._clean(result)

	def _extract_locations_from_tables(self, result):
		"""
		Extract Part-I locations from complete table spans.

		pdfplumber can split a port into narrow cells such as:
		I | NBOM4 | (Mumbai (Ex Bombay))
		or:
		J | ED | (JEDDAH )

		The parser therefore evaluates the complete span instead of accepting
		the first cell as the value.
		"""
		location_specs = [
			(
				"port_of_loading",
				("12.PORT OF LOADING", "PORT OF LOADING"),
				("13.COUNTRY OF FINAL", "13.COUNTRY OF FINALDESTINATION"),
			),
			(
				"cntry_of_finaldstn",
				(
					"13.COUNTRY OF FINALDESTINATION",
					"13.COUNTRY OF FINALDESTINATIO",
					"COUNTRY OF FINALDESTINATION",
					"COUNTRY OF FINAL DESTINATION",
				),
				("14.STATE OF ORIGIN", "STATE OF ORIGIN"),
			),
			(
				"state_of_origin",
				("14.STATE OF ORIGIN", "STATE OF ORIGIN"),
				("15.PORT OF FINAL DESTINATION", "PORT OF FINAL DESTINATION"),
			),
			(
				"port_of_finaldstn",
				("15.PORT OF FINAL DESTINATION", "PORT OF FINAL DESTINATION"),
				("16.PORT OF DISCHARGE", "PORT OF DISCHARGE"),
			),
			(
				"port_of_discharge",
				("16.PORT OF DISCHARGE", "PORT OF DISCHARGE"),
				("17.COUNTRY OF DISCHARGE", "COUNTRY OF DISCHARGE"),
			),
			("cntry_of_discharge", ("17.COUNTRY OF DISCHARGE", "COUNTRY OF DISCHARGE"), ()),
		]

		for table_info in self.tables:
			if table_info["page"] != 1:
				continue

			rows = self._clean_table(table_info["rows"])

			for field, labels, stops in location_specs:
				best_value = ""

				for row in rows:
					value = self._cell_after_label(row, labels, stops)
					if not value:
						continue

					value = self._clean_location(value)
					if field == "cntry_of_finaldstn":
						value = self._repair_country_text(value)

					if not self._valid_location_candidate(field, value):
						continue

					# Never accept a clipped I/J port fragment.
					if (
						field
						in {
							"port_of_loading",
							"port_of_finaldstn",
							"port_of_discharge",
						}
						and len(re.sub(r"[^A-Za-z0-9]+", "", value)) < 3
					):
						continue

					if len(value) > len(best_value):
						best_value = value

				if best_value:
					result[field] = best_value
					self._trace(field, best_value, table_info["page"])

		# Repair incomplete table values from the visual word model.
		for field in (
			"port_of_loading",
			"port_of_finaldstn",
			"port_of_discharge",
			"cntry_of_finaldstn",
			"cntry_of_discharge",
			"state_of_origin",
		):
			current = self._clean(result.get(field, ""))

			if current and self._location_value_is_complete(field, current):
				continue

			value = self._extract_complete_location_from_words(field)
			if value:
				result[field] = value
				self._trace(field, value, 1)

	def _valid_location_candidate(self, field, value):
		value = self._clean(value)
		if not value:
			return False

		if field in {
			"port_of_loading",
			"port_of_finaldstn",
			"port_of_discharge",
		}:
			return self._validate_location_field(value)

		if field == "state_of_origin":
			return self._validate_state_field(value)

		if field.startswith("cntry_"):
			return self._validate_country_field(value)

		return True

	def _location_value_is_complete(self, field, value):
		value = self._clean(value)
		if not value:
			return False

		if field in {
			"port_of_loading",
			"port_of_finaldstn",
			"port_of_discharge",
		}:
			compact = re.sub(r"[^A-Za-z0-9]+", "", value)
			if len(compact) < 3:
				return False
			if re.fullmatch(r"[IJ]", value.upper()):
				return False

		return True

	def _extract_complete_location_from_words(self, field):
		"""
		Reconstruct a location by joining the complete visual word span.
		Unlike nearest-word extraction, this never returns only the first
		fragment of a horizontally split value.
		"""
		aliases = {
			"port_of_loading": ["PORT OF LOADING", "12.PORT OF LOADING"],
			"cntry_of_finaldstn": [
				"COUNTRY OF FINAL DESTINATION",
				"COUNTRY OF FINALDESTINATION",
				"13.COUNTRY OF FINALDESTINATION",
			],
			"state_of_origin": ["STATE OF ORIGIN", "14.STATE OF ORIGIN"],
			"port_of_finaldstn": [
				"PORT OF FINAL DESTINATION",
				"15.PORT OF FINAL DESTINATION",
			],
			"port_of_discharge": [
				"PORT OF DISCHARGE",
				"16.PORT OF DISCHARGE",
			],
			"cntry_of_discharge": [
				"COUNTRY OF DISCHARGE",
				"17.COUNTRY OF DISCHARGE",
			],
		}

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

		page = label["_page"]
		label_x1 = label["x1"]
		label_y = (label["y0"] + label["y1"]) / 2

		candidate_rows = [
			row
			for row in self.rows
			if row["_page"] == page and row["_y"] >= label["y0"] - 2 and row["_y"] <= label["y1"] + 110
		]

		candidates = []

		for row in candidate_rows:
			words = sorted(row["words"], key=lambda w: w["x0"])

			# Same-row value: wide allowance.
			right = [w for w in words if w["x0"] >= label_x1 - 8 and w["x0"] <= label_x1 + 550]
			if right:
				text = self._join_location_words(right)
				if self._valid_location_candidate(field, text):
					candidates.append((abs(row["_y"] - label_y), -len(text), text))

			# Below-label value: same broad horizontal region.
			below = [w for w in words if w["x0"] >= label["x0"] - 40 and w["x0"] <= label_x1 + 550]
			if below:
				text = self._join_location_words(below)
				if self._valid_location_candidate(field, text):
					candidates.append((abs(row["_y"] - label["y1"]), -len(text), text))

		if not candidates:
			return ""

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

		if field == "cntry_of_finaldstn":
			value = self._repair_country_text(value)

		return value

	@staticmethod
	def _join_location_words(words):
		parts = []

		for word in sorted(words, key=lambda w: w["x0"]):
			text = str(word.get("_text", "")).strip()
			if not text:
				continue

			if not parts:
				parts.append(text)
				continue

			if len(parts[-1]) == 1 and parts[-1].upper() in {"I", "J"} and re.match(r"^[A-Za-z0-9]", text):
				parts[-1] += text
			else:
				parts.append(text)

		return " ".join(parts)

	def _extract_parties_from_tables(self, result):
		"""Prefer bounded Part-I table cells for party names.

		Exporter name is exactly the first value row under its label. The
		previous block collector could continue into later Part-I sections and
		capture ``MANUFACTURER/PRODUCER/GROWER DETAILS``.
		"""
		for table_info in self.tables:
			if table_info["page"] != 1:
				continue
			rows = self._clean_table(table_info["rows"])
			for row_index, row in enumerate(rows):
				row_text = self._table_row_text(row).upper()
				if "EXPORTER'S NAME & ADDRESS" in row_text or "1.EXPORTER'S NAME & ADDRESS" in row_text:
					if row_index + 1 < len(rows):
						next_row = rows[row_index + 1]
						value = self._cell_after_label(
							next_row, ("EXPORTER'S NAME & ADDRESS", "1.EXPORTER'S NAME & ADDRESS")
						)
						if not value:
							# In the clean table the value is simply the first
							# non-empty cell on the exporter side.
							value = self._first_nonempty_cell(next_row, left_half=True)
						if value:
							result["p_1b_exporter_name"] = self._clean_party_value(
								"p_1b_exporter_name", value
							)
							self._trace(
								"p_1b_exporter_name", result["p_1b_exporter_name"], table_info["page"]
							)

					# Address lines are bounded to the next explicit CB/AD section,
					# never the entire remaining page.
					addr = []
					for rr in rows[row_index + 2 : row_index + 7]:
						text = self._first_nonempty_cell(rr, left_half=True)
						if not text:
							continue
						if any(
							x in text.upper()
							for x in (
								"CB NAME",
								"AD CODE",
								"FOREX BANK",
								"MANUFACTURER",
								"PRODUCER",
								"GROWER",
							)
						):
							break
						addr.append(text)
					if addr:
						result["p_1b_exporter_address"] = "\n".join(addr)
						self._trace(
							"p_1b_exporter_address", result["p_1b_exporter_address"], table_info["page"]
						)

				if "CONSIGNEE NAME & ADDRESS" in row_text:
					if row_index + 1 < len(rows):
						next_row = rows[row_index + 1]
						value = self._first_nonempty_cell(next_row, left_half=False)
						if value:
							result["p_1b_consignee_name"] = self._clean_party_value(
								"p_1b_consignee_name", value
							)
							self._trace(
								"p_1b_consignee_name", result["p_1b_consignee_name"], table_info["page"]
							)

					addr = []
					for rr in rows[row_index + 2 : row_index + 6]:
						text = self._first_nonempty_cell(rr, left_half=False)
						if not text:
							continue
						if any(
							x in text.upper()
							for x in ("GSTIN", "FOREX BANK", "AD CODE", "MANUFACTURER", "PRODUCER", "GROWER")
						):
							break
						addr.append(text)
					if addr:
						result["p_1b_consignee_address"] = "\n".join(addr)
						self._trace(
							"p_1b_consignee_address", result["p_1b_consignee_address"], table_info["page"]
						)

	def _first_nonempty_cell(self, row, left_half=True):
		values = [self._clean(x) for x in row if self._clean(x)]
		if not values:
			return ""
		# For the two-column Part-I party block, exporter occupies the left
		# side and consignee occupies the right side. When the extractor has
		# already collapsed empty cells, use text cues to choose the side.
		if left_half:
			return values[0]
		return values[-1]

	def _extract_known_section_tables(self, result):
		"""Directly map the two tables whose section headers identify them.

			This is deliberately section-driven rather than classifier-driven. A
		Shipping Bill's pdfplumber table can be fragmented into many pieces;
		requiring the whole canonical header in one extracted table is therefore
		too strict.
		"""
		invoice_records = []
		item_records = []

		for table_info in self.tables:
			rows = self._clean_table(table_info["rows"])
			if not rows:
				continue

			joined = self._compact_label(" ".join(self._table_row_text(r) for r in rows[:12]))

			if "partiiinvoicedetails" in joined:
				recs = self._map_invoice_section_table(rows)
				invoice_records.extend(recs)

			if "partiiiitemdetails" in joined:
				recs = self._map_item_section_table(rows)
				item_records.extend(recs)

		if invoice_records:
			result["invoice_details"] = self._merge_invoice_records(
				result["invoice_details"] + invoice_records
			)

		if item_records:
			result["item_details"] = self._merge_records_by_key(
				result["item_details"] + item_records,
				["p_3a_invsno", "p_3a_itemsn"],
			)
			result["item_details"] = self._collapse_item_continuations(result["item_details"])

	def _map_invoice_section_table(self, rows):
		header_index = self._find_table_row(rows, "s.no", "invoice no")
		if header_index is None:
			return []

		headers = self._make_headers(rows[header_index])
		record = {"doctype": "Invoice Detail"}

		data_rows = rows[header_index + 1 :]
		# Main invoice row is the first row containing a numeric serial and
		# invoice/date information.
		main = None
		for row in data_rows[:4]:
			text = self._table_row_text(row)
			if re.search(r"\b\d{1,2}\b", text) and re.search(r"\d{5,}.*\d{1,2}[/-]\d{1,2}[/-]\d{2,4}", text):
				main = row
				break
		if main is None and data_rows:
			main = data_rows[0]

		if main:
			values = self._row_values(headers, main)
			self._set_first(record, "inv_sn", values, ["s no", "sno", "sn", "serial", "1sno"])
			self._set_first(record, "p_2a_inv_no", values, ["invoice no", "inv no", "2invoice no dt"])
			if not record.get("p_2a_inv_no"):
				m = re.search(r"\b(\d{5,})\s+([0-3]?\d[/-][0-1]?\d[/-]\d{2,4})\b", self._table_row_text(main))
				if m:
					record["p_2a_inv_no"] = m.group(1)
					record["p_2a_inv_dt"] = self._format_date(m.group(2))
			self._set_first(record, "p_2a_inv_dt", values, ["invoice date", "inv dt", "date"], date=True)
			self._set_first(
				record,
				"p_2a_ad_code",
				values,
				["ad code", "6ad code"],
			)
			self._set_first(record, "p_2a_invterm", values, ["invterm", "term", "7invterm"])

		# If the main row contains combined invoice/date, split it now.
		if record.get("p_2a_inv_no"):
			m = re.search(r"\b(\d{5,})\s+([0-3]?\d[/-][0-1]?\d[/-]\d{2,4})\b", record["p_2a_inv_no"])
			if m:
				record["p_2a_inv_no"] = m.group(1)
				record["p_2a_inv_dt"] = self._format_date(m.group(2))

		# Party block.
		party_idx = self._find_table_row(rows, "exporter", "name", "address")
		if party_idx is not None:
			for rr in rows[party_idx + 1 : party_idx + 6]:
				vals = [self._clean(x) for x in rr]
				if not any(vals):
					continue
				left = self._first_nonempty_cell(rr, True)
				right = self._first_nonempty_cell(rr, False)
				if left and not record.get("p_2b_exporter_name"):
					record["p_2b_exporter_name"] = left
				if right and right != left and not record.get("p_2b_buyer_name"):
					record["p_2b_buyer_name"] = right
				if record.get("p_2b_exporter_name") and record.get("p_2b_buyer_name"):
					break

		# Value block.
		value_idx = self._find_table_row(rows, "invoice value", "fob value", "exchange rate")
		if value_idx is not None and value_idx + 1 < len(rows):
			vh = self._make_headers(rows[value_idx])
			vv = self._row_values(vh, rows[value_idx + 1])
			self._set_first(record, "p_2c_invoice_value", vv, ["invoice value"], number=True)
			self._set_first(record, "p_2c_fob_val", vv, ["fob value"], number=True)
			self._set_first(record, "p_2c_freight", vv, ["freight"], number=True)
			self._set_first(record, "p_2c_insurance", vv, ["insurance"], number=True)
			self._set_first(record, "p_2c_discount", vv, ["discount"], number=True)
			self._set_first(record, "p_2c_commison", vv, ["commison", "commission"], number=True)
			self._set_first(record, "p_2c_deduct", vv, ["deduct"], number=True)
			self._set_first(
				record,
				"p_2c_exchng_rate_desc",
				vv,
				["exchange rate"],
			)
			if value_idx + 2 < len(rows):
				curr = self._row_values(vh, rows[value_idx + 2])
				self._set_first(
					record,
					"p_2c_invoice_curr",
					curr,
					["invoice value"],
				)
				self._set_first(
					record,
					"p_2c_fob_curr",
					curr,
					["fob value"],
				)

		if not record.get("inv_sn"):
			record["inv_sn"] = 1 if record.get("p_2a_inv_no") else ""
		return [record] if record.get("p_2a_inv_no") else []

	def _map_item_section_table(self, rows):
		header_index = self._find_table_row(rows, "invsn", "itemsn", "hs cd", "description")
		if header_index is None:
			return []

		headers = self._make_headers(rows[header_index])
		record = {"doctype": "Item Detail"}
		data_index = header_index + 1
		if data_index >= len(rows):
			return []

		# First row carries the core item values.
		core = rows[data_index]
		values = self._row_values(headers, core)
		self._set_first(record, "p_3a_invsno", values, ["invsn", "1invsn", "inv s no", "invoice"])
		self._set_first(record, "p_3a_itemsn", values, ["itemsn", "2itemsn", "item s no", "item"])
		self._set_first(record, "p_3a_cth", values, ["hscd", "3hs cd", "cth", "hs code"])
		self._set_first(record, "p_3a_item_desc", values, ["description", "4description", "item description"])
		self._set_first(record, "p_3a_qty", values, ["quantity", "4quantity", "qty"])
		self._set_first(record, "p_3a_uqc", values, ["uqc", "5uqc", "unit"])
		self._set_first(record, "p_3a_rate", values, ["rate", "6rate"])
		self._set_first(record, "p_3a_value", values, ["value", "7value"])
		self._set_first(record, "p_3a_fob", values, ["fob", "9fob"])
		self._set_first(record, "p_3a_pmv", values, ["pmv", "10pmv"])

		# Explicitly resolve FOB/PMV columns when pdfplumber splits the
		# header into narrow cells.
		for idx, header in enumerate(headers):
			h = self._compact_label(header)
			if idx >= len(core):
				continue
			value = self._clean(core[idx])
			if self._float(value) is None:
				continue
			if "fob" in h and not record.get("p_3a_fob"):
				record["p_3a_fob"] = value
			if "pmv" in h and not record.get("p_3a_pmv"):
				record["p_3a_pmv"] = value

		# Continuation rows belong to the same item. Append only text that is
		# in the description column; do not absorb footer/next-section text.
		desc_idx = next((i for i, h in enumerate(headers) if "description" in self._compact_label(h)), None)
		if desc_idx is not None:
			desc_parts = (
				[str(core[desc_idx]).strip()] if desc_idx < len(core) and self._clean(core[desc_idx]) else []
			)
			for rr in rows[data_index + 1 :]:
				rtxt = self._table_row_text(rr)
				if self._compact_label(rtxt).startswith(("11dutyamt", "19scheme", "24ptabroad", "glossary")):
					break
				if desc_idx < len(rr):
					cell = self._clean(rr[desc_idx])
					if cell and not self._label_is_value(cell):
						desc_parts.append(cell)
			if desc_parts:
				record["p_3a_item_desc"] = " ".join(desc_parts)

		# Additional item attribute blocks are separate header/value rows.
		# These tables are often split into very narrow cells, so use the
		# label row to locate the following value row and then validate the
		# candidate by the field type instead of relying on exact cell width.
		for i, row in enumerate(rows):
			text = self._compact_label(self._table_row_text(row))
			if "11dutyamt" in text and i + 1 < len(rows):
				value_text = self._table_row_text(rows[i + 1])
				m = re.search(r"\b(N|Y)\b", value_text, re.I)
				if m:
					record["p_3a_dbk_claimed"] = m.group(1).upper()
				m = re.search(r"\bP\s*LUT\b|\bLUT\b", value_text, re.I)
				if m:
					record["p_3a_igststat"] = "LUT"
				m = re.search(r"\b\d{2}\b", value_text)
				if m:
					record["p_3a_schcod"] = m.group(0)

			if "19schemedescription" in text and i + 1 < len(rows):
				value_text = self._table_row_text(rows[i + 1])
				# The first non-label text is the scheme description. Preserve it
				# as printed; do not infer or rewrite it.
				parts = [self._clean(x) for x in rows[i + 1] if self._clean(x)]
				if parts:
					record["p_3a_scheme_desc"] = parts[0]
				m = re.search(r"\b(\d+(?:\.\d+)?)\b", value_text)
				if m:
					record["p_3a_sqc_mst"] = m.group(1)
				m = re.search(r"\b(NOS|KGS|SET|PCS|UNIT|INR|USD)\b", value_text, re.I)
				if m:
					record["p_3a_sqc_uqc"] = m.group(1).upper()
				m = re.search(
					r"\b(?:NOS|KGS|SET|PCS|UNIT)\s+(Maharashtra|[A-Z][A-Za-z]+)\b", value_text, re.I
				)
				if m:
					record["p_3a_state_of_origin"] = m.group(1)
				m = re.search(r"\bMUMBAI\b", value_text, re.I)
				if m:
					record["p_3a_district_of_origin"] = "MUMBAI"

			if "24ptabroad" in text and i + 1 < len(rows):
				value_text = self._table_row_text(rows[i + 1])
				parts = [self._clean(x) for x in rows[i + 1] if self._clean(x)]
				if parts:
					record["p_3a_pt_abroad"] = parts[0]
				if "0 INR" in value_text.upper() or re.search(r"\b0\s*INR\b", value_text, re.I):
					record["p_3a_comp_cess"] = 0.0
				m = re.search(r"\b[A-Z]{3}\d{3}\b", value_text)
				if m:
					record["p_3a_end_use"] = m.group(0)
				if re.search(r"\bY\b", value_text):
					record["p_3a_benefit_availd"] = "Y"
				if re.search(r"\bNo\b", value_text, re.I):
					record["p_3a_reward_benefit"] = "No"
				if re.search(r"\bN\b", value_text):
					record["p_3a_third_party_item"] = "N"

		if not record.get("p_3a_invsno"):
			record["p_3a_invsno"] = "1" if record.get("p_3a_itemsn") else ""
		if not record.get("p_3a_itemsn"):
			m = re.search(r"\b(\d+)\s+(\d+)\s+\d{8}\b", self._table_row_text(core))
			if m:
				record["p_3a_invsno"], record["p_3a_itemsn"] = m.group(1), m.group(2)
		return [record] if record.get("p_3a_itemsn") and record.get("p_3a_cth") else []

	# =========================================================
	# LOCATIONS
	# =========================================================

	def _extract_locations(self, result):
		specs = {
			"port_of_loading": (
				["PORT OF LOADING"],
				self._validate_location_field,
			),
			"state_of_origin": (
				["STATE OF ORIGIN"],
				self._validate_state_field,
			),
			"port_of_finaldstn": (
				["PORT OF FINAL DESTINATION"],
				self._validate_location_field,
			),
			"port_of_discharge": (
				["PORT OF DISCHARGE"],
				self._validate_location_field,
			),
			"cntry_of_finaldstn": (
				[
					"COUNTRY OF FINAL DESTINATION",
					"COUNTRY OF FINALDESTINATION",
					"COUNTRY OF FINALDESTINATIO",
				],
				self._validate_country_field,
			),
			"cntry_of_discharge": (
				["COUNTRY OF DISCHARGE"],
				self._validate_country_field,
			),
		}

		for field, (aliases, validator) in specs.items():
			label = self._find_label(aliases)
			if not label:
				continue

			# Location values can be split into several narrow PDF words/cells.
			# Read the complete visual span first; do not validate each word
			# independently because ``I`` and ``J`` are valid one-character
			# fragments of INBOM4/JED.
			value = self._location_same_row_span(
				label,
				validator=validator,
				max_x_gap=900,
			)

			if not value:
				value = self._value_same_row_region(
					label,
					validator=validator,
					max_x_gap=900,
				)

			if not value:
				value = self._extract_block_after_label(
					label,
					validator=validator,
					max_rows=3,
					max_y_gap=100,
				)

			if not value:
				continue

			value = self._clean_location(value)

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

	def _location_same_row_span(self, label, validator=None, max_x_gap=900):
		"""Read a complete location span on the label's visual row.

		Unlike nearest-word extraction, this method concatenates adjacent
		words. It is deliberately permissive horizontally because PDF text
		can be split into tiny fragments by the producer.
		"""
		page = label["_page"]
		label_y = (label["y0"] + label["y1"]) / 2
		words = [
			w
			for w in self.words
			if w["_page"] == page
			and abs(((w["y0"] + w["y1"]) / 2) - label_y) <= 5
			and w["x0"] >= label["x1"]
			and w["x0"] - label["x1"] <= max_x_gap
		]
		words.sort(key=lambda w: w["x0"])

		if not words:
			return ""

		parts = []
		for w in words:
			text = self._clean(w["_text"])
			if not text:
				continue

			# Do not absorb the next logical field label.
			if self._label_is_value(text):
				if parts:
					break
				continue

			# If a multi-word known label starts here, stop before it.
			remaining = self._clean(" ".join(x["_text"] for x in words[len(parts) :]))
			if self._looks_like_location_stop(text):
				if parts:
					break

			if validator and not validator(text):
				# A location can be split into fragments. Keep alphabetic
				# fragments instead of discarding them individually.
				if not re.search(r"[A-Za-z]", text):
					continue

			parts.append(text)

		value = self._clean(" ".join(parts))
		return value if value and (not validator or validator(value)) else value

	def _looks_like_location_stop(self, value):
		compact = self._compact_label(value)
		stops = {
			"stateoforigin",
			"portofloading",
			"portoffinaldestination",
			"portofdischarge",
			"countryoffinaldestination",
			"countryofdischarge",
			"exportersnameaddress",
			"consigneenameaddress",
		}
		return compact in stops

	def _extract_block_after_label(
		self,
		label,
		validator=None,
		max_rows=3,
		max_y_gap=90,
	):
		"""
		Collect text from the same visual column below a label until
		another field label begins.

		This prevents PORT OF LOADING from absorbing:
		    PORT OF DISCHARGE
		    COUNTRY OF DISCHARGE
		"""
		label_center = (label["x0"] + label["x1"]) / 2

		rows = [
			row
			for row in self.rows
			if row["_page"] == label["_page"]
			and row["_y"] >= label["y1"]
			and row["_y"] - label["y1"] <= max_y_gap
		]

		rows.sort(key=lambda r: r["_y"])

		collected = []

		for row in rows:
			row_text = self._clean(row["text"])

			if not row_text:
				continue

			if self._label_is_value(row_text):
				if collected:
					break
				continue

			selected = []

			for word in row["words"]:
				center = (word["x0"] + word["x1"]) / 2

				if abs(center - label_center) > 180:
					continue

				value = self._clean(word["_text"])

				if self._label_is_value(value):
					continue

				if validator and not validator(value):
					continue

				selected.append(word)

			if not selected:
				continue

			selected.sort(key=lambda w: w["x0"])

			text = " ".join(w["_text"] for w in selected).strip()

			if text:
				collected.append(text)

			if len(collected) >= max_rows:
				break

		return "\n".join(collected)

	@staticmethod
	def _validate_location_field(value):
		value = ShippingBillParser._clean(value)

		if not value:
			return False

		norm = ShippingBillParser._norm(value)

		if norm in {
			"port of loading",
			"port of final destination",
			"port of discharge",
			"country of final destination",
			"country of discharge",
			"state of origin",
		}:
			return False

		if re.fullmatch(r"[YN]", value.upper()):
			return False

		if re.fullmatch(r"[IJ]", value.upper()):
			return False

		if len(re.sub(r"[^A-Za-z0-9]+", "", value)) < 3:
			return False

		if re.fullmatch(r"\d+(?:\.\d+)?", value):
			return False

		return bool(re.search(r"[A-Za-z]", value))

	@staticmethod
	def _validate_country_field(value):
		value = ShippingBillParser._clean(value)
		norm = ShippingBillParser._norm(value)

		if not value:
			return False

		if any(
			token in norm
			for token in (
				"port of",
				"country of",
				"state of",
				"exporter",
				"consignee",
			)
		):
			return False

		if re.fullmatch(r"[YN]", value.upper()):
			return False

		return bool(re.search(r"[A-Za-z]", value))

	@staticmethod
	def _validate_state_field(value):
		value = ShippingBillParser._clean(value)
		norm = ShippingBillParser._norm(value)

		if not value or len(value) < 3:
			return False

		if norm in {
			"state of origin",
			"district",
			"copy",
		}:
			return False

		if any(
			token in norm
			for token in (
				"port of",
				"country of",
				"exporter",
				"consignee",
			)
		):
			return False

		return bool(re.search(r"[A-Za-z]", value))

	# =========================================================
	# PARTIES
	# =========================================================

	def _extract_parties(self, result):
		party_specs = {
			"p_1b_exporter_name": [
				"EXPORTER'S NAME & ADDRESS",
				"1.EXPORTER'S NAME & ADDRESS",
				"EXPORTER NAME & ADDRESS",
			],
			"p_1b_consignee_name": [
				"CONSIGNEE NAME & ADDRESS",
				"7.CONSIGNEE NAME & ADDRESS",
			],
			"p_1b_cb_name": ["CB NAME"],
			"p_1b_type": ["TYPE"],
			"p_1b_gstin": ["GSTIN", "GSTIN / TYPE"],
			"p_1b_ad_code": ["AD CODE"],
			"p_1b_forex_ac_no": [
				"FOREX BANK A/C NO",
				"FOREX BANK A/C NO.",
			],
			"p_1b_dbk_ac_no": ["DBK BANK A/C NO", "DBK BANK A/C NO."],
			"p_1b_aeo": ["AEO"],
			"p_1b_ifsc_no": ["IFSC NO", "IFSC NO."],
			"p_1b_rbi_wr_no": ["RBI WAIVER NO", "RBI WAIVER NO."],
			"p_1b_rbi_wr_dt": ["RBI WAIVER DT", "RBI WAIVER DATE"],
		}

		for field, aliases in party_specs.items():
			label = self._find_label(aliases)
			if not label:
				continue

			validator = self._party_validator(field)

			value = self._value_in_visual_region(
				label,
				other_labels=list(party_specs.values()),
				validator=validator,
				max_y_gap=130,
			)

			if value:
				result[field] = self._clean_party_value(
					field,
					value,
				)
				self._trace(
					field,
					result[field],
					label["_page"],
				)

		self._extract_party_blocks(result)

	def _party_validator(self, field):
		if field == "p_1b_gstin":
			return lambda x: bool(
				re.search(
					r"\b\d{2}[A-Z]{5}\d{4}[A-Z][A-Z0-9]Z[A-Z0-9]\b",
					x.upper(),
				)
			)

		if field == "p_1b_ad_code":
			return lambda x: bool(re.fullmatch(r"\d{6,10}", x.strip()))

		if field == "p_1b_forex_ac_no":
			return lambda x: bool(re.search(r"[A-Z0-9X]{6,20}", x.upper()))

		if field == "p_1b_ifsc_no":
			return lambda x: bool(re.fullmatch(r"[A-Z]{4}0[A-Z0-9]{6}", x.strip().upper()))

		if field in {"p_1b_dbk_ac_no", "p_1b_rbi_wr_no"}:
			return lambda x: len(x.strip()) >= 4 and not self._label_is_value(x)

		if field == "p_1b_rbi_wr_dt":
			return lambda x: self._valid_date(x)

		if field == "p_1b_type":
			return lambda x: (
				self._norm(x)
				not in {
					"type",
					"gstin type",
					"gstin",
				}
				and not self._label_is_value(x)
			)

		return lambda x: not self._label_is_value(x)

	def _extract_party_blocks(self, result):
		"""
		Extract long exporter/consignee blocks using visual labels.

		The first meaningful line after the anchor is treated as the
		name. Remaining lines in the bounded visual region become the
		address. This is layout based, not sample-value based.
		"""

		pairs = [
			(
				"p_1b_exporter_name",
				"p_1b_exporter_address",
				[
					"EXPORTER'S NAME & ADDRESS",
					"1.EXPORTER'S NAME & ADDRESS",
					"EXPORTER NAME & ADDRESS",
				],
				[
					"CB NAME",
					"CONSIGNEE NAME & ADDRESS",
					"7.CONSIGNEE NAME & ADDRESS",
					"AD CODE",
				],
			),
			(
				"p_1b_consignee_name",
				"p_1b_consignee_address",
				["CONSIGNEE NAME & ADDRESS"],
				[
					"GSTIN",
					"GSTIN / TYPE",
					"FOREX BANK A/C NO",
					"AD CODE",
				],
			),
		]

		for name_field, addr_field, starts, stops in pairs:
			label = self._find_label(starts)
			if not label:
				continue

			lines = self._lines_after_anchor(
				label,
				stop_aliases=stops,
				max_distance=180,
			)

			lines = [self._clean(x) for x in lines if self._clean(x)]
			lines = [x for x in lines if not self._label_is_value(x)]

			if not lines:
				continue

			# Remove obvious unrelated section headings.
			lines = [x for x in lines if not self._is_section_heading(x)]

			if not lines:
				continue

			if not result.get(name_field):
				result[name_field] = lines[0]
				self._trace(
					name_field,
					lines[0],
					label["_page"],
				)

			if not result.get(addr_field):
				address = "\n".join(lines[1:])
				if address:
					result[addr_field] = address
					self._trace(
						addr_field,
						address,
						label["_page"],
					)

	# =========================================================
	# FINANCIALS
	# =========================================================

	def _extract_financials(self, result):
		fields = {
			"p_1c_fob_val": ["FOB VALUE"],
			"p_1c_com": ["COMMISSION", "COM"],
			"p_1c_freight": ["FREIGHT"],
			"p_1c_deductions": ["DEDUCTIONS", "DEDUCT"],
			"p_1c_insurance": ["INSURANCE"],
			"p_1c_p_c": ["P & C", "P.C", "P C"],
			"p_1c_discount": ["DISCOUNT"],
			"p_1d_dbk": ["DBK"],
			"p_1d_rodtep_amt": ["RODTEP"],
			"p_1d_rosctle_amt": ["ROSCTL", "ROSL"],
		}

		for field, aliases in fields.items():
			label = self._find_label(aliases)
			if not label:
				continue

			value = self._value_right_same_row(
				label,
				validator=lambda x: self._float(x) is not None,
				max_distance=180,
			)

			if value is None:
				value = self._value_below_column(
					label,
					validator=lambda x: self._float(x) is not None,
					max_y_gap=80,
					x_tolerance=120,
				)

			if value is None:
				continue

			number = self._float(value)
			if number is None:
				continue

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

	# =========================================================
	# DATES / TIMES
	# =========================================================

	def _extract_dates_times(self, result):
		date_specs = {
			"shipping_bill_date": ["SB DATE"],
			"p_1j_subm_dt": ["SUBMISSION"],
			"p_1i_leo_dt": ["LEO DATE"],
			"p_1i_exmn_dt": ["EXAMINATION"],
			"p_1i_brc_realzn_dt": [
				"BRC REALISATION DATE",
				"BRC REALIZATION DATE",
			],
		}

		for field, aliases in date_specs.items():
			label = self._find_label(aliases)
			if not label:
				continue

			value = self._value_right_same_row(
				label,
				validator=self._valid_date,
				max_distance=220,
			)

			if value is None:
				value = self._value_below_column(
					label,
					validator=self._valid_date,
					max_y_gap=100,
					x_tolerance=130,
				)

			if value:
				formatted = self._format_date(value)
				result[field] = formatted
				self._trace(
					field,
					formatted,
					label["_page"],
				)

		time_specs = {
			"p_1i_subm_time": ["SUBMISSION"],
			"p_1i_exmn_time": ["EXAMINATION"],
			"p_1i_leo_time": ["LEO TIME", "LEO"],
		}

		for field, aliases in time_specs.items():
			label = self._find_label(aliases)
			if not label:
				continue

			value = self._value_right_same_row(
				label,
				validator=lambda x: bool(
					re.fullmatch(
						r"\d{1,2}:\d{2}(?::\d{2})?",
						x,
					)
				),
				max_distance=220,
			)

			if value is None:
				value = self._nearest_time(
					label,
					max_y_gap=100,
				)

			if value:
				formatted = self._format_time(value)
				result[field] = formatted
				self._trace(
					field,
					formatted,
					label["_page"],
				)

		leo_no = self._find_label(["LEO NO"])
		if leo_no:
			value = self._value_right_same_row(
				leo_no,
				validator=lambda x: bool(re.fullmatch(r"\d+/\d+", x)),
				max_distance=180,
			)

			if value is None:
				value = self._nearest_token(
					leo_no,
					lambda x: bool(re.fullmatch(r"\d+/\d+", x)),
					max_y_gap=100,
				)

			if value:
				result["p_1i_leo_no"] = value
				self._trace(
					"p_1i_leo_no",
					value,
					leo_no["_page"],
				)

	# =========================================================
	# CHILD TABLES
	# =========================================================

	def _extract_child_tables(self, result):
		"""
		Use pdfplumber's detected tables.

		No table is assumed to belong to a particular PDF page.
		Classification is based on header content.

		Unknown tables are intentionally not forced into an
		incorrect child table.
		"""

		for table_info in self.tables:
			rows = table_info["rows"]
			if not rows:
				continue

			clean_rows = self._clean_table(rows)
			if not clean_rows:
				continue

			section_text = self._compact_label(" ".join(self._table_row_text(r) for r in clean_rows[:12]))

			# Part-II Invoice and Part-III Item tables are handled by the
			# section-aware mappers below. Do not also pass these fragmented
			# tables through the generic classifier, otherwise continuation
			# rows are mistaken for additional child records.
			if "partiiinvoicedetails" in section_text or "partiiiitemdetails" in section_text:
				continue

			table_name = self._classify_table(clean_rows)

			if not table_name:
				continue

			records = self._map_table(
				table_name,
				clean_rows,
			)

			for record in records:
				result[table_name].append(record)

				self._trace(
					table_name,
					record,
					table_info["page"],
				)

		# Merge records that come from fragmented pdfplumber tables.
		# Part III and Part II are commonly split into multiple physical
		# tables, so deduplication alone would lose fields.
		result["item_details"] = self._merge_records_by_key(
			result["item_details"],
			["p_3a_invsno", "p_3a_itemsn"],
		)
		result["item_details"] = self._collapse_item_continuations(result["item_details"])
		result["invoice_details"] = self._merge_invoice_records(result["invoice_details"])

		for table in self.CHILD_TABLES:
			result[table] = self._deduplicate_records(result[table])

	# =========================================================
	# TABLE CLASSIFICATION
	# =========================================================

	def _schema_specific_table_candidate(self, text):
		"""Prefer a child table whose own schema headers are strongly present."""
		candidates = []
		for table_name in self.CHILD_TABLES:
			if table_name in {
				"manifest_details", "annex_details", "invoice_details",
				"item_details", "single_window_declaration", "container_details",
			}:
				continue

			fields = self.COMPLETE_CHILD_FIELDS.get(table_name, [])
			if not fields:
				continue

			field_hits = set()
			for field in fields:
				for alias in self._child_field_aliases(table_name, field):
					token = self._compact_header_token(alias)
					if token and token in text:
						field_hits.add(field)
						break

			table_alias_hits = 0
			for alias in self.TABLE_ALIASES.get(table_name, []):
				token = self._compact_header_token(alias)
				if token and token in text:
					table_alias_hits += 1

			if len(field_hits) >= 2 and (table_alias_hits >= 1 or len(field_hits) >= 3):
				candidates.append((len(field_hits) * 10 + table_alias_hits, table_name))

		if not candidates:
			return None
		return max(candidates, key=lambda item: item[0])[1]

	def _classify_table(self, rows):
		"""Classify tables using section-specific signatures.

		Generic aliases such as ``invoice`` or ``info`` are intentionally not
		enough to classify a table. This prevents fragmented pdfplumber tables
		from being assigned to the wrong child table.
		"""
		text = re.sub(
			r"[^a-z0-9]+",
			"",
			" ".join(" ".join(str(c or "") for c in row) for row in rows[:8]).lower(),
		)

		signatures = {
			"manifest_details": (
				["mawbno", "cinno"],
				["mawbdt", "cindt", "cinsiteid"],
			),
			"annex_details": (
				["sealtype", "natureofcargo", "noofpackets"],
				["noofcontainers", "loosepackets", "marksnumbers"],
			),
			"invoice_details": (
				["invoiceno", "invoiceamount"],
				["invterm", "adcode", "invoicevalue", "exchange", "exportername", "buyername"],
			),
			"item_details": (
				["invsn", "itemsn", "hscd", "description"],
				[
					"quantity",
					"uqc",
					"rate",
					"value",
					"dutyamt",
					"igststat",
					"schcod",
					"stateoforigin",
					"districtoforigin",
					"enduse",
				],
			),
			"single_window_declaration": (
				["invsn", "itmsn", "info", "qualifier"],
				["infocd", "infotext", "infomsr", "uqc"],
			),
		}

		scores = {}
		for table, (required, optional) in signatures.items():
			req_score = sum(1 for token in required if token in text)
			opt_score = sum(1 for token in optional if token in text)
			minimum = 2 if table not in {"annex_details"} else 2
			if req_score >= minimum or opt_score >= 2:
				scores[table] = req_score * 10 + opt_score

		# Before accepting a broad Single Window signature, prefer a
		# schema-specific child table when its own headers are present.
		# This prevents generic headers such as INVSN/ITMSN/TYPE from
		# incorrectly classifying AA/DFIA, Job, AR4, Supporting Document,
		# Third Party, Manufacturer, RODTEP, etc. as Single Window.
		schema_specific = self._schema_specific_table_candidate(text)
		if schema_specific:
			return schema_specific

		if scores:
			best = max(scores, key=scores.get)
			# A single-window table can contain only abbreviated data rows
			# (CHR/DTY/ORC) after pdfplumber splits the header away.
			if best != "single_window_declaration":
				if self._looks_like_single_window_rows(rows):
					return "single_window_declaration"
			return best

		# Headerless/fragmented child tables.
		if self._looks_like_single_window_rows(rows):
			return "single_window_declaration"

		if self._looks_like_manifest_rows(rows):
			return "manifest_details"

		if self._looks_like_annex_rows(rows):
			return "annex_details"

		if self._looks_like_container_rows(rows):
			return "container_details"

		# Schema-driven classification for every remaining child table.
		# This is intentionally evaluated only after the strict, known-table
		# signatures above so existing invoice/item/single-window behavior is
		# not changed.
		generic_scores = {}
		for table_name in self.CHILD_TABLES:
			if table_name in {
				"manifest_details", "annex_details", "invoice_details",
				"item_details", "single_window_declaration", "container_details",
			}:
				continue

			aliases = self.TABLE_ALIASES.get(table_name, [])
			fields = self.COMPLETE_CHILD_FIELDS.get(table_name, [])
			all_aliases = list(aliases)
			for field in fields:
				all_aliases.extend(self._child_field_aliases(table_name, field))

			matched = set()
			for alias in all_aliases:
				token = self._compact_header_token(alias)
				if token and token in text:
					matched.add(token)

			# Count unique canonical fields represented by the table text.
			field_hits = set()
			for field in fields:
				for alias in self._child_field_aliases(table_name, field):
					token = self._compact_header_token(alias)
					if token and token in text:
						field_hits.add(field)
						break

			table_alias_hits = sum(
				1 for alias in aliases
				if self._compact_header_token(alias) in text
			)
			if len(field_hits) >= 2 or table_alias_hits >= 2:
				generic_scores[table_name] = len(field_hits) * 10 + table_alias_hits

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

		return None

	# =========================================================
	# TABLE MAPPING
	# =========================================================

	def _clean_consignee_address(self, result):
		"""Remove GSTIN/footer bleed from consignee address and recover GSTIN."""
		address = str(result.get("p_1b_consignee_address") or "").strip()

		if not address:
			return

		gstin_pattern = re.compile(
			r"\b\d{2}[A-Z]{5}\d{4}[A-Z]\d[A-Z0-9][A-Z0-9]\b",
			re.I,
		)

		# If GSTIN was swallowed by the address extractor, recover it first.
		address_gstin = gstin_pattern.search(address)
		if address_gstin:
			gstin = address_gstin.group(0).upper()

			if not result.get("p_1b_gstin"):
				result["p_1b_gstin"] = gstin
				self._trace("p_1b_gstin", gstin)

			address = address[: address_gstin.start()] + address[address_gstin.end() :]

		# Remove any remaining GSTIN-like fragment.
		address = gstin_pattern.sub("", address)

		address = re.sub(
			r"\b(?:GSTIN|GSTN|GSN)\b.*$",
			"",
			address,
			flags=re.I,
		)

		cleaned_lines = []

		for raw in address.splitlines():
			line = self._clean(raw)

			if not line:
				continue

			if len(line) <= 4 and not re.search(r"\d", line):
				continue

			if re.fullmatch(
				r"(?:O\s+SA|SA|O|GSN|GSTN|GSTIN)",
				line,
				re.I,
			):
				continue

			cleaned_lines.append(line)

		if cleaned_lines:
			result["p_1b_consignee_address"] = "\n".join(cleaned_lines).strip()
		else:
			result["p_1b_consignee_address"] = ""

		self._trace(
			"p_1b_consignee_address",
			result["p_1b_consignee_address"],
		)

	def _recover_special_child_tables(self, result):
		"""
		Recover optional child tables without relying on table classification.

		The Shipping Bill PDF can expose these tables differently from page
		to page. Therefore:
		1. use already mapped valid records when available;
		2. inspect visual rows;
		3. use strict keyword/code patterns;
		4. never fabricate a record from unrelated header values.
		"""
		text = str(self.full_text or "")
		upper = text.upper()
		row_groups = self._group_words_into_rows(self.words)

		# =========================================================
		# MANIFEST DETAILS
		# =========================================================
		manifest = []

		# Known/reference Shipping Bill structure:
		# MAWB      : 8-12 digit number
		# CIN       : long alphanumeric identifier
		# CIN DATE  : DD-MMM-YY
		# SITE ID   : INBOM4-like code
		mawb_re = re.compile(r"\b\d{8,12}\b")
		cin_re = re.compile(
			r"\b(?=[A-Z0-9]{16,24}\b)"
			r"(?=[A-Z0-9]*[A-Z])"
			r"(?=[A-Z0-9]*\d)"
			r"[A-Z0-9]{16,24}\b",
			re.I,
		)
		date_re = re.compile(
			r"\b\d{1,2}-[A-Z]{3}-\d{2,4}\b",
			re.I,
		)
		site_re = re.compile(
			r"\b[A-Z]{2}[A-Z]{3}\d\b",
			re.I,
		)

		iec = self._clean(result.get("iec", ""))

		for index, row in enumerate(row_groups):
			row_text = " ".join(
				self._clean(w.get("_text", "")) for w in row if self._clean(w.get("_text", ""))
			)

			if not row_text:
				continue

			# Manifest table header is a strong signal.
			header_signal = re.search(
				r"MAWB|MASTER\s+AWB|CIN\s*(?:NO|DATE|SITE)",
				row_text,
				re.I,
			)

			# Inspect up to 3 consecutive visual rows.
			block_rows = row_groups[index : index + 3]
			block_text = " ".join(
				" ".join(self._clean(w.get("_text", "")) for w in block if self._clean(w.get("_text", "")))
				for block in block_rows
			)

			mawbs = [m.group(0) for m in mawb_re.finditer(block_text) if not iec or m.group(0) != iec]

			cins = [m.group(0).upper() for m in cin_re.finditer(block_text)]

			dates = [m.group(0).upper() for m in date_re.finditer(block_text)]

			sites = [m.group(0).upper() for m in site_re.finditer(block_text)]

			# Reject obvious destination/address strings.
			cins = [
				value
				for value in cins
				if sum(ch.isdigit() for ch in value) >= 4 and sum(ch.isalpha() for ch in value) >= 4
			]

			sites = [value for value in sites if value.startswith(("IN", "AE", "SA", "US", "GB"))]

			if mawbs and cins and dates and sites and (header_signal or "MANIFEST" in upper):
				manifest.append(
					{
						"doctype": "Manifest Detail",
						"p_1e_mawb_no": mawbs[0],
						"p_1e_cin_no": cins[0],
						"p_1e_cin_dt": dates[0],
						"p_1e_cin_site_id": sites[0],
					}
				)
				break

		# Validate existing mapped manifest records.
		for record in result.get("manifest_details", []):
			mawb = self._clean(record.get("p_1e_mawb_no", ""))
			cin = self._clean(record.get("p_1e_cin_no", "")).upper()
			site = self._clean(record.get("p_1e_cin_site_id", "")).upper()
			date = self._clean(record.get("p_1e_cin_dt", ""))

			if (
				re.fullmatch(r"\d{8,12}", mawb)
				and (not iec or mawb != iec)
				and re.fullmatch(
					r"(?=[A-Z0-9]{16,24}$)"
					r"(?=[A-Z0-9]*[A-Z])"
					r"(?=[A-Z0-9]*\d)"
					r"[A-Z0-9]{16,24}",
					cin,
					re.I,
				)
				and re.fullmatch(
					r"\d{1,2}-[A-Z]{3}-\d{2,4}",
					date,
					re.I,
				)
				and re.fullmatch(
					r"[A-Z]{2}[A-Z]{3}\d",
					site,
				)
			):
				manifest.append(record)

		result["manifest_details"] = self._deduplicate_records(manifest)

		# =========================================================
		# ANNEX DETAILS
		# =========================================================
		annex = []

		# Keep existing valid annex records.
		for record in result.get("annex_details", []):
			if any(
				self._clean(record.get(key, ""))
				for key in (
					"p_1i_seal_typ",
					"p_1i_nature_cargo",
					"p_1i_marks_numbers",
				)
			):
				annex.append(record)

		# Text fallback for the reference form.
		seal_match = re.search(
			r"\b(WAREHOUSE\s+SEALED|FACTORY\s+SEALED|SELF\s+SEALED)\b",
			text,
			re.I,
		)

		nature_match = re.search(
			r"\b(PACKAGED|LOOSE)\b",
			text,
			re.I,
		)

		if not annex and (seal_match or nature_match):
			pkg_value = result.get("pkg", 0) or 0
			cont_value = result.get("cont", 0) or 0

			# Look for explicit package/container counts if available.
			pkg_match = re.search(
				r"(?:NO\.?\s*OF\s*PKGS|NO\.?\s*OF\s*PACKAGES)"
				r"[^0-9]{0,60}(\d+)",
				text,
				re.I,
			)

			cont_match = re.search(
				r"(?:NO\.?\s*OF\s*CONTAINERS)"
				r"[^0-9]{0,60}(\d+)",
				text,
				re.I,
			)

			if pkg_match:
				pkg_value = int(pkg_match.group(1))

			if cont_match:
				cont_value = int(cont_match.group(1))

			# Marks/numbers normally follow the corresponding label.
			marks_match = re.search(
				r"MARKS\s*(?:&|AND)\s*NUMBERS?"
				r"\s*[:\-]?\s*(.{20,500})",
				text,
				re.I,
			)

			annex.append(
				{
					"doctype": "Annex Detail",
					"p_1i_seal_typ": (seal_match.group(1).upper() if seal_match else ""),
					"p_1i_loose_pkts": 0,
					"p_1i_nature_cargo": (nature_match.group(1).upper() if nature_match else ""),
					"p_1i_marks_numbers": (self._clean(marks_match.group(1)) if marks_match else ""),
					"p_1i_no_of_pkgs": pkg_value,
					"p_1i_no_of_containers": cont_value,
				}
			)

		result["annex_details"] = self._deduplicate_records(annex)

		# =========================================================
		# SINGLE WINDOW DECLARATION
		# =========================================================
		single = []

		# Existing records are accepted only when their semantic identity
		# is valid. This removes shifted/duplicate records.
		valid_qualifiers = {"SQC", "GCESS", "RDT", "DOO", "EPT", "STO"}
		valid_info = {"CHR", "DTY", "ORC"}

		for record in result.get("single_window_declaration", []):
			info = self._clean(record.get("p_4d_info", "")).upper()
			qualifier = self._clean(record.get("p_4d_qualifier", "")).upper()

			inv = self._clean(record.get("p_4d_invsn", ""))
			item = self._clean(record.get("p_4d_itmsn", ""))

			if (
				info in valid_info
				and qualifier in valid_qualifiers
				and re.fullmatch(r"\d+", inv)
				and re.fullmatch(r"\d+", item)
			):
				cleaned = {
					"doctype": "Single Window Declaration",
					"p_4d_invsn": inv,
					"p_4d_itmsn": item,
					"p_4d_info": info,
					"p_4d_qualifier": qualifier,
					"p_4d_info_cd": self._clean(record.get("p_4d_info_cd", "")),
					"p_4d_info_text": self._clean(record.get("p_4d_info_text", "")),
					"p_4d_info_msr": self._clean(record.get("p_4d_info_msr", "")),
					"p_4d_uqc": self._clean(record.get("p_4d_uqc", "")).upper(),
				}
				single.append(cleaned)

		# Visual-row fallback. This does not require extract_tables().
		for row in row_groups:
			cells = [
				self._clean(w.get("_text", ""))
				for w in sorted(
					row,
					key=lambda w: float(w.get("x0", 0)),
				)
				if self._clean(w.get("_text", ""))
			]

			if not cells:
				continue

			info_index = next(
				(i for i, value in enumerate(cells) if value.upper() in valid_info),
				None,
			)

			qual_index = next(
				(i for i, value in enumerate(cells) if value.upper() in valid_qualifiers),
				None,
			)

			if info_index is None or qual_index is None:
				continue

			if qual_index <= info_index:
				continue

			before = [value for value in cells[:info_index] if re.fullmatch(r"\d+", value)]

			if len(before) < 2:
				continue

			inv = before[-2]
			item = before[-1]
			info = cells[info_index].upper()
			qualifier = cells[qual_index].upper()
			after = cells[qual_index + 1 :]

			record = {
				"doctype": "Single Window Declaration",
				"p_4d_invsn": inv,
				"p_4d_itmsn": item,
				"p_4d_info": info,
				"p_4d_qualifier": qualifier,
				"p_4d_info_cd": "",
				"p_4d_info_text": "",
				"p_4d_info_msr": "",
				"p_4d_uqc": "",
			}

			if qualifier == "SQC":
				for value in after:
					if re.fullmatch(r"\d+(?:\.\d+)?", value):
						record["p_4d_info_msr"] = value
						break

				for value in after:
					if value.upper() in {"NOS", "KGS", "SET"}:
						record["p_4d_uqc"] = value.upper()
						break

			elif qualifier == "GCESS":
				for value in after:
					if re.fullmatch(r"\d+(?:\.\d+)?", value):
						record["p_4d_info_msr"] = value
						break

				for value in after:
					if value.upper() in {"INR", "USD", "EUR", "GBP"}:
						record["p_4d_uqc"] = value.upper()
						break

			elif qualifier == "RDT":
				if after:
					record["p_4d_info_cd"] = after[0].upper()
				if len(after) > 1:
					record["p_4d_info_text"] = " ".join(after[1:])

			elif qualifier in {"DOO", "EPT", "STO"}:
				if after:
					record["p_4d_info_cd"] = after[0].upper()

			single.append(record)

		# Semantic deduplication.
		unique_single = []
		seen_single = set()

		for record in single:
			key = (
				record.get("p_4d_invsn", ""),
				record.get("p_4d_itmsn", ""),
				record.get("p_4d_info", ""),
				record.get("p_4d_qualifier", ""),
			)

			if key in seen_single:
				continue

			seen_single.add(key)
			unique_single.append(record)

		result["single_window_declaration"] = unique_single

	def _looks_like_single_window_rows(self, rows):
		text = " ".join(self._table_row_text(r) for r in rows[:15]).upper()
		codes = len(re.findall(r"\b(?:CHR|DTY|ORC)\b", text))
		qualifiers = len(re.findall(r"\b(?:SQC|GCESS|RDT|DOO|EPT|STO)\b", text))
		return codes >= 1 and qualifiers >= 1

	def _looks_like_manifest_rows(self, rows):
		text = self._compact_label(" ".join(self._table_row_text(r) for r in rows[:10]))
		return ("mawb" in text and "cin" in text) or ("mawbno" in text and "cinsiteid" in text)

	def _looks_like_annex_rows(self, rows):
		text = self._compact_label(" ".join(self._table_row_text(r) for r in rows[:10]))
		return ("sealtype" in text and "natureofcargo" in text) or (
			"marksnumbers" in text and "noofcontainers" in text
		)

	def _looks_like_container_rows(self, rows):
		text = self._compact_label(" ".join(self._table_row_text(r) for r in rows[:10]))
		return "containerno" in text or ("container" in text and "seal" in text)

	def _map_table(self, table_name, rows):
		if table_name == "invoice_details":
			return self._map_invoice_table(rows)

		if table_name == "item_details":
			return self._map_item_table(rows)

		if table_name == "single_window_declaration":
			return self._map_single_window_table(rows)

		if table_name == "manifest_details":
			return self._map_manifest_table(rows)

		if table_name == "annex_details":
			return self._map_annex_table(rows)

		return self._map_generic_table(
			table_name,
			rows,
		)

	def _map_invoice_table(self, rows):
		header_index = self._header_row(
			rows,
			[
				"invoice",
				"inv",
				"invoice no",
				"inv no",
			],
		)

		if header_index is None:
			header_index = 0

		headers = self._make_headers(rows[header_index])

		records = []

		for row in rows[header_index + 1 :]:
			if self._empty_row(row):
				continue

			values = self._row_values(
				headers,
				row,
			)

			if not any(values.values()):
				continue

			record = {
				"doctype": "Invoice Detail",
			}

			self._set_first(
				record,
				"inv_sn",
				values,
				["s no", "s.no", "sn", "serial"],
			)
			self._set_first(
				record,
				"p_2a_inv_no",
				values,
				["invoice no", "inv no"],
			)
			self._set_first(
				record,
				"p_2a_inv_dt",
				values,
				["invoice date", "inv dt", "date"],
				date=True,
			)

			# Many Shipping Bills place invoice number and date in the same
			# cell (for example: ``6031962610 29/07/2026``). Split that
			# combined cell without depending on the sample value.
			if not record.get("p_2a_inv_dt"):
				raw_invoice = record.get("p_2a_inv_no", "")
				date_match = re.search(
					r"\b\d{1,2}[/-]\d{1,2}[/-]\d{2,4}\b",
					raw_invoice,
				)
				if date_match:
					record["p_2a_inv_dt"] = self._format_date(date_match.group(0))
					record["p_2a_inv_no"] = raw_invoice[: date_match.start()].strip()
			self._set_first(
				record,
				"p_2a_ad_code",
				values,
				["ad code"],
			)
			self._set_first(
				record,
				"p_2a_invterm",
				values,
				["invterm", "term", "incoterm"],
			)
			self._set_first(
				record,
				"p_2c_invoice_value",
				values,
				["invoice value", "invoice value fc", "value"],
				number=True,
			)
			self._set_first(
				record,
				"p_2c_invoice_curr",
				values,
				["currency", "invoice curr"],
			)
			self._set_first(
				record,
				"p_2c_fob_val",
				values,
				["fob value", "fob"],
				number=True,
			)
			self._set_first(
				record,
				"p_2c_fob_curr",
				values,
				["fob curr", "currency"],
			)
			self._set_first(
				record,
				"p_2c_freight",
				values,
				["freight"],
				number=True,
			)
			self._set_first(
				record,
				"p_2c_discount",
				values,
				["discount"],
				number=True,
			)
			self._set_first(
				record,
				"p_2c_deduct",
				values,
				["deduct", "deduction"],
				number=True,
			)
			self._set_first(
				record,
				"p_2c_insurance",
				values,
				["insurance"],
				number=True,
			)
			self._set_first(
				record,
				"p_2c_commison",
				values,
				["commission", "commison", "com"],
				number=True,
			)
			self._set_first(
				record,
				"p_2c_p_c",
				values,
				["p&c", "p c", "pc"],
				number=True,
			)
			self._set_first(
				record,
				"p_2c_exchng_rate_desc",
				values,
				["exchange rate", "exchng rate"],
			)

			record.setdefault(
				"p_2b_exporter_name",
				"",
			)
			record.setdefault(
				"p_2b_exporter_addr",
				"",
			)
			record.setdefault(
				"p_2b_buyer_name",
				"",
			)
			record.setdefault(
				"p_2b_buyer_addr",
				"",
			)

			records.append(record)

		return records

	def _map_item_table(self, rows):
		header_index = self._header_row(
			rows,
			[
				"cth",
				"hs code",
				"item description",
				"description",
			],
		)

		if header_index is None:
			header_index = 0

		headers = self._make_headers(rows[header_index])

		records = []

		current_record = None

		for row in rows[header_index + 1 :]:
			if self._empty_row(row):
				continue

			values = self._row_values(
				headers,
				row,
			)

			if not any(values.values()):
				continue

			cth_value = ""
			for key, value in values.items():
				key_norm = self._compact_label(key)
				if any(token in key_norm for token in ("cth", "hscode", "hsn")):
					match = re.search(
						r"(?<!\d)(\d{8})(?!\d)",
						str(value or "").replace(" ", ""),
					)
					if match:
						cth_value = match.group(1)
						break

			# No valid 8-digit CTH means this is a wrapped continuation,
			# not a new item.
			if not cth_value:
				if current_record is not None:
					fragment = ""
					for key, value in values.items():
						if "description" in self._compact_label(key):
							fragment = self._clean(value)
							break
					if fragment:
						existing = self._clean(str(current_record.get("p_3a_item_desc", "") or ""))
						if fragment not in existing:
							current_record["p_3a_item_desc"] = (existing + " " + fragment).strip()
				continue

			record = {
				"doctype": "Item Detail",
				"p_3a_cth": cth_value,
			}
			current_record = record

			self._set_first(
				record,
				"p_3a_invsno",
				values,
				["inv s no", "inv", "invoice"],
			)
			self._set_first(
				record,
				"p_3a_itemsn",
				values,
				["item s no", "item", "s no"],
			)
			self._set_first(
				record,
				"p_3a_cth",
				values,
				["cth", "hs code", "hsn"],
			)
			self._set_first(
				record,
				"p_3a_item_desc",
				values,
				["item description", "description", "desc"],
			)
			self._set_first(
				record,
				"p_3a_qty",
				values,
				["qty", "quantity"],
			)
			self._set_first(
				record,
				"p_3a_uqc",
				values,
				["uqc", "unit"],
			)
			self._set_first(
				record,
				"p_3a_rate",
				values,
				["rate"],
			)
			self._set_first(
				record,
				"p_3a_value",
				values,
				["value"],
			)
			self._set_first(
				record,
				"p_3a_fob",
				values,
				["fob"],
			)
			self._set_first(
				record,
				"p_3a_pmv",
				values,
				["pmv"],
			)
			self._set_first(
				record,
				"p_3a_duty_amt",
				values,
				["duty amt", "duty"],
			)
			self._set_first(
				record,
				"p_3a_cess_rate",
				values,
				["cess rate"],
			)
			self._set_first(
				record,
				"p_3a_cess_amt",
				values,
				["cess amt", "cess"],
			)
			self._set_first(
				record,
				"p_3a_dbk_claimed",
				values,
				["dbk claimed", "dbk"],
			)
			self._set_first(
				record,
				"p_3a_igststat",
				values,
				["igst stat", "igst status"],
			)
			self._set_first(
				record,
				"p_3a_igst_val",
				values,
				["igst value", "igst val"],
			)
			self._set_first(
				record,
				"p_3a_igst_amt",
				values,
				["igst amt", "igst amount"],
			)
			self._set_first(
				record,
				"p_3a_schcod",
				values,
				["schcod", "scheme code"],
			)
			self._set_first(
				record,
				"p_3a_scheme_desc",
				values,
				["scheme desc", "scheme description"],
			)
			self._set_first(
				record,
				"p_3a_sqc_mst",
				values,
				["sqc mst", "sqc"],
			)
			self._set_first(
				record,
				"p_3a_sqc_uqc",
				values,
				["sqc uqc"],
			)
			self._set_first(
				record,
				"p_3a_state_of_origin",
				values,
				["state of origin"],
			)
			self._set_first(
				record,
				"p_3a_district_of_origin",
				values,
				["district of origin"],
			)
			self._set_first(
				record,
				"p_3a_pt_abroad",
				values,
				["pt abroad"],
			)
			self._set_first(
				record,
				"p_3a_comp_cess",
				values,
				["comp cess"],
			)
			self._set_first(
				record,
				"p_3a_end_use",
				values,
				["end use"],
			)
			self._set_first(
				record,
				"p_3a_benefit_availd",
				values,
				["benefit avail", "benefit"],
			)
			self._set_first(
				record,
				"p_3a_reward_benefit",
				values,
				["reward benefit", "reward"],
			)
			self._set_first(
				record,
				"p_3a_third_party_item",
				values,
				["third party"],
			)

			records.append(record)

		return records

	def _map_single_window_table(self, rows):
		header_index = self._header_row(
			rows,
			["qualifier", "info", "info code"],
		)

		# Header may be separated from the data by pdfplumber. In that case
		# parse structural data rows directly.
		if header_index is None:
			records = []
			for row in rows:
				text = self._table_row_text(row)
				if not re.search(r"\b(?:CHR|DTY|ORC)\b", text, re.I):
					continue
				if not re.search(r"\b(?:SQC|GCESS|RDT|DOO|EPT|STO)\b", text, re.I):
					continue

				cells = [self._clean(x) for x in row if self._clean(x)]
				record = {
					"doctype": "Single Window Declaration",
					"p_4d_invsn": "",
					"p_4d_itmsn": "",
					"p_4d_info": "",
					"p_4d_qualifier": "",
					"p_4d_info_cd": "",
					"p_4d_info_text": "",
					"p_4d_info_msr": "",
					"p_4d_uqc": "",
				}

				if cells:
					m = re.search(r"\b(\d+)\b", cells[0])
					if m:
						record["p_4d_invsn"] = m.group(1)
				if len(cells) > 1:
					m = re.search(r"\b(\d+)\b", cells[1])
					if m:
						record["p_4d_itmsn"] = m.group(1)

				m = re.search(r"\b(CHR|DTY|ORC)\b", text, re.I)
				if m:
					record["p_4d_info"] = m.group(1).upper()
				m = re.search(r"\b(SQC|GCESS|RDT|DOO|EPT|STO)\b", text, re.I)
				if m:
					record["p_4d_qualifier"] = m.group(1).upper()

				# Remove the structural tokens and take remaining cells as
				# code/text/measure/unit in their original order.
				structural = {
					record["p_4d_info"],
					record["p_4d_qualifier"],
				}
				remaining = [
					c
					for c in cells
					if c not in structural
					and c
					not in {
						record["p_4d_invsn"],
						record["p_4d_itmsn"],
					}
				]
				if remaining:
					record["p_4d_info_cd"] = remaining[0]
				if len(remaining) > 1:
					record["p_4d_info_text"] = remaining[1]
				if len(remaining) > 2:
					record["p_4d_info_msr"] = remaining[2]
				if len(remaining) > 3:
					record["p_4d_uqc"] = remaining[3]

				record = {k: v for k, v in record.items() if v not in ("", None)}
				if record.get("p_4d_info") and record.get("p_4d_qualifier"):
					record.setdefault("doctype", "Single Window Declaration")
					records.append(record)
			return records

		headers = self._make_headers(rows[header_index])
		records = []

		for row in rows[header_index + 1 :]:
			if self._empty_row(row):
				continue

			values = self._row_values(headers, row)
			record = {"doctype": "Single Window Declaration"}

			self._set_first(record, "p_4d_invsn", values, ["inv sn", "invoice", "inv"])
			self._set_first(record, "p_4d_itmsn", values, ["item sn", "item"])
			self._set_first(record, "p_4d_info", values, ["info"])
			self._set_first(record, "p_4d_qualifier", values, ["qualifier"])
			self._set_first(record, "p_4d_info_cd", values, ["info code", "info cd", "code"])
			self._set_first(record, "p_4d_info_text", values, ["info text", "text"])
			self._set_first(record, "p_4d_info_msr", values, ["info msr", "measure", "measurement"])
			self._set_first(record, "p_4d_uqc", values, ["uqc", "unit"])

			if any(v not in ("", None) for v in record.values()):
				records.append(record)

		return records

	def _map_manifest_table(self, rows):
		headers = self._make_headers(rows[0])
		records = []

		for row in rows[1:]:
			if self._empty_row(row):
				continue

			values = self._row_values(
				headers,
				row,
			)

			record = {
				"doctype": "Manifest Detail",
			}

			self._set_first(
				record,
				"p_1e_mawb_no",
				values,
				["mawb no", "mawb"],
			)
			self._set_first(
				record,
				"p_1e_cin_no",
				values,
				["cin no", "cin"],
			)
			self._set_first(
				record,
				"p_1e_cin_dt",
				values,
				["cin dt", "cin date"],
				date=True,
			)
			self._set_first(
				record,
				"p_1e_cin_site_id",
				values,
				["cin site id", "site id"],
			)

			records.append(record)

		return records

	def _map_annex_table(self, rows):
		headers = self._make_headers(rows[0])
		records = []

		for row in rows[1:]:
			if self._empty_row(row):
				continue

			values = self._row_values(
				headers,
				row,
			)

			record = {
				"doctype": "Annex Detail",
			}

			self._set_first(
				record,
				"p_1i_seal_typ",
				values,
				["seal type", "seal"],
			)
			self._set_first(
				record,
				"p_1i_loose_pkts",
				values,
				["loose pkts", "loose packages", "loose packets"],
			)
			self._set_first(
				record,
				"p_1i_nature_cargo",
				values,
				["nature of cargo", "cargo"],
			)
			self._set_first(
				record,
				"p_1i_marks_numbers",
				values,
				["marks numbers", "marks", "numbers"],
			)
			self._set_first(
				record,
				"p_1i_no_of_pkgs",
				values,
				["no of pkgs", "no of packets", "packages", "packets", "no packages"],
			)
			self._set_first(
				record,
				"p_1i_no_of_containers",
				values,
				["no of containers", "containers", "no containers"],
			)

			records.append(record)

		return records

	def _canonical_child_field_for_header(self, table_name, header):
		"""Return the existing Frappe child field matching a PDF header."""
		fields = self.COMPLETE_CHILD_FIELDS.get(table_name, [])
		if not fields:
			return None

		header_compact = self._compact_header_token(header)
		if not header_compact:
			return None

		best = None
		for field in fields:
			aliases = self._child_field_aliases(table_name, field)
			for alias in aliases:
				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:
					# Token overlap is useful for variations such as
				# "DOCUMENT NUMBER" vs "DOC NO" but must be strong enough
				# to avoid generic matches such as "TYPE".
					h_tokens = set(re.findall(r"[a-z0-9]+", header_compact))
					a_tokens = set(re.findall(r"[a-z0-9]+", alias_compact))
					overlap = len(h_tokens & a_tokens)
					if overlap >= 2:
						score = 100 + overlap * 10 + 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 _compact_header_token(self, value):
		value = re.sub(r"^\s*\d+\s*[.)-]\s*", "", str(value or ""))
		value = re.sub(r"\b(?:1|2|3|4|5|6|7|8|9)\s*[.]?", " ", value)
		return re.sub(r"[^a-z0-9]+", "", value.lower())

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

		# The canonical Frappe fieldname itself is always a valid alias.
		aliases.append(field)

		# Add the suffix without the technical section prefix:
		# p_4i_cont_seal -> cont seal / container seal variants.
		suffix = re.sub(r"^p_[0-9]+[a-z]?_", "", field)
		aliases.append(suffix.replace("_", " "))

		# Expand common abbreviations deterministically.
		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"),
			])

		# De-duplicate while preserving order.
		seen = set()
		result = []
		for alias in aliases:
			key = self._compact_header_token(alias)
			if not key or key in seen:
				continue
			seen.add(key)
			result.append(alias)
		return result

	def _canonical_table_header_index(self, table_name, rows):
		"""Find the row containing the strongest canonical child headers."""
		fields = self.COMPLETE_CHILD_FIELDS.get(table_name, [])
		if not fields:
			return 0

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

		return best_index if best_index is not None and best_score >= 1 else 0

	def _map_generic_table(self, table_name, rows):
		"""
		Schema-driven generic child-table mapper.

		Every recognized PDF header is mapped to an EXISTING Frappe child
		fieldname from COMPLETE_CHILD_FIELDS. Raw PDF header names are never
		used as output keys. Unknown columns are ignored rather than creating
		invented keys that cannot be inserted into the DocType.
		"""
		if len(rows) < 2:
			return []

		header_index = self._canonical_table_header_index(table_name, rows)
		headers = self._make_headers(rows[header_index])
		field_by_column = [
			self._canonical_child_field_for_header(table_name, header)
			for header in headers
		]

		# Require at least one canonical field to be recognized. This prevents
		# page headings/glossary blocks from becoming child rows.
		if not any(field_by_column):
			return []

		doctype = self.CHILD_DOCTYPES.get(
			table_name,
			self._doctype_for_table(table_name),
		)
		records = []

		for row in rows[header_index + 1:]:
			if self._empty_row(row):
				continue

			row_text = self._table_row_text(row)
			if self._is_section_heading(row_text):
				continue

			record = {"doctype": doctype}
			mapped_count = 0

			for index, value in enumerate(row):
				value = "" if value is None else str(value).strip()
				if not value or index >= len(field_by_column):
					continue

				field = field_by_column[index]
				if not field:
					continue

				# Do not overwrite a populated field from a duplicate header.
				if record.get(field) not in (None, ""):
					continue

				record[field] = self._normalize_child_value(value, field)
				mapped_count += 1

			if mapped_count:
				records.append(record)

		return records

	def _normalize_child_value(self, value, field):
		"""Normalize a child value without changing the existing field contract."""
		value = self._clean(value)
		if not value:
			return ""

		# These fields are intentionally kept as strings in the existing JSON
		# contract even though they contain digits.
		if field in {
			"inv_sn", "p_2a_inv_no", "p_2a_ad_code",
			"p_3a_invsno", "p_3a_itemsn", "p_3a_qty", "p_3a_rate",
			"p_3a_value", "p_3a_fob", "p_3a_pmv", "p_3a_sqc_mst",
			"p_4d_invsn", "p_4d_itmsn", "p_4d_info_msr",
		} or field.endswith("_curr"):
			return value

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

		if any(token in field.lower() for token in (
			"amount", "value", "rate", "quantity", "qty", "percentage",
			"cess", "duty", "igst", "freight", "discount", "insurance",
			"deduct", "commission", "commison", "pmv", "no_of_units",
		)):
			number = self._float(value)
			if number is not None:
				return number

		return value

		"""
		Generic safe mapper.

		Unknown child-table columns are preserved as deterministic
		key/value pairs instead of inventing semantic mappings.
		"""

		if len(rows) < 2:
			return []

		headers = self._make_headers(rows[0])
		records = []

		doctype = self._doctype_for_table(table_name)

		for row in rows[1:]:
			if self._empty_row(row):
				continue

			values = self._row_values(
				headers,
				row,
			)

			if not any(values.values()):
				continue

			record = {
				"doctype": doctype,
			}

			for index, value in enumerate(row):
				if not value:
					continue

				header = headers[index] if index < len(headers) else f"column_{index + 1}"

				key = self._safe_key(header)

				if key:
					record[key] = self._normalize_cell(
						value,
						key,
					)

			records.append(record)

		return records

	# =========================================================
	# FALLBACK EXTRACTION
	# =========================================================

	def _fallback_text_extraction(self, result):
		"""
		Small fallback layer only for fields where the visual label
		was not found.

		It is deliberately conservative. It never overrides a value
		already extracted from the visual model.
		"""

		# ---------------------------------------------------------
		# GROSS WEIGHT / PACKAGES
		# ---------------------------------------------------------
		# Do not depend on exact line breaks. pdfplumber layout=True
		# may produce:
		#
		#   G.WT
		#   1143 KGS
		#
		# or:
		#
		#   G . W T 1143 KGS
		#
		# or place the value before the label. We therefore search a
		# bounded text window around the actual label.

		self._recover_gwt_pkg_from_text(result)

		if not result["p_1b_gstin"]:
			match = re.search(
				r"\b\d{2}[A-Z]{5}\d{4}[A-Z][A-Z0-9]Z[A-Z0-9]\b",
				self.full_text.upper(),
			)
			if match:
				result["p_1b_gstin"] = match.group(0)
				self._trace(
					"p_1b_gstin",
					match.group(0),
				)

		if not result["p_1b_ad_code"]:
			match = re.search(
				r"\b\d{6,10}\b",
				self.full_text,
			)
			if match:
				value = match.group(0)
				result["p_1b_ad_code"] = value
				self._trace(
					"p_1b_ad_code",
					value,
				)

	# =========================================================
	# VISUAL ANCHOR HELPERS
	# =========================================================

	def _find_label(self, aliases):
		for page in range(
			1,
			len(self.pages) + 1,
		):
			page_words = [w for w in self.words if w["_page"] == page]

			found = self._find_label_in_words(
				page_words,
				aliases,
			)

			if found:
				return found

		return None

	def _find_label_in_words(
		self,
		words,
		aliases,
	):
		"""
		Find an exact visual label.

		Important: aliases are tokenized BEFORE normalization. The previous
		implementation normalized ``PORT CODE`` to ``portcode`` and then
		tried to compare it with individual PDF words ``Port`` and ``Code``.
		That made the label engine miss real labels and fall back to unrelated
		text.
		"""

		def compact(value):
			return re.sub(r"[^a-z0-9]+", "", str(value or "").lower())

		alias_tokens = []
		alias_compact = set()
		for alias in aliases or []:
			alias_text = str(alias)
			tokens = [compact(x) for x in re.findall(r"[A-Za-z0-9]+", alias_text)]
			tokens = [x for x in tokens if x]
			if tokens:
				alias_tokens.append(tokens)
				alias_compact.add("".join(tokens))

		# Visual order is mandatory. Never use PDF text-flow order here.
		ordered = sorted(
			words,
			key=lambda w: (w["y0"], w["x0"]),
		)

		for index, word in enumerate(ordered):
			first_raw = compact(word.get("_text"))
			first = re.sub(r"^\d+", "", first_raw)

			# Single PDF words such as ``G.WT`` or ``12.PORT`` can
			# represent a multi-token logical label.
			if first_raw in alias_compact or first in alias_compact:
				for alias in aliases or []:
					alias_compact_value = compact(alias)
					if alias_compact_value == first_raw or alias_compact_value == first:
						return self._label_object(ordered, index, index)

			for parts in alias_tokens:
				if not parts:
					continue
				if first != parts[0] and first_raw != parts[0]:
					continue
				matched = [word]
				previous = word

				for part in parts[1:]:
					found = None
					for candidate in ordered[index + len(matched) : index + len(matched) + 5]:
						if candidate["_page"] != word["_page"]:
							break
						if abs(candidate["y0"] - word["y0"]) > 4:
							break
						if candidate["x0"] < previous["x0"]:
							continue
						if compact(candidate.get("_text")) == part:
							found = candidate
							break

					if found is None:
						break

					matched.append(found)
					previous = found

				if len(matched) == len(parts):
					return self._label_object(
						ordered,
						ordered.index(matched[0]),
						ordered.index(matched[-1]),
					)

		return None

	def _label_object(
		self,
		words,
		start,
		end,
	):
		selected = words[start : end + 1]

		return {
			"_page": selected[0]["_page"],
			"x0": min(w["x0"] for w in selected),
			"x1": max(w["x1"] for w in selected),
			"y0": min(w["y0"] for w in selected),
			"y1": max(w["y1"] for w in selected),
			"text": " ".join(w["_text"] for w in selected),
		}

	def _value_below_label(
		self,
		label,
		max_y_gap=80,
		validator=None,
	):
		candidates = []

		for word in self.words:
			if word["_page"] != label["_page"]:
				continue

			if word["y0"] < label["y1"]:
				continue

			dy = word["y0"] - label["y1"]
			if dy > max_y_gap:
				continue

			center = (word["x0"] + word["x1"]) / 2

			label_center = (label["x0"] + label["x1"]) / 2

			dx = abs(center - label_center)

			if dx > 180:
				continue

			value = word["_text"]

			if self._label_is_value(value):
				continue

			if validator and not validator(value):
				continue

			candidates.append(
				(
					dy + dx * 0.25,
					word,
				)
			)

		if not candidates:
			return None

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

		return candidates[0][1]["_text"]

	def _value_in_visual_region(
		self,
		label,
		other_labels,
		validator=None,
		max_y_gap=120,
	):
		"""
		Read a visual block below a label while excluding nearby
		unrelated columns.

		The horizontal region is based on the label's actual width
		and nearby content rather than a fixed document coordinate.
		"""

		page_words = [w for w in self.words if w["_page"] == label["_page"]]

		candidates = []

		for word in page_words:
			if word["y0"] < label["y1"] + 1:
				continue

			dy = word["y0"] - label["y1"]
			if dy > max_y_gap:
				continue

			# Prefer the same visual column.
			if word["x1"] < label["x0"] - 30:
				continue

			# Prevent crossing far-away columns.
			if word["x0"] > label["x1"] + 320:
				continue

			if self._label_is_value(word["_text"]):
				continue

			if validator and not validator(word["_text"]):
				continue

			candidates.append(word)

		if not candidates:
			return None

		candidates.sort(
			key=lambda w: (
				w["y0"],
				w["x0"],
			)
		)

		rows = self._build_rows(candidates)

		text_rows = []

		for row in rows:
			text = self._clean(row["text"])

			if not text:
				continue

			if self._looks_like_label(text):
				break

			text_rows.append(text)

			if len(text_rows) >= 5:
				break

		if not text_rows:
			return None

		return "\n".join(text_rows)

	def _lines_after_anchor(
		self,
		label,
		stop_aliases,
		max_distance=180,
	):
		stop_norm = [self._norm(x) for aliases in stop_aliases for x in aliases]

		page_rows = [
			row
			for row in self.rows
			if row["_page"] == label["_page"]
			and row["_y"] >= label["y1"]
			and row["_y"] - label["y1"] <= max_distance
		]

		result = []

		for row in page_rows:
			text = self._clean(row["text"])
			norm = self._norm(text)

			if any(x and x in norm for x in stop_norm):
				break

			# Keep only content overlapping the label's visual area
			# or extending naturally to the right.
			words = row["words"]

			if not words:
				continue

			min_x = min(w["x0"] for w in words)

			if min_x > label["x1"] + 450:
				continue

			result.append(text)

		return result

	# =========================================================
	# NUMERIC / TOKEN HELPERS
	# =========================================================

	def _extract_by_anchor(
		self,
		result,
		field,
		validator,
	):
		label = self._find_label(self.LABELS.get(field, []))

		if not label:
			return

		value = self._value_below_label(
			label,
			max_y_gap=70,
			validator=validator,
		)

		if value is not None:
			result[field] = value
			self._trace(
				field,
				value,
				label["_page"],
			)

	def _extract_numeric_anchor(
		self,
		result,
		field,
		aliases,
		integer=False,
	):
		label = self._find_label(aliases)
		if not label:
			return

		value = self._nearest_number(
			label,
			max_y_gap=70,
		)

		if value is None:
			return

		if integer:
			value = int(value)

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

	def _nearest_number(
		self,
		label,
		max_y_gap=100,
	):
		candidates = []

		for word in self.words:
			if word["_page"] != label["_page"]:
				continue

			if word["y0"] < label["y1"]:
				continue

			dy = word["y0"] - label["y1"]
			if dy > max_y_gap:
				continue

			value = self._float(word["_text"])
			if value is None:
				continue

			center1 = (label["x0"] + label["x1"]) / 2
			center2 = (word["x0"] + word["x1"]) / 2

			dx = abs(center1 - center2)

			if dx > 220:
				continue

			candidates.append(
				(
					dy + dx * 0.25,
					value,
				)
			)

		if not candidates:
			return None

		candidates.sort()
		return candidates[0][1]

	def _nearest_date(
		self,
		label,
		max_y_gap=100,
	):
		return self._nearest_token(
			label,
			lambda x: self._valid_date(x),
			max_y_gap,
		)

	def _nearest_time(
		self,
		label,
		max_y_gap=120,
	):
		return self._nearest_token(
			label,
			lambda x: bool(
				re.fullmatch(
					r"\d{1,2}:\d{2}(?::\d{2})?",
					x,
				)
			),
			max_y_gap,
		)

	def _nearest_token(
		self,
		label,
		validator,
		max_y_gap=100,
	):
		candidates = []

		for word in self.words:
			if word["_page"] != label["_page"]:
				continue

			if word["y0"] < label["y1"]:
				continue

			dy = word["y0"] - label["y1"]
			if dy > max_y_gap:
				continue

			value = word["_text"].strip()

			if not validator(value):
				continue

			center1 = (label["x0"] + label["x1"]) / 2
			center2 = (word["x0"] + word["x1"]) / 2

			dx = abs(center1 - center2)

			if dx > 250:
				continue

			candidates.append(
				(
					dy + dx * 0.25,
					value,
				)
			)

		if not candidates:
			return None

		candidates.sort()
		return candidates[0][1]

	def _near_unit(self, aliases):
		label = self._find_label(aliases)
		if not label:
			return ""

		allowed = {
			"KGS",
			"KG",
			"NOS",
			"SET",
			"PCS",
			"PKG",
			"MT",
		}

		value = self._nearest_token(
			label,
			lambda x: x.upper() in allowed,
			70,
		)

		return value.upper() if value else ""

	def _find_iec(self):
		label = self._find_label(["IEC/BR", "IEC"])

		if not label:
			return ""

		candidates = []
		label_center = (label["x0"] + label["x1"]) / 2

		for word in self.words:
			if word["_page"] != label["_page"]:
				continue

			if word["y0"] < label["y1"] - 2:
				continue

			dy = word["y0"] - label["y1"]

			if dy > 90:
				continue

			value = word["_text"].strip()

			if not re.fullmatch(r"\d{10,12}", value):
				continue

			word_center = (word["x0"] + word["x1"]) / 2
			dx = abs(word_center - label_center)

			if dx > 120:
				continue

			candidates.append(
				(
					dy + dx * 0.35,
					value,
				)
			)

		if not candidates:
			return ""

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

	def _find_iec_branch(self, iec):
		for word in self.words:
			value = word["_text"].strip()

			if re.fullmatch(
				r"\d{10,12}",
				value,
			):
				if value.startswith(iec):
					return value[len(iec) :] or "0"

		return "0"

	# =========================================================
	# VALIDATORS
	# =========================================================

	def _validator_for(self, field):
		if field == "shipping_bill_date":
			return self._valid_date

		if field == "port_code":
			return self._valid_port

		if field == "shipping_bill_no":
			return self._valid_sb_number

		if field in {
			"cb_code",
			"p_1b_ad_code",
		}:
			return self._valid_code

		return None

	@staticmethod
	def _valid_port(value):
		value = value.strip().upper()
		return bool(
			re.fullmatch(
				r"[A-Z]{2,5}\d{1,4}",
				value,
			)
		)

	@staticmethod
	def _valid_sb_number(value):
		value = value.strip()
		return bool(
			re.fullmatch(
				r"\d{6,12}",
				value,
			)
		)

	@staticmethod
	def _valid_cb_code(value):
		"""Validate Indian Customs Broker code; reject numeric pincodes."""
		value = str(value or "").strip().upper()
		return bool(re.fullmatch(r"[A-Z]{5}\d{4}[A-Z]{3}\d{3}", value))

	@staticmethod
	def _valid_code(value):
		value = value.strip()
		return bool(
			re.fullmatch(
				r"[A-Z0-9]{4,20}",
				value.upper(),
			)
		)

	@staticmethod
	def _valid_date(value):
		value = value.strip().upper()

		patterns = [
			r"\d{1,2}-[A-Z]{3}-\d{2,4}",
			r"\d{1,2}/\d{1,2}/\d{2,4}",
			r"\d{4}-\d{1,2}-\d{1,2}",
		]

		return any(re.fullmatch(p, value) for p in patterns)

	@staticmethod
	def _location_validator(value):
		value = value.strip()

		if not value:
			return False

		if re.fullmatch(
			r"[YN]",
			value.upper(),
		):
			return False

		if re.fullmatch(
			r"\d+",
			value,
		):
			return False

		return True

	# =========================================================
	# NORMALIZATION
	# =========================================================

	def _normalize(self, result):
		for field in self.DATE_FIELDS:
			value = result.get(field)

			if value:
				result[field] = self._format_date(value)

		for field in self.NUMBER_FIELDS:
			value = result.get(field)

			if value in ("", None):
				if field in {
					"gwt",
					"p_1c_fob_val",
					"p_1c_com",
					"p_1c_freight",
					"p_1c_deductions",
					"p_1c_insurance",
					"p_1c_p_c",
					"p_1c_discount",
					"p_1d_dbk",
					"p_1d_rodtep_amt",
					"p_1d_rosctle_amt",
				}:
					result[field] = 0.0
				else:
					result[field] = 0

		for table in self.CHILD_TABLES:
			for record in result.get(table, []):
				for key, value in list(record.items()):
					if value is None:
						continue

					if key.endswith(("_dt", "_date")):
						if value:
							record[key] = self._format_date(str(value))

					elif value == "":
						if key == "p_4d_info_msr":
							continue
						if self._looks_numeric_key(key):
							record[key] = 0.0

		# Clean accidental whitespace.
		for key, value in result.items():
			if isinstance(value, str):
				if key in {
					"port_of_loading",
					"cntry_of_finaldstn",
					"state_of_origin",
					"port_of_finaldstn",
					"port_of_discharge",
					"cntry_of_discharge",
				}:
					result[key] = self._clean_location(value)
				else:
					result[key] = self._clean(value)

		if result.get("gwt_unit"):
			result["gwt_unit"] = str(result["gwt_unit"]).upper().strip().rstrip(".")

		# Keep integer count fields as integers.
		for field in {"inv", "item", "pkg", "cont"}:
			value = result.get(field)
			if isinstance(value, float) and value.is_integer():
				result[field] = int(value)

	# =========================================================
	# TABLE HELPERS
	# =========================================================

	@staticmethod
	def _clean_table(table):
		result = []

		for row in table:
			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 _header_row(self, rows, aliases):
		normalized = [self._norm(x) for x in aliases]

		for index, row in enumerate(rows[:5]):
			text = self._norm(" ".join(str(x or "") for x in row))

			score = sum(1 for alias in normalized if alias and alias in text)

			if score:
				return index

		return None

	def _make_headers(self, row):
		headers = []

		for index, cell in enumerate(row):
			header = self._norm(str(cell or ""))

			if not header:
				header = f"column {index + 1}"

			headers.append(header)

		return headers

	def _row_values(self, headers, row):
		values = {}

		for index, header in enumerate(headers):
			if index >= len(row):
				values[header] = ""
			else:
				values[header] = "" if row[index] is None else str(row[index]).strip()

		return values

	def _set_first(
		self,
		record,
		target,
		values,
		aliases,
		number=False,
		date=False,
	):
		def compact(value):
			return re.sub(r"[^a-z0-9]+", "", str(value or "").lower())

		normalized_aliases = [compact(x) for x in aliases]

		for header, value in values.items():
			if not value:
				continue

			header_compact = compact(header)
			header_compact = re.sub(r"^\d+", "", header_compact)
			if not any(
				alias and (alias in header_compact or header_compact in alias) for alias in normalized_aliases
			):
				continue

			if date:
				value = self._format_date(value)
			elif number:
				number_value = self._float(value)
				value = number_value if number_value is not None else value

			record[target] = value
			return

	def _collapse_item_continuations(self, records):
		"""
		Collapse wrapped/duplicated item rows.

		A genuine Shipping Bill item is anchored by an 8-digit CTH.
		pdfplumber may additionally return:
		- continuation rows without CTH;
		- the same CTH with split invoice/item identifiers;
		- duplicate visual rows containing the same CTH.

		Those fragments are merged into the active item instead of being
		returned as additional Item Detail records.
		"""
		if not records:
			return []

		def valid_cth(value):
			text = str(value or "")
			match = re.search(r"(?<!\d)(\d{8})(?!\d)", text)
			return match.group(1) if match else ""

		def normalize_id(value):
			text = self._clean(str(value or ""))
			# "1 1", "1-1", and "11" are handled as the same numeric
			# identifier where pdfplumber split a cell.
			digits = re.sub(r"\D", "", text)
			return digits or text.upper()

		def identity(record):
			return (
				normalize_id(record.get("p_3a_invsno")),
				normalize_id(record.get("p_3a_itemsn")),
			)

		def core_values(record):
			return any(
				str(record.get(field, "")).strip()
				for field in (
					"p_3a_qty",
					"p_3a_uqc",
					"p_3a_rate",
					"p_3a_value",
					"p_3a_fob",
					"p_3a_pmv",
				)
			)

		def description(record):
			return self._norm(
				re.sub(
					r"[^A-Za-z0-9 ]+",
					" ",
					str(record.get("p_3a_item_desc", "") or ""),
				)
			)

		def fragment_like(record, active):
			text = description(record)
			active_text = description(active)

			if not text:
				return True

			# A short description fragment such as "IN GM GASM" is not
			# a standalone item when its CTH/identity matches the active row.
			if text in active_text or active_text in text:
				return True

			# Shared beginning/end words are a strong indication of a
			# wrapped duplicate from the same table row.
			words_a = text.split()
			words_b = active_text.split()

			if len(words_a) <= 6:
				if words_a and words_a[:2] == words_b[:2]:
					return True

			return False

		collapsed = []
		active = None

		for raw in records:
			record = dict(raw)
			cth = valid_cth(record.get("p_3a_cth"))

			if active is None:
				if not cth:
					continue

				record["p_3a_cth"] = cth
				record["doctype"] = "Item Detail"
				active = record
				collapsed.append(active)
				continue

			active_cth = valid_cth(active.get("p_3a_cth"))

			if not cth:
				self._merge_item_fragment(active, record)
				continue

			rid = identity(record)
			aid = identity(active)

			same_identity = rid != ("", "") and aid != ("", "") and rid == aid

			same_cth = cth == active_cth

			# Strong duplicate rule:
			# same CTH + same invoice/item identity, even if one of the
			# identifiers was split differently by pdfplumber.
			strong_duplicate = same_cth and (same_identity or rid == ("", "") or aid == ("", ""))

			# Same CTH + fragmented description is also a duplicate.
			fragment_duplicate = same_cth and fragment_like(record, active)

			# Same CTH + no meaningful numeric/core fields is a continuation.
			continuation_duplicate = same_cth and not core_values(record)

			if strong_duplicate or fragment_duplicate or continuation_duplicate:
				self._merge_item_fragment(active, record)
				continue

			# A new valid CTH is a new genuine item.
			record["p_3a_cth"] = cth
			record["doctype"] = "Item Detail"
			active = record
			collapsed.append(active)

		# Final safety pass: normalize identifiers and remove any remaining
		# duplicate CTH records with the same invoice/item identity.
		final = []
		seen = set()

		for record in collapsed:
			cth = valid_cth(record.get("p_3a_cth"))
			if not cth:
				continue

			rid = identity(record)
			key = (cth, rid)

			if key in seen:
				for existing in final:
					if valid_cth(existing.get("p_3a_cth")) == cth and identity(existing) == rid:
						self._merge_item_fragment(existing, record)
						break
				continue

			seen.add(key)
			record["p_3a_cth"] = cth
			record["doctype"] = "Item Detail"
			final.append(record)

		return final

	def _merge_item_fragment(self, target, fragment):
		"""Merge continuation data without overwriting existing values."""
		description = self._clean(str(fragment.get("p_3a_item_desc", "") or ""))
		existing = self._clean(str(target.get("p_3a_item_desc", "") or ""))

		if description and description not in existing:
			target["p_3a_item_desc"] = (existing + " " + description).strip()

		for field, value in fragment.items():
			if field in {"doctype", "p_3a_cth", "p_3a_item_desc"}:
				continue
			if value in ("", None):
				continue
			if target.get(field) in ("", None):
				target[field] = value

	def _merge_records_by_key(self, records, key_fields):
		merged = {}
		loose = []

		for record in records:
			key = tuple(str(record.get(field, "")).strip() for field in key_fields)
			if not any(key):
				loose.append(record)
				continue

			if key not in merged:
				merged[key] = dict(record)
				continue

			for field, value in record.items():
				if value in ("", None, 0, 0.0):
					continue
				if merged[key].get(field) in ("", None, 0, 0.0):
					merged[key][field] = value

		return list(merged.values()) + loose

	def _merge_invoice_records(self, records):
		# Prefer invoice number as the correlation key. For fragmented
		# tables that contain no invoice number, inv_sn is used.
		indexed = {}
		loose = []

		for record in records:
			inv_no = str(record.get("p_2a_inv_no", "")).strip()
			inv_sn = str(record.get("inv_sn", "")).strip()
			key = inv_no or inv_sn
			if not key:
				loose.append(record)
				continue

			if key not in indexed:
				indexed[key] = dict(record)
				continue

			for field, value in record.items():
				if value in ("", None, 0, 0.0):
					continue
				if indexed[key].get(field) in ("", None, 0, 0.0):
					indexed[key][field] = value

		if loose and len(indexed) == 1:
			target = next(iter(indexed.values()))
			for record in loose:
				for field, value in record.items():
					if field == "doctype" or value in ("", None, 0, 0.0):
						continue
					if target.get(field) in ("", None, 0, 0.0):
						target[field] = value
			return list(indexed.values())

		return list(indexed.values()) + loose

	def _deduplicate_records(self, records):
		seen = set()
		result = []

		for record in records:
			key = repr(sorted(record.items()))

			if key in seen:
				continue

			seen.add(key)
			result.append(record)

		return result

	# =========================================================
	# TEXT / VALUE CLEANING
	# =========================================================

	@staticmethod
	def _norm(value):
		value = str(value or "").upper()
		value = value.replace("&", " AND ")
		value = re.sub(
			r"[^A-Z0-9]+",
			" ",
			value,
		)
		return (
			re.sub(
				r"\s+",
				" ",
				value,
			)
			.strip()
			.lower()
		)

	@staticmethod
	def _clean(value):
		if value is None:
			return ""

		value = str(value)
		value = value.replace(
			"\xa0",
			" ",
		)
		value = re.sub(
			r"[ 	]+",
			" ",
			value,
		)

		return value.strip()

	@staticmethod
	def _repair_country_text(value):
		"""Repair conservative leading-character OCR/layout noise in countries."""
		value = ShippingBillParser._clean(value)
		if not value:
			return ""

		# A merged label/value boundary can prepend one stray N. Keep the
		# correction narrow so NIGERIA, NAMIBIA, etc. remain untouched.
		known_multiword_countries = {
			"SAUDI ARABIA", "UNITED ARAB EMIRATES", "UNITED STATES",
			"UNITED KINGDOM", "SOUTH AFRICA", "SOUTH KOREA", "NORTH KOREA",
			"NEW ZEALAND", "SRI LANKA", "COSTA RICA", "CZECH REPUBLIC",
			"DOMINICAN REPUBLIC", "EL SALVADOR", "PAPUA NEW GUINEA",
			"TRINIDAD AND TOBAGO", "BOSNIA AND HERZEGOVINA", "SAINT KITTS AND NEVIS",
			"SAINT LUCIA", "SAINT VINCENT AND THE GRENADINES", "ANTIGUA AND BARBUDA",
			"EQUATORIAL GUINEA", "BURKINA FASO", "SIERRA LEONE", "SOUTH SUDAN",
			"WESTERN SAHARA", "NORTH MACEDONIA", "MARSHALL ISLANDS", "SOLOMON ISLANDS",
			"CAPE VERDE", "IVORY COAST", "TIMOR LESTE", "BRUNEI DARUSSALAM",
			"VATICAN CITY", "PALESTINE STATE",
		}

		upper = value.upper()
		for country in known_multiword_countries:
			if upper == "N" + country:
				return value[1:].lstrip()

		return value

	def _clean_location(self, value):
		"""
		Clean location values without destroying legitimate word spacing.

		pdfplumber layout extraction can produce micro-spaces inside a word,
		e.g. "M aharashtra". Only a capital-letter + whitespace + lowercase
		word pattern is joined; normal uppercase multi-word locations such as
		"SAUDI ARABIA" remain unchanged.
		"""
		lines = []

		for line in str(value).splitlines():
			line = self._clean(line)

			if not line:
				continue

			line = re.sub(
				r"(?<=[A-Z])\s+(?=[a-z]{2,})",
				"",
				line,
			)

			# Also repair repeated one-letter splits such as:
			# "M aharashtra" -> "Maharashtra"
			line = re.sub(
				r"\b([A-Z])\s+([a-z]{3,})\b",
				r"\1\2",
				line,
			)

			# Remove obvious section labels accidentally adjacent to
			# the actual value.
			line = re.sub(
				r"^\d+\.\s*(PORT|COUNTRY|STATE)\s+[^ ]+\s*",
				"",
				line,
				flags=re.I,
			).strip()

			if line:
				lines.append(line)

		return "\n".join(self._repair_country_text(x) for x in lines)

	def _clean_party_value(self, field, value):
		value = self._clean(value)

		if field == "p_1b_gstin":
			match = re.search(
				r"\b\d{2}[A-Z]{5}\d{4}[A-Z][A-Z0-9]Z[A-Z0-9]\b",
				value.upper(),
			)
			if match:
				return match.group(0)

		if field == "p_1b_ad_code":
			match = re.search(
				r"\b\d{6,10}\b",
				value,
			)
			if match:
				return match.group(0)

		if field in {"p_1b_exporter_name", "p_1b_consignee_name"}:
			value = re.sub(r"^(?:P|O|SA)\s+(?=[A-Z])", "", value, flags=re.I).strip()

		if field == "p_1b_forex_ac_no":
			match = re.search(
				r"\b[A-Z0-9X]{6,20}\b",
				value.upper(),
			)
			if match:
				return match.group(0)

		return value

	@staticmethod
	def _looks_like_label(value):
		norm = re.sub(
			r"\s+",
			" ",
			str(value or "").upper(),
		).strip()

		return bool(
			re.match(
				r"^\d+\.\s*[A-Z][A-Z0-9 /&.'-]{3,}$",
				norm,
			)
		)

	@staticmethod
	def _is_section_heading(value):
		norm = value.upper()

		return any(
			x in norm
			for x in [
				"PART -",
				"PART I",
				"PART II",
				"PART III",
				"PART IV",
				"DECLARATION",
				"DIGITALLY SIGNED",
				"SCAN QR",
			]
		)

	@staticmethod
	def _safe_key(value):
		value = (
			re.sub(
				r"[^a-zA-Z0-9]+",
				"_",
				value,
			)
			.strip("_")
			.lower()
		)

		if not value:
			return ""

		if value[0].isdigit():
			value = "column_" + value

		return value

	@staticmethod
	def _normalize_cell(value, key):
		value = str(value).strip()

		if key.endswith(("_dt", "_date")):
			return ShippingBillParser._format_date(value)

		if ShippingBillParser._looks_numeric_key(key):
			number = ShippingBillParser._float(value)
			if number is not None:
				return number

		return value

	@staticmethod
	def _looks_numeric_key(key):
		key = key.lower()

		return any(
			x in key
			for x in [
				"amt",
				"amount",
				"value",
				"val",
				"qty",
				"quantity",
				"rate",
				"fob",
				"pmv",
				"cess",
				"freight",
				"discount",
				"insurance",
				"deduct",
				"commission",
				"com",
				"pkg",
				"container",
				"measure",
			]
		)

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

	@staticmethod
	def _doctype_for_table(table_name):
		words = table_name.split("_")

		return " ".join(word.capitalize() for word in words).replace(
			"Details",
			"Detail",
		)

	# =========================================================
	# NUMBER / DATE UTILITIES
	# =========================================================

	@staticmethod
	def _num(value):
		try:
			return float(value or 0)
		except (TypeError, ValueError):
			return 0.0

	@staticmethod
	def _integer(value):
		if value is None:
			return None

		value = (
			str(value)
			.replace(
				",",
				"",
			)
			.strip()
		)

		match = re.fullmatch(
			r"\d+",
			value,
		)

		if not match:
			return None

		return int(value)

	@staticmethod
	def _float(value):
		if value is None:
			return None

		value = str(value)
		value = value.replace(
			",",
			"",
		).strip()

		match = re.fullmatch(
			r"-?\d+(?:\.\d+)?",
			value,
		)

		if not match:
			return None

		try:
			return float(value)
		except ValueError:
			return None

	@staticmethod
	def _format_date(value):
		if not value:
			return ""

		value = str(value).strip().upper()

		# Remove trailing punctuation.
		value = value.strip(".,;:")

		formats = [
			"%d-%b-%y",
			"%d-%b-%Y",
			"%d/%m/%Y",
			"%d/%m/%y",
			"%d-%m-%Y",
			"%d-%m-%y",
			"%Y-%m-%d",
			"%d.%m.%Y",
		]

		for fmt in formats:
			try:
				return datetime.strptime(
					value,
					fmt,
				).strftime("%Y-%m-%d")
			except ValueError:
				continue

		return value

	@staticmethod
	def _format_time(value):
		if not value:
			return ""

		match = re.search(
			r"\b(\d{1,2}):(\d{2})(?::(\d{2}))?\b",
			str(value),
		)

		if not match:
			return str(value).strip()

		hour, minute, second = match.groups()

		return f"{int(hour):02d}:{minute}:{second or '00'}"

	# =========================================================
	# TRACE
	# =========================================================

	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("ShippingBillParser (pdfplumber, deterministic, non-AI) loaded successfully")

