# -*- coding: utf-8 -*-
# Part of ds_amcs_extract. See LICENSE file for full copyright and licensing details.

"""
Account Move Extension
----------------------
Extends account.move with OCR processing capabilities using the OCR mixin.
"""

import json
import logging
import copy
import re
import uuid
from odoo import _, api, Command, fields, models
from odoo.exceptions import UserError

_logger = logging.getLogger(__name__)


class AccountMove(models.Model):
    """
    Override account.move to use custom OCR service.
    """

    _inherit = "account.move"

    invoice_date = fields.Date(string='Invoice/Bill Date', required=False, tracking=True)
    # date = fields.Date(string='Date', required=False, tracking=True)

    def _get_view(self, view_id=None, view_type='form', **options):
        """Override _get_view to force invoice_date and date to be NOT required."""
        arch, view = super()._get_view(view_id=view_id, view_type=view_type, **options)
        if view_type == 'form':
            from lxml import etree
            for field_name in ['invoice_date', 'date']:
                for field_node in arch.xpath(f"//field[@name='{field_name}']"):
                    field_node.set('required', '0')
                for label_node in arch.xpath(f"//label[@for='{field_name}']"):
                    label_node.attrib.pop('required', None)
        return arch, view

    # OCR fields (consolidated from ocr.mixin)
    ocr_extract_state = fields.Selection(
        [
            ("no_extract_requested", "No Extract Requested"),
            ("waiting_extraction", "Waiting Extraction"),
            ("extract_not_ready", "Extract Not Ready"),
            ("waiting_validation", "Waiting Validation"),
            ("to_validate", "To Validate"),
            ("done", "Done"),
            ("error_status", "Error"),
            ("not_enough_credit", "Not Enough Credit"),
        ],
        string="OCR State",
        default="no_extract_requested",
        tracking=True,
    )
    ocr_extract_request_uuid = fields.Char(string="Request UUID", copy=False)
    ocr_extract_document_id = fields.Many2one("ir.attachment", string="OCR Document", copy=False)
    ocr_extract_last_check = fields.Datetime(string="Last Status Check", copy=False)
    ocr_extract_result_json = fields.Text(string="OCR Result", copy=False)
    ocr_extract_validated_on = fields.Datetime(string="Validated On", copy=False)
    ocr_column_mapping = fields.Text(related='partner_id.ocr_column_mapping', readonly=False, string="Modèle de colonnes OCR")

    extract_error_message = fields.Char(compute="_compute_extract_error_message", string="Extract Error")
    extract_can_show_send_button = fields.Boolean(compute="_compute_extract_can_show_send_button", string="Can Send")
    extract_can_show_banners = fields.Boolean(compute="_compute_extract_can_show_banners", string="Can Show Banners")

    @api.depends("ocr_extract_state")
    def _compute_extract_error_message(self):
        for rec in self:
            rec.extract_error_message = ""

    @api.depends("ocr_extract_state", "move_type", "state")
    def _compute_extract_can_show_send_button(self):
        for rec in self:
            rec.extract_can_show_send_button = (
                rec.state == "draft"
                and rec.move_type in ("in_invoice", "in_refund", "in_receipt", "out_invoice", "out_refund", "out_receipt")
                and rec.ocr_extract_state in ("no_extract_requested", "error_status", "not_enough_credit")
            )

    @api.depends("ocr_extract_state", "move_type")
    def _compute_extract_can_show_banners(self):
        for rec in self:
            rec.extract_can_show_banners = (
                rec.move_type in ("in_invoice", "in_refund", "in_receipt", "out_invoice", "out_refund", "out_receipt")
                and rec.ocr_extract_state not in ("no_extract_requested", "done")
            )

    @api.onchange("ocr_extract_state")
    def _onchange_ocr_extract_state(self):
        if self.ocr_extract_state != self.extract_state:
            self.extract_state = self.ocr_extract_state

    def write(self, vals):
        if "ocr_extract_state" in vals and "extract_state" not in vals:
            vals["extract_state"] = vals["ocr_extract_state"]
        if "ocr_extract_request_uuid" in vals and "extract_document_uuid" not in vals:
            vals["extract_document_uuid"] = vals["ocr_extract_request_uuid"]
        if "ocr_extract_document_id" in vals and "extract_attachment_id" not in vals:
            vals["extract_attachment_id"] = vals["ocr_extract_document_id"]
        
        res = super().write(vals)

        # Auto-trigger OCR if message_main_attachment_id or attachment_ids changes and digitalization is auto
        if any(f in vals for f in ["message_main_attachment_id", "attachment_ids"]):
            for move in self:
                if move.ocr_extract_state == 'no_extract_requested':
                    if move.move_type in ("in_invoice", "in_refund") and self.env.company.extract_in_invoice_digitalization_mode == 'auto_send':
                        move.with_context(ocr_auto_trigger=True).send_for_ocr_extraction()
                    elif move.move_type in ("out_invoice", "out_refund", "out_receipt") and self.env.company.extract_out_invoice_digitalization_mode == 'auto_send':
                        move.with_context(ocr_auto_trigger=True).send_for_ocr_extraction()
        return res
 
    @api.model_create_multi
    def create(self, vals_list):
        moves = super().create(vals_list)
        for move in moves:
            if move.ocr_extract_state == 'no_extract_requested':
                if move.move_type in ("in_invoice", "in_refund") and self.env.company.extract_in_invoice_digitalization_mode == 'auto_send':
                    move.with_context(ocr_auto_trigger=True).send_for_ocr_extraction()
                elif move.move_type in ("out_invoice", "out_refund", "out_receipt") and self.env.company.extract_out_invoice_digitalization_mode == 'auto_send':
                    move.with_context(ocr_auto_trigger=True).send_for_ocr_extraction()
        return moves

    def action_post(self):
        """
        RAG: When posting the invoice, send the validated data back to the OCR server.
        """
        _logger.info("AccountMove action_post triggered for moves: %s", self.ids) # Trace le début du postage des factures
        res = super().action_post()
        for move in self:
            # Send feedback if we have OCR result, regardless of exact state to be safer
            if move.ocr_extract_result_json:
                _logger.info("Attempting to send RAG feedback for move %s", move.id) # Indique l'envoi du feedback RAG pour un mouvement
                try:
                    move._send_rag_feedback()
                    if move.ocr_extract_state != 'done':
                        move.ocr_extract_state = "done"
                except Exception as e:
                    _logger.warning("Failed to send RAG feedback for move %s: %s", move.id, e) # Signalise l'échec de l'envoi du feedback
            else:
                _logger.debug("No OCR result json for move %s, skipping RAG feedback", move.id) # Indique l'absence de données OCR pour le feedback
        if len(self) == 1 and self.ocr_extract_result_json:
            return {
                'type': 'ir.actions.client',
                'tag': 'reload',
            }
        return res

    def _send_rag_feedback(self):
        """Construct and send validated JSON to OCR server."""
        self.ensure_one()
        client = self._get_ocr_client()
        iap_account = self.env['iap.account'].get_ocr_account()
        if not iap_account:
            return
 
        # Extract raw text from previous OCR result
        ocr_res = json.loads(self.ocr_extract_result_json)
        raw_text = "\n".join(ocr_res.get("lines", []))
        
        if not raw_text:
            return
 
        # Construct validated JSON from current Odoo fields
        tax_summary = {}
        for line in self.invoice_line_ids:
            if line.tax_ids:
                rate = str(line.tax_ids[0].amount)
                tax_summary[rate] = tax_summary.get(rate, 0.0) + line.price_subtotal
 
        validated_data = {
            "supplier": self.partner_id.name,
            "invoice_id": self.ref,
            "date": str(self.invoice_date) if self.invoice_date else False,
            "VAT_Number": self.partner_id.vat,
            "total_ht": self.amount_untaxed,
            "total_ttc": self.amount_total,
            "currency": self.currency_id.name,
            "tax_summary": tax_summary,
            "invoice_lines": []
        }
        
        for line in self.invoice_line_ids:
            if line.display_type == 'product' or not line.display_type:
                validated_data["invoice_lines"].append({
                    "description": line.name,
                    "quantity": line.quantity,
                    "unit_price": line.price_unit,
                    "discount": line.discount,
                    "tax_rate": line.tax_ids[0].amount if line.tax_ids else 0.0
                })
 
        # Send to server via JSON-RPC
        params = {
            "dbuuid": self.env['ir.config_parameter'].sudo().get_param('database.uuid'),
            "account_token": iap_account.account_token,
            "raw_text": raw_text,
            "validated_json": validated_data,
            "supplier_name": self.partner_id.name,
            "vat_number": self.partner_id.vat,
        }
        
        # Use IAP JSON-RPC or direct requests
        try:
             # We assume the ocr_client has a method for this or we do it directly
             endpoint = self.env['ir.config_parameter'].sudo().get_param('iap_extract_endpoint', 'https://ocr.amcs.tn')
             url = f"{endpoint}/ocr/1/validate"
             from odoo.addons.iap.tools import iap_tools
             iap_tools.iap_jsonrpc(url, params=params)
             _logger.info("RAG feedback sent successfully for move %s", self.id) # Confirme l'envoi réussi du feedback RAG
        except Exception as e:
             _logger.error("Error sending RAG feedback: %s", e) # Log l'erreur lors de l'envoi du feedback RAG

    @api.constrains("ocr_extract_state")
    def _check_extract_state_transition(self):
        """Validate that state transitions follow the defined order."""
        VALID_TRANSITIONS = {
            "no_extract_requested": ["no_extract_requested", "waiting_extraction", "error_status", "not_enough_credit"],
            "waiting_extraction": ["waiting_extraction", "extract_not_ready", "waiting_validation", "error_status"],
            "extract_not_ready": ["extract_not_ready", "waiting_validation", "error_status"],
            "waiting_validation": ["waiting_validation", "to_validate", "error_status"],
            "error_status": ["error_status", "no_extract_requested", "waiting_extraction"],
        }
        for rec in self:
            if rec.ocr_extract_state not in VALID_TRANSITIONS.get(rec._origin.ocr_extract_state or "no_extract_requested", ["no_extract_requested"]):
                pass # Allow for now to avoid blocking

    def action_send_for_digitization(self):
        """Standard Odoo button hook with UI reload."""
        self.send_for_ocr_extraction()
        return []

    def action_manual_send_for_digitization(self):
        """Standard Odoo manual button hook with UI reload."""
        self.send_for_ocr_extraction()
        return []

    def action_reload_ai_data(self):
        """Standard Odoo 'Reload AI Data' button hook."""
        self.send_for_ocr_extraction()
        return []

    def _get_nested_value(self, data, key, default=None):
        if not isinstance(data, dict):
            return default
        value = data.get(key, default)
        if isinstance(value, dict):
            if "selected_value" in value:
                return value.get("selected_value", {}).get("content", default)
            if "value" in value:
                return value.get("value", default)
        return value if value is not None else default

    _INVOICE_PATTERN_MAP = {
        "invoice_id": [
            re.compile(r"(?:facture\s+(?:n[°.:]?\s*:?\s*)?|n[°.]\s*(?:facture|invoice)\s*:?\s*|n[°.]\s*:\s*|invoice\s*(?:no|number|num|#)\s*:?\s*|facture\s+|code\s*pi[èe]ce\s*:?\s*)", re.I),
        ],
        "date": [
            re.compile(r"(?:nabeul\s*,?\s*le\s*:?\s*|date\s*(?:d[e']?\s*)?(?:facturation|facture|of\s*invoice)?\s*:?\s*|invoice\s*date\s*:?\s*|dat[eé]\s*(?:de\s*)?(?:facturation|facture)\s*:?\s*|le\s*:?\s*)", re.I),
        ],
        "due_date": [
            re.compile(r"(?:due\s*date\s*:?\s*|date\s*d[ueé]e\s*:?\s*|payment\s*due\s*:?\s*|[éeé]ch[eé]ance\s*:?\s*)", re.I),
        ],
        "supplier": [
            re.compile(r"(?:supplier|vendor|seller|fournisseur|soci[eé]t[eé]|s\.a\.r\.[lLi]|from\s*:)", re.I),
        ],
        "customer": [
            re.compile(r"(?:nom\s*client|client\s*:|customer|acheteur|destinataire)", re.I),
        ],
        "VAT_Number": [
            re.compile(r"(?:n\s*°?\s*tva\s*:?\s*|n\s*tva\s*:?\s*|tva\s*:?\s*|vat\s*(?:no|number|#)\s*:?\s*|tax\s*id\s*:?\s*|siret\s*:?\s*|nif\s*:?\s*|mf\s*:?\s*|matricule\s*fiscale?\s*:?\s*)", re.I),
        ],
        "total_ttc": [
            re.compile(r"(?:net\s*[aà]\s*payer|total\s*t\.?t\.?c|montant\s*total\s*ttc|total\s*general|grand\s*total|total\b(?:\s+t\.?t\.?c)?)", re.I),
        ],
        "total_ht": [
            re.compile(r"(?:total\s*net\s*h\.?t|total\s*h\.?t\b|montant\s*net\s*ht|montant\s*hors\s*tax(?:e)?s?|subtotal|sub\s*total|total\s*amount(?:\s+ht)?)", re.I),
        ],
        "total_tva": [
            re.compile(r"(?:montant\s*tva|total\s*tva|total\s*(?:tax|vat)|montant\s*tva)", re.I),
        ],
        "currency": [
            re.compile(r"(?:(?:currency|devise|monnaie)\s*:?\s*(?:TND|DT|EUR|USD|GBP|MAD|DZD|XOF|XAF|CHF|[€$£])|(?:TND|EUR|USD|GBP)\s*$)", re.I),
        ],
    }

    _DATE_PATTERNS = [
        re.compile(r"(\d{1,2}[/.-]\d{1,2}[/.-]\d{2,4})"),
        re.compile(r"(\d{4}[/.-]\d{1,2}[/.-]\d{1,2})"),
    ]

    _AMOUNT_PATTERN = re.compile(r"[\d]+(?:[.,]\d+)?")

    @staticmethod
    def _is_garbage(text):
        if not text or len(text.strip()) < 2:
            return True
        s = text.strip()
        if re.match(r"^\d{1,4}[/.\-]\d{1,2}[/.\-]\d{2,4}$", s):
            return False
        if re.match(r"^[\d\s]+(?:[.,]\d+)?\s*(?:DT|EUR|USD|TND|€|\$|£)\s*$", s, re.I):
            return False
        if re.match(r"^[\d\s]+(?:[.,]\d{1,3})?\s*$", s):
            return False
        alpha = sum(1 for c in s if c.isalpha())
        digits = sum(1 for c in s if c.isdigit())
        if alpha > 0 and digits > 0 and alpha + digits >= 4:
            return False
        if alpha == 0 and not re.search(r"\d{2,}[.,]\d{2,}", s):
            if not re.match(r"\d+[/.\-]\d+", s):
                return True
        ratio = alpha / max(len(s), 1)
        if ratio < 0.2 and len(s) > 6:
            if not re.search(r"\d{1,4}[/.\-]\d{1,2}[/.\-]\d{2,4}", s) and not re.match(r"\d+[/.\-]\d+", s):
                return True
        lower = s.lower()
        vowels = sum(1 for c in lower if c in "aeiouyàâéèêëîïôùû")
        if alpha > 3 and vowels == 0 and len(s) > 5:
            return True
        if re.match(r"^[a-z\d]+\d[a-z\d]*$", s, re.I) and len(s) <= 10 and alpha <= 3:
            return True
        return False

    _LOWER_HEADER_WORDS = frozenset(["description", "quantite", "quantité", "quantity", "prix", "price",
        "montant", "amount", "taxes", "tax", "tva", "base", "subtotal", "sous-total",
        "unitaire", "unit", "designation", "ref", "reference", "code", "libelle",
        "remise", "discount", "total", "pu ht", "mont ht", "montant ht", "prix unitaire"])

    @staticmethod
    def _is_valid_field_value(field_key, value):
        if not value or len(value.strip()) < 2:
            return False
        v = value.strip()
        lower = v.lower().strip()
        if lower in AccountMove._LOWER_HEADER_WORDS:
            return False
        if field_key == "date":
            return bool(re.search(r"\d{1,4}[/.\-]\d{1,2}[/.\-]\d{2,4}", v))
        if field_key == "due_date":
            return bool(re.search(r"\d{1,4}[/.\-]\d{1,2}[/.\-]\d{2,4}", v))
        if field_key == "invoice_id":
            return bool(re.search(r"\d", v)) and len(v) <= 30 and lower not in AccountMove._LOWER_HEADER_WORDS
        if field_key in ("total_ttc", "total_ht", "total_tva"):
            try:
                float(v.replace(",", ".").replace(" ", ""))
                return True
            except (ValueError, TypeError):
                return len(v) <= 20 and not any(w in v.lower() for w in ["arrete", "facture", "dinars", "millimes", "somme"])
        if field_key == "VAT_Number":
            return bool(re.search(r"[A-Z]{2,}\d|[A-Z]\d{3,}|\d{4,}[A-Z]", v)) and len(v) <= 20
        if field_key == "currency":
            return bool(re.match(r"^(EUR|USD|GBP|TND|DT|MAD|DZD|XOF|XAF|CHF)$", v, re.I))
        if field_key == "customer":
            return len(v) <= 60 and not any(c.isdigit() and c != ' ' for c in v[:5])
        return True

    @staticmethod
    def _normalize_date(date_str):
        if not date_str:
            return None
        date_str = date_str.strip()
        for fmt in ("%d/%m/%Y", "%d-%m-%Y", "%d.%m.%Y", "%Y-%m-%d", "%Y/%m/%d", "%Y.%m.%d"):
            try:
                from datetime import datetime as dt
                return dt.strptime(date_str, fmt).strftime("%Y-%m-%d")
            except (ValueError, TypeError):
                continue
        m = re.match(r"(\d{1,2})[/\-.](\d{1,2})[/\-.](\d{2,4})", date_str)
        if m:
            d, mo, y = m.group(1), m.group(2), m.group(3)
            if len(y) == 2:
                y = "20" + y
            return f"{y}-{mo.zfill(2)}-{d.zfill(2)}"
        return date_str

    @staticmethod
    def _parse_french_amount(text):
        text = text.strip().replace("\u00a0", "").replace(" ", "")
        m = re.search(r"[\d]+(?:[.,]\d+)?", text)
        if not m:
            return None
        val = m.group().replace(",", ".")
        try:
            return float(val)
        except ValueError:
            return None

    def _parse_raw_ocr_lines(self, ocr_data):
        """
        Parse raw OCR result into structured invoice data.
        Accepts either a list of text lines (backward compat) or a dict with 'lines' and 'words'.
        Uses bounding-box positions from 'words' when available for table reconstruction.
        """
        if not ocr_data:
            return {"lines": [], "fields": {}, "invoice_lines": []}

        if isinstance(ocr_data, str):
            ocr_data = {"lines": ocr_data.splitlines(), "words": []}
        elif isinstance(ocr_data, list):
            ocr_data = {"lines": ocr_data, "words": []}

        lines = ocr_data.get("lines", [])
        words = ocr_data.get("words", [])

        result = self._parse_flat_lines(lines)

        if words and any(w.get("x1") is not None for w in words):
            pos_result = self._parse_with_positions(lines, words)
            for key in ("invoice_id", "date", "supplier", "VAT_Number", "customer", "total_ttc", "total_ht", "total_tva", "currency"):
                pos_val = pos_result.get(key)
                flat_val = result.get(key)
                if pos_val and self._is_valid_field_value(key, str(pos_val)):
                    if not flat_val or not self._is_valid_field_value(key, str(flat_val)):
                        result[key] = pos_val
            pos_lines = pos_result.get("invoice_lines", [])
            flat_lines = result.get("invoice_lines", [])
            pos_desc_count = sum(1 for l in pos_lines if isinstance(l, dict) and len(l.get("description", "")) > 3 and not self._is_garbage(l.get("description", "")))
            flat_desc_count = sum(1 for l in flat_lines if isinstance(l, dict) and len(l.get("description", "")) > 3 and not self._is_garbage(l.get("description", "")))
            if pos_desc_count > flat_desc_count:
                result["invoice_lines"] = pos_lines

        # T021: Merge LLM-refined fields if available
        refined_fields = ocr_data.get("fields")
        if refined_fields and isinstance(refined_fields, dict):
            _logger.info("Merging LLM-refined fields into result for move %s: %s", self.id, list(refined_fields.keys()))
            for key, val in refined_fields.items():
                if val:
                    result[key] = val

        # Reconstruct rows for horizontal table analysis
        reconstructed_rows = []
        if words and any(w.get("x1") is not None for w in words):
            sorted_words = sorted([w for w in words if w.get("confidence", 0.5) >= 0.3], key=lambda w: (w.get("y1", 0), w.get("x1", 0)))
            if sorted_words:
                ROW_TOLERANCE = 15
                current_row = [sorted_words[0]]
                for w in sorted_words[1:]:
                    if abs(w.get("y1", 0) - current_row[0].get("y1", 0)) <= ROW_TOLERANCE:
                        current_row.append(w)
                    else:
                        reconstructed_rows.append(sorted(current_row, key=lambda x: x.get("x1", 0)))
                        current_row = [w]
                if current_row:
                    reconstructed_rows.append(sorted(current_row, key=lambda x: x.get("x1", 0)))

        # Override logic with horizontal/vertical table awareness (Spatial mapping)
        # Priority: Net HT / Net à Payer should override simple Total HT / Total TTC
        for i, row in enumerate(reconstructed_rows):
            row_text = " ".join(w.get("text", "") for w in row).lower()
            
            # Keywords for override
            is_net_ht = any(k in row_text for k in ["net ht", "net h.t", "total net ht", "net net"])
            is_net_ttc = any(k in row_text for k in ["net a payer", "net à payer", "net a payer", "total net ttc"])
            is_simple_ht = not is_net_ht and any(k in row_text for k in ["total ht", "total h.t", "montant ht"])
            is_simple_ttc = not is_net_ttc and any(k in row_text for k in ["total ttc", "total t.t.c", "montant ttc"])
            is_timbre = "timbre" in row_text

            if not (is_net_ht or is_net_ttc or is_simple_ht or is_simple_ttc or is_timbre):
                continue

            nums_in_row = []
            for w in row:
                val = self._parse_french_amount(w.get("text", ""))
                if val is not None and re.search(r'\d', w.get("text", "")):
                    nums_in_row.append((w, val))
            
            # Vertical Case: Label and Number on the SAME line
            if len(nums_in_row) >= 1:
                # Take the last number on the line as the value for the label
                val = nums_in_row[-1][1]
                if is_net_ht:
                    result["total_ht"] = val
                elif is_simple_ht and "total_ht" not in result:
                    result["total_ht"] = val
                elif is_net_ttc:
                    result["total_ttc"] = val
                elif is_simple_ttc and "total_ttc" not in result:
                    result["total_ttc"] = val
                elif is_timbre and val < 2.0:
                    result["stamp_duty"] = val
                
            # Tabular/Horizontal Case: Labels on this line, numbers on next line
            if len(nums_in_row) == 0 and i + 1 < len(reconstructed_rows):
                next_row = reconstructed_rows[i+1]
                next_nums = []
                for w in next_row:
                    val = self._parse_french_amount(w.get("text", ""))
                    if val is not None and re.search(r'\d', w.get("text", "")):
                        next_nums.append((w, val))
                
                if len(next_nums) >= 2:
                    label_boxes = []
                    for w in row:
                        t = w.get("text", "").lower()
                        for lbl in ["total ht", "total h.t", "remise", "net ht", "total net ht", "tva", "timbre", "net a payer", "net à payer"]:
                            if lbl in t:
                                label_boxes.append({"lbl": lbl, "x": (w.get("x1", 0) + w.get("x2", 0)) / 2})
                    
                    if label_boxes:
                        for num_w, num_val in next_nums:
                            num_x = (num_w.get("x1", 0) + num_w.get("x2", 0)) / 2
                            best_lbl = None
                            min_dist = 1000
                            for lb in label_boxes:
                                dist = abs(lb["x"] - num_x)
                                if dist < min_dist:
                                    min_dist = dist
                                    best_lbl = lb["lbl"]
                            
                            if best_lbl and min_dist < 150:
                                if best_lbl in ["net ht", "total net ht"]:
                                    result["total_ht"] = num_val
                                elif best_lbl in ["net a payer", "net à payer"]:
                                    result["total_ttc"] = num_val
                                elif best_lbl == "timbre" and num_val < 2.0:
                                    result["stamp_duty"] = num_val

        return result

    def _parse_with_positions(self, lines, words):
        """Parse OCR data using bounding-box positions for table reconstruction."""
        filtered_words = []
        for w in words:
            conf = w.get("confidence", 0.5)
            if conf < 0.3 or self._is_garbage(w.get("text", "")):
                continue
            filtered_words.append(w)
 
        # _logger.info("OCR position-based parsing: %d words filtered from %d total", len(filtered_words), len(words))
 
        if not filtered_words:
            return self._parse_flat_lines(lines)
 
        sorted_words = sorted(filtered_words, key=lambda w: (w.get("y1", 0), w.get("x1", 0)))
 
        ROW_TOLERANCE = 15
        rows = []
        current_row = [sorted_words[0]]
        for w in sorted_words[1:]:
            y_diff = abs(w.get("y1", 0) - current_row[0].get("y1", 0))
            if y_diff <= ROW_TOLERANCE:
                current_row.append(w)
            else:
                rows.append(sorted(current_row, key=lambda ww: ww.get("x1", 0)))
                current_row = [w]
        if current_row:
            rows.append(sorted(current_row, key=lambda ww: ww.get("x1", 0)))
 
        # _logger.info("OCR position rows: %d rows reconstructed from %d words", len(rows), len(filtered_words))
        # for i, row in enumerate(rows[:5]):
        #     row_text = " ".join(w.get("text", "") for w in row)
        #     _logger.info("  Row %d: %s", i, row_text[:120])
 
        result = {}
        invoice_lines = []
        field_matched = set()
        prev_key = None
 
        header_ended = False
        table_section = False
        total_section = False
        current_item = {}
 
        for row in rows:
            row_text = " ".join(w["text"] for w in row)
            row_text_stripped = row_text.strip()
            if not row_text_stripped:
                continue
 
            matched_field = self._match_invoice_field(row_text, field_matched, prev_key)
            if matched_field:
                field_key, field_value, is_prev_key = matched_field
                if is_prev_key:
                    prev_key = field_key
                else:
                    result[field_key] = field_value
                    field_matched.add(field_key)
                    prev_key = None
                if field_key in ("total_ttc", "total_ht", "total_tva"):
                    total_section = True
                continue
 
            if prev_key and prev_key not in result:
                if not self._is_garbage(row_text_stripped):
                    result[prev_key] = row_text_stripped
                    field_matched.add(prev_key)
                prev_key = None
                continue
 
            if total_section or self._is_total_row(row_text_stripped):
                total_handled = False
                total_labels_inline = {
                    "total_ttc": ["net a payer", "net à payer", "total ttc", "total t.t.c", "total general", "total"],
                    "total_ht": ["total net ht", "total ht", "total h.t", "total h.t.", "montant net ht", "montant hors taxe", "montant hors taxes"],
                    "total_tva": ["total tva", "montant tva"],
                }
                for tkey, tlabels in total_labels_inline.items():
                    if tkey not in field_matched:
                        for tlabel in tlabels:
                            if tlabel in row_text_stripped.lower():
                                val = self._parse_french_amount(row_text_stripped)
                                if val is not None:
                                    result[tkey] = val
                                    field_matched.add(tkey)
                                    total_handled = True
                                break
                if total_handled:
                    continue
 
            if self._is_table_header(row_text_stripped):
                header_ended = True
                table_section = True
                continue
 
            if self._is_line_item_separator(row_text_stripped):
                continue
 
            if header_ended and not total_section:
                desc, qty, price, amount = self._parse_line_row(row, row_text_stripped)
                if desc and amount:
                    invoice_lines.append({
                        "description": desc,
                        "quantity": qty,
                        "unit_price": price,
                    })
 
        date_val = self._extract_date_from_rows(rows)
        if date_val and "date" not in result:
            result["date"] = date_val
 
        result["invoice_lines"] = invoice_lines
        result["lines"] = [isinstance(l, str) and l or str(l) for l in lines]
 
        if not invoice_lines:
            # _logger.info("OCR position parser found 0 line items, falling back to flat text parsing")
            flat_result = self._parse_flat_lines(lines)
            result["invoice_lines"] = flat_result.get("invoice_lines", [])
            for key in ("supplier", "date", "invoice_id", "VAT_Number", "customer", "total_ttc", "total_ht", "total_tva", "currency"):
                if key not in result or not result.get(key):
                    if flat_result.get(key):
                        result[key] = flat_result[key]
 
        # _logger.info("OCR position parsing result: fields=%s, invoice_lines=%d", list(result.keys()), len(result.get("invoice_lines", [])))
        # for il in result.get("invoice_lines", [])[:5]:
        #     _logger.info("  Invoice line: desc=%s qty=%s price=%s", il.get("description", "")[:60], il.get("quantity"), il.get("unit_price"))
        return result

    def _parse_flat_lines(self, lines):
        """Parse OCR data using sequential line analysis with table-aware grouping."""
        if not lines:
            return {"lines": [], "fields": {}, "invoice_lines": []}

        if isinstance(lines, str):
            lines = lines.splitlines()

        result = {}
        field_matched = set()
        prev_key = None
        pending_total_key = None

        cleaned = []
        for raw_line in lines:
            line = raw_line.strip() if isinstance(raw_line, str) else str(raw_line).strip()
            if not line or self._is_garbage(line):
                continue
            cleaned.append(line)

        _logger.info("OCR flat parser: %d cleaned lines from %d raw lines", len(cleaned), len(lines))

        in_table = False
        table_lines = []
        current_item_desc = None
        current_item_nums = []

        for i, line in enumerate(cleaned):
            lower = line.lower().strip()
            _logger.debug("OCR flat line %d: '%s' (in_table=%s)", i, line[:80], in_table)

            if pending_total_key:
                val = self._parse_french_amount(line)
                if val is not None:
                    result[pending_total_key] = val
                    field_matched.add(pending_total_key)
                pending_total_key = None
                continue

            total_labels = {
                "total_ttc": ["net a payer", "net à payer", "total ttc", "total t.t.c", "total general", "total"],
                "total_ht": ["total net ht", "total ht", "total h.t", "total h.t.", "montant net ht", "montant hors taxe", "montant hors taxes"],
                "total_tva": ["total tva", "montant tva"],
            }
            total_handled = False
            for tkey, tlabels in total_labels.items():
                if tkey in field_matched:
                    continue
                for tlabel in tlabels:
                    if tlabel in lower:
                        val = self._parse_french_amount(line)
                        if val is not None:
                            result[tkey] = val
                            field_matched.add(tkey)
                            total_handled = True
                        else:
                            pending_total_key = tkey
                            total_handled = True
                        break
                if total_handled:
                    break
            if total_handled:
                in_table = False
                continue

            if "timbre" in lower or "stamp" in lower:
                val = self._parse_french_amount(line)
                if val is not None:
                    result["stamp_duty"] = val
                continue

            matched_field = self._match_invoice_field(line, field_matched, prev_key)
            if matched_field:
                field_key, field_value, is_prev_key = matched_field
                if is_prev_key:
                    prev_key = field_key
                else:
                    if self._is_valid_field_value(field_key, field_value):
                        result[field_key] = field_value
                        field_matched.add(field_key)
                    prev_key = field_key if not self._is_valid_field_value(field_key, field_value) else None
                in_table = False
                continue

            if prev_key and prev_key not in result:
                if not self._is_garbage(line) and self._is_valid_field_value(prev_key, line):
                    result[prev_key] = line
                    field_matched.add(prev_key)
                prev_key = None
                continue

            if self._is_table_header(line):
                in_table = True
                continue

            if self._is_line_item_separator(line):
                continue

            if not in_table and re.match(r"^\d{4,}[/.-]\d+$", line.strip()):
                if "invoice_id" not in field_matched:
                    m = re.match(r"(\d{4,}[/.-]\d+)", line.strip())
                    if m:
                        result["invoice_id"] = m.group(1)
                        field_matched.add("invoice_id")
                        continue

            in_table = True

            if lower in AccountMove._LOWER_HEADER_WORDS:
                continue

            if re.match(r"^\d+\s*%$", line.strip()):
                continue

            if re.match(r"^(?:base|tva)\s", lower):
                continue

            amount_with_currency = re.match(r"^[\d\s]+(?:[.,]\d{1,3})?\s*(?:DT|EUR|USD|TND|€|\$|£)\s*$", line.strip(), re.I)

            if amount_with_currency and current_item_desc:
                amt_str = re.sub(r'(?:DT|EUR|USD|TND|€|\$|£)', '', line.strip(), flags=re.I).strip()
                amt_str = amt_str.replace(" ", "").replace(",", ".")
                try:
                    line_amount = float(amt_str)
                except ValueError:
                    line_amount = 0.0

                qty = 1.0
                price = 0.0
                if len(current_item_nums) >= 2:
                    qty = current_item_nums[0] if current_item_nums[0] < 100 else 1.0
                    price = current_item_nums[1]
                elif len(current_item_nums) == 1:
                    price = current_item_nums[0]
                else:
                    price = line_amount

                if price == 0 and qty > 0:
                    price = line_amount / qty if qty > 0 else line_amount

                table_lines.append({
                    "description": current_item_desc,
                    "quantity": qty,
                    "unit_price": round(price, 2),
                })
                _logger.info("OCR table line: desc='%s' qty=%.1f price=%.2f amount=%.2f", current_item_desc[:50], qty, price, line_amount)
                current_item_desc = None
                current_item_nums = []
                continue

            plain_num_in_line = re.findall(r"[\d]+(?:[.,]\d{2,})?", line)
            if plain_num_in_line and not amount_with_currency:
                for n in plain_num_in_line:
                    try:
                        val = float(n.replace(",", "."))
                        if val > 0:
                            current_item_nums.append(val)
                    except ValueError:
                        pass

            if self._is_description_line(line):
                if current_item_desc and not current_item_desc.lower().startswith("du "):
                    current_item_desc = None
                    current_item_nums = []
                if current_item_desc and current_item_desc.lower().startswith("du "):
                    current_item_desc = current_item_desc + " - " + line.strip()
                    continue
                current_item_desc = line.strip()
                current_item_nums = []
                continue

            if not current_item_desc:
                desc_part = re.sub(r"[\d]+(?:[.,]\d{2,})?", "", line).strip()
                desc_part = re.sub(r"\s+DT\s*$", "", desc_part, flags=re.I).strip()
                if len(desc_part) >= 3 and self._is_description_line(desc_part):
                    current_item_desc = desc_part

        remaining = [l for l in lines if isinstance(l, str) and l.strip()]
        self._extract_dates_from_remaining(remaining, result, field_matched)
        self._extract_vendor_from_remaining(remaining, result, field_matched)
        self._extract_totals_lookahead(remaining, result, field_matched)

        if table_lines:
            result["invoice_lines"] = table_lines
        _logger.info("OCR flat parser: fields=%s, invoice_lines=%d", list(result.keys()), len(table_lines))

        result["lines"] = [isinstance(l, str) and l or str(l) for l in lines]
        return result

    def _is_description_line(self, line):
        stripped = line.strip()
        if len(stripped) < 3:
            return False
        alpha_count = sum(1 for c in stripped if c.isalpha())
        if alpha_count < 3:
            return False
        if re.match(r"^\d+([.,]\d+)?\s*(DT|EUR|USD|TND)?$", stripped, re.I):
            return False
        if re.match(r"^\d+\s*%$", stripped):
            return False
        if re.match(r"^\d+[.,]\d{2,}$", stripped):
            return False
        return True

    def _match_invoice_field(self, text, field_matched, prev_key=None):
        """Match a text line against known invoice field patterns. Returns (key, value, is_prev_key) or None."""
        for field_key, patterns in self._INVOICE_PATTERN_MAP.items():
            if field_key in field_matched:
                continue
            for pattern in patterns:
                match = pattern.search(text)
                if match:
                    value_part = text[match.end():].strip().lstrip(":").strip()
                    value_part = value_part.strip(".;:,")
                    if value_part:
                        return (field_key, value_part, False)
                    else:
                        return (field_key, None, True)
        return None

    def _is_total_row(self, text):
        total_keywords = [
            "total net ht", "total ht", "total h.t", "montant net",
            "total ttc", "total t.t.c", "net a payer", "net à payer",
            "total tva", "total general", "grand total", "total amount",
            "total due", "montant total",
        ]
        lower = text.lower().strip()
        return any(kw in lower for kw in total_keywords)

    def _extract_totals_lookahead(self, lines, result, field_matched):
        total_labels = {
            "total_ttc": ["net a payer", "net à payer", "total ttc", "total t.t.c", "total general", "total"],
            "total_ht": ["total net ht", "total ht", "total h.t", "total h.t.", "montant net ht", "montant hors taxe", "montant hors taxes"],
            "total_tva": ["total tva", "total tax", "total vat", "montant tva"],
        }
        for i, line in enumerate(lines):
            lower = line.lower().strip()
            for tkey, tlabels in total_labels.items():
                if tkey in field_matched:
                    continue
                matched_label = False
                for tlabel in tlabels:
                    if tlabel in lower:
                        matched_label = True
                        val = self._parse_french_amount(line)
                        if val is not None:
                            result[tkey] = val
                            field_matched.add(tkey)
                            break
                        for j in range(i + 1, min(i + 5, len(lines))):
                            val = self._parse_french_amount(lines[j])
                            if val is not None:
                                result[tkey] = val
                                field_matched.add(tkey)
                                break
                        break
                if matched_label:
                    break

    def _is_table_header(self, text):
        stripped = text.strip().lower()
        if len(stripped) < 3:
            return False
        if stripped in AccountMove._LOWER_HEADER_WORDS:
            return True
        count = sum(1 for kw in AccountMove._LOWER_HEADER_WORDS if kw in stripped)
        return count >= 2

    def _is_line_item_separator(self, text):
        stripped = text.strip()
        if re.match(r"^[-=_*]{3,}$", stripped):
            return True
        if re.match(r"^[Xx]{5,}$", stripped):
            return True
        return False

    def _parse_line_row(self, row_words, row_text):
        """Parse a table row into (description, quantity, unit_price, line_total)."""
        desc_parts = []
        qty = 1.0
        price = 0.0
        amount = 0.0
        numbers = []

        for w in row_words:
            text = w.get("text", "").strip()
            if not text:
                continue
            if re.match(r"^[\d]+(?:[.,]\d+)?%?$", text):
                val = self._parse_french_amount(text)
                if val is not None:
                    numbers.append(val)
                continue
            if re.match(r"^\d+$", text) and len(text) <= 4:
                try:
                    numbers.append(float(text))
                except ValueError:
                    desc_parts.append(text)
                continue
            if re.match(r"^[\d]+(?:[.,]\d{2,})$", text):
                val = self._parse_french_amount(text)
                if val is not None:
                    numbers.append(val)
                continue
            desc_parts.append(text)

        desc = " ".join(desc_parts).strip()
        if not desc and len(numbers) >= 3:
            desc = f"Line item"

        numbers_len = len(numbers)
        if numbers_len == 1:
            amount = numbers[0]
        elif numbers_len == 2:
            qty = 1.0
            price = numbers[0]
            amount = numbers[1]
        elif numbers_len == 3:
            qty = numbers[0]
            price = numbers[1]
            amount = numbers[2]
        elif numbers_len >= 4:
            qty = numbers[0]
            price = numbers[-2]
            amount = numbers[-1]

        if amount == 0 and price > 0:
            amount = qty * price

        return (desc, qty, price, amount)

    def _extract_date_from_rows(self, rows):
        """Extract dates from OCR rows positioned near date labels."""
        for i, row in enumerate(rows):
            row_text = " ".join(w.get("text", "") for w in row).lower()
            date_label_patterns = ["date", "le:", "le :", "du:", "facture", "nabeul"]
            if any(p in row_text for p in date_label_patterns):
                search_start = max(0, i)
                search_end = min(len(rows), i + 3)
                for j in range(search_start, search_end):
                    for w in rows[j]:
                        for pat in self._DATE_PATTERNS:
                            m = pat.search(w.get("text", ""))
                            if m:
                                return m.group(1)

        for row in rows:
            row_text = " ".join(w.get("text", "") for w in row)
            for pat in self._DATE_PATTERNS:
                m = pat.search(row_text)
                if m:
                    return m.group(1)

        return None

    def _extract_dates_from_remaining(self, lines, result, field_matched):
        """Extract dates from remaining text lines."""
        if "date" in field_matched:
            return
        date_label_lines = set()
        for i, line in enumerate(lines):
            lower = line.lower()
            if any(p in lower for p in ["date", "le:", "le :", "nabeul"]):
                for j in range(i, min(i + 3, len(lines))):
                    for pat in self._DATE_PATTERNS:
                        m = pat.search(lines[j])
                        if m:
                            result["date"] = m.group(1)
                            field_matched.add("date")
                            return

        for line in lines:
            for pat in self._DATE_PATTERNS:
                m = pat.search(line)
                if m:
                    date_str = m.group(1)
                    if re.match(r"\d{1,2}[/.-]\d{1,2}[/.-]\d{4}", date_str):
                        result.setdefault("date", date_str)
                        field_matched.add("date")
                        return

    def _extract_vendor_from_remaining(self, lines, result, field_matched):
        """Extract vendor name from header lines."""
        if "supplier" in field_matched:
            return
        vendor_keywords = ["siege", "societe", "s.a.r.l", "s.a.r.i", "ste ", "sarl", "eurl", "sa ", "centre"]
        for line in lines:
            upper = line.upper()
            if any(kw.upper() in upper for kw in vendor_keywords):
                clean = re.sub(r"(?:^(?:siege\s*(social)?\s*:\s*|societe\s*|ste\s*)|s\.a\.r\.[lLi]\s*)", "", line, flags=re.I).strip()
                if clean and len(clean) > 3 and not self._is_garbage(clean):
                    result["supplier"] = clean
                    field_matched.add("supplier")
                    return

    def _get_ocr_client(self):
        from .ocr_client import OCRClientFactory

        return OCRClientFactory.create(self.env)

    def _get_document_base64(self):
        """Get PDF data from invoice attachment as base64 string."""
        attachment = self.message_main_attachment_id
        if attachment and "pdf" in (attachment.mimetype or ""):
            datas = attachment.datas
            if isinstance(datas, bytes):
                return datas.decode("utf-8")
            return datas

        for att in self.attachment_ids:
            if "pdf" in (att.mimetype or "") or "image" in (att.mimetype or ""):
                datas = att.datas
                if isinstance(datas, bytes):
                    return datas.decode("utf-8")
                return datas

        return None

    def _get_ocr_service_name(self):
        """Get the OCR service name for invoices."""
        return "invoice_ocr"

    @api.model
    def _ai_cron_process_queue(self):
        """
        Process pending OCR extractions queue.
        Called by the 'AI Invoice Extraction Queue' cron job.
        """
        moves = self.search(
            [
                ("ocr_extract_state", "in", ["waiting_extraction", "extract_not_ready"]),
                ("move_type", "in", ["in_invoice", "in_refund", "out_invoice", "out_refund", "out_receipt"]),
            ],
            limit=20,
        )
 
        if not moves:
            return
 
        _logger.info(
            "OCR cron: processing %d invoice(s) in extraction queue", len(moves)
        ) # Trace le nombre de factures traitées par le cron OCR
 
        for move in moves:
            try:
                move.check_ocr_status()
            except Exception as e:
                _logger.exception("OCR cron: error processing move %s: %s", move.id, e) # Log l'exception lors du traitement cron d'un mouvement


    def send_for_ocr_extraction(self):
        """Send the document for OCR processing."""
        self.ensure_one()
        # Allow re-sending even if not in 'no_extract_requested' state
        doc_b64 = self._get_document_base64()
        if not doc_b64:
            if self.env.context.get('ocr_auto_trigger'):
                return False
            raise UserError(_("No document attached to this record"))
        try:
            client = self._get_ocr_client()
            user_infos = {}
            if self.partner_id:
                user_infos['supplier_name'] = self.partner_id.name
                if hasattr(self.partner_id, 'ocr_column_mapping') and self.partner_id.ocr_column_mapping:
                    user_infos['custom_prompt'] = self.partner_id.ocr_column_mapping
            
            result = client.parse(doc_b64, service_name=self._get_ocr_service_name(), user_infos=user_infos)
            self.write({
                "ocr_extract_state": "waiting_extraction",
                "ocr_extract_request_uuid": result.get("document_token", str(uuid.uuid4())),
                "ocr_extract_last_check": fields.Datetime.now(),
            })
            if result.get("status") == "success":
                extraction_data = result.get("result") or result.get("results", [{}])[0]
                self.ocr_extract_result_json = json.dumps(extraction_data)
                self.ocr_extract_state = "waiting_validation"
                self._process_ocr_result(extraction_data)
        except Exception as e:
            _logger.error("Failed to send for OCR: %s", str(e)) # Log l'échec de l'envoi du document vers l'OCR
            raise UserError(_("Failed to send document for OCR: %s") % str(e))
        return True

    def check_ocr_status(self):
        """Check the status of a pending OCR extraction."""
        self.ensure_one()
        if self.ocr_extract_state not in ["waiting_extraction", "extract_not_ready"]:
            return False
        if not self.ocr_extract_request_uuid:
            return False
        try:
            client = self._get_ocr_client()
            result = client.get_result(self.ocr_extract_request_uuid)
            self.ocr_extract_last_check = fields.Datetime.now()
            if result.get("status") == "ready" or result.get("status") == "success":
                extraction_data = result.get("result", {})
                self.ocr_extract_result_json = json.dumps(extraction_data)
                self.ocr_extract_state = "waiting_validation"
                self._process_ocr_result(extraction_data)
                return True
            elif result.get("status") == "pending":
                if self.ocr_extract_state == "waiting_extraction":
                    self.ocr_extract_state = "extract_not_ready"
        except Exception as e:
            _logger.exception("Error checking OCR status for %s: %s", self.id, str(e)) # Log l'exception lors de la vérification du statut OCR
        return False

    def _process_ocr_result(self, result):
        """
        Process OCR result and apply to invoice.
        Converts raw OCR data into IAP format for standard _save_form processing.
        """
        _logger.info(
            "Processing OCR result for move %s: %s",
            self.id,
            list(result.keys()) if isinstance(result, dict) else "raw",
        ) # Trace le début du traitement du résultat OCR pour un mouvement
 
        if isinstance(result, dict) and "results" in result:
            if isinstance(result["results"], list) and result["results"]:
                result = result["results"][0]
 
        # T021: Prioritize Date Extraction to avoid 'Missing Field' errors
        date_raw = self._get_nested_value(result, "date") or self._get_nested_value(result, "invoice_date")
        if date_raw:
            norm_date = self._normalize_date(str(date_raw))
            if norm_date:
                self.write({'invoice_date': norm_date})
                self.env.cr.commit() # Force commit to ensure UI sees it

        has_iap_format = False
        if isinstance(result, dict):
            # Strict check: must have fields wrapped in selected_value to be considered IAP format
            for v in result.values():
                if isinstance(v, dict) and "selected_value" in v:
                    has_iap_format = True
                    break

        if has_iap_format:
            _logger.info("Using IAP format directly via _save_form for move %s", self.id)
            try:
                with self.env.cr.savepoint():
                    self._save_form(result)
                self.ocr_extract_state = "waiting_validation"
                return True
            except Exception as e:
                _logger.warning("IAP _save_form failed: %s, falling back to conversion", e)

        is_raw_ocr = False
        if isinstance(result, dict) and "lines" in result:
            _logger.info("Converting raw OCR data to IAP format for move %s (has_words=%s)", self.id, bool(result.get("words")))
            result = self._parse_raw_ocr_lines(result)
            _logger.info("Parsed result keys for move %s: %s", self.id, list(result.keys()))
            is_raw_ocr = True

        if is_raw_ocr:
            iap_data = self._convert_to_iap_format(result)
            _logger.info("DEBUG OCR DATA: Converted IAP Data: %s", json.dumps(iap_data, indent=2))
            try:
                with self.env.cr.savepoint():
                    self._save_form(iap_data)
                _logger.info("DEBUG OCR: _save_form success for move %s", self.id)
            except Exception as e:
                _logger.error("DEBUG OCR: _save_form FAILED: %s", e)

            try:
                self._apply_custom_mapping(result)
            except Exception as e:
                _logger.exception("Custom mapping failed for move %s: %s", self.id, e)
            self.ocr_extract_state = "waiting_validation"
            return True

        _logger.warning("Unrecognized OCR result format for move %s", self.id)
        self.ocr_extract_state = "waiting_validation"
        return True

    @staticmethod
    def _sv(content):
        return {"selected_value": {"content": content}}

    def _convert_to_iap_format(self, parsed):
        """Convert our parsed dict into IAP format that _save_form expects."""
        iap = {}

        date_val = parsed.get("date")
        if date_val:
            norm = self._normalize_date(str(date_val))
            if norm:
                iap["date"] = self._sv(norm)

        due_val = parsed.get("due_date")
        if due_val:
            norm = self._normalize_date(str(due_val))
            if norm:
                iap["due_date"] = self._sv(norm)

        inv_val = parsed.get("invoice_id")
        if inv_val and len(str(inv_val).strip()) <= 30 and not self._is_garbage(str(inv_val)):
            iap["invoice_id"] = self._sv(str(inv_val).strip())

        supplier_val = parsed.get("supplier") or parsed.get("customer")
        if supplier_val and not self._is_garbage(str(supplier_val)):
            iap["supplier"] = self._sv(str(supplier_val).strip())

        vat_val = parsed.get("VAT_Number")
        if vat_val:
            vat_str = str(vat_val).strip()
            if re.search(r"[A-Z]{2,}\d|[A-Z]\d{4,}", vat_str) and len(vat_str) <= 20:
                iap["VAT_Number"] = self._sv(vat_str)

        currency_val = parsed.get("currency")
        if currency_val:
            curr_str = str(currency_val).strip().upper()
            if re.match(r"^(EUR|USD|GBP|TND|MAD|DZD|XOF|XAF|CHF)$", curr_str):
                iap["currency"] = self._sv(curr_str)

        def _parse_float(val):
            if val is None: return 0.0
            s = str(val).replace('\u00a0', '').replace(' ', '')
            # If comma is used as decimal separator (FR style) and there's no point
            if ',' in s and '.' not in s:
                s = s.replace(',', '.')
            # If both are present, assume comma is thousands separator
            elif ',' in s and '.' in s:
                s = s.replace(',', '')
            try:
                return float(re.sub(r'[^\d.]', '', s))
            except:
                return 0.0

        total_val = parsed.get("total_ttc") or parsed.get("total")
        if total_val is not None:
            iap["total"] = self._sv(_parse_float(total_val))

        subtotal_val = parsed.get("total_ht") or parsed.get("subtotal")
        if subtotal_val is not None:
            iap["subtotal"] = self._sv(_parse_float(subtotal_val))

        tax_val = parsed.get("total_tva")
        if tax_val is not None:
            iap["total_tax_amount"] = self._sv(_parse_float(tax_val))

        raw_lines = parsed.get("invoice_lines", [])
        if raw_lines:
            iap_lines = []
            tax_cache = {}
            for line in raw_lines:
                if not isinstance(line, dict):
                    continue
                desc = str(line.get("description", "")).strip()
                if not desc or len(desc) < 3 or self._is_garbage(desc):
                    continue
                qty = _parse_float(line.get("quantity", 1.0)) or 1.0
                price = _parse_float(line.get("unit_price", 0.0))
                if price <= 0:
                    continue
                taxes = []
                tax_rate = line.get("tax_rate")
                if tax_rate:
                    try:
                        rate_f = float(str(tax_rate).replace("%", "").strip())
                        if rate_f > 0:
                            taxes.append(rate_f)
                    except (ValueError, TypeError):
                        pass

                discount = 0.0
                discount_val = line.get("discount")
                if discount_val:
                    try:
                        discount = float(str(discount_val).replace("%", "").strip())
                    except (ValueError, TypeError):
                        pass

                iap_line = {
                    "description": self._sv(desc),
                    "quantity": self._sv(qty),
                    "unit_price": self._sv(price),
                    "taxes": taxes, # Taxes remain as a list in IAP format
                    "discount": self._sv(discount),
                    "subtotal": self._sv(round(qty * price, 2)),
                }
                iap_lines.append(iap_line)
            if iap_lines:
                iap["invoice_lines"] = iap_lines

        return iap

    def _save_form(self, ocr_results, **kwargs):
        """
        Override standard _save_form to force critical fields like date.
        """
        _logger.info("Custom _save_form for move %s", self.id)
        
        # Ensure date is forced if found in OCR results
        if isinstance(ocr_results, dict):
            date_raw = ocr_results.get("date", {}).get("selected_value", {}).get("content")
            if not date_raw:
                 # Check raw results if nested
                 date_raw = ocr_results.get("date")
            
            if date_raw:
                norm_date = self._normalize_date(str(date_raw))
                if norm_date:
                    _logger.info("Forcing invoice_date to %s in _save_form for move %s", norm_date, self.id)
                    self.write({'invoice_date': norm_date})

        return super(AccountMove, self)._save_form(ocr_results, **kwargs)

    def _apply_custom_mapping(self, processed_results):
        """Apply custom field mapping from OCR results."""
        for move in self:
            vals = {}

            _logger.info("Applying custom mapping for move %s with keys: %s", move.id, list(processed_results.keys()) if isinstance(processed_results, dict) else "not-dict")
            _logger.info("OCR parsed data for move %s: %s", move.id, {k: (v if k != 'lines' else f'[{len(v)} items]') for k, v in processed_results.items()} if isinstance(processed_results, dict) else processed_results)

            date_val = self._get_nested_value(processed_results, "date") or self._get_nested_value(processed_results, "invoice_date")
            if date_val:
                norm_date = self._normalize_date(str(date_val))
                if norm_date:
                    vals["invoice_date"] = norm_date
                    _logger.info("Setting invoice_date to %s for move %s", norm_date, move.id)

            due_val = self._get_nested_value(processed_results, "due_date")
            if due_val:
                due_val = self._normalize_date(str(due_val))
                if due_val:
                    vals["invoice_date_due"] = due_val

            ref_val = self._get_nested_value(processed_results, "invoice_id")
            if ref_val:
                vals["ref"] = ref_val

            supplier_val = self._get_nested_value(processed_results, "supplier")
            if not supplier_val:
                supplier_val = self._get_nested_value(processed_results, "customer")
            vat_val = self._get_nested_value(
                processed_results, "VAT_Number"
            ) or self._get_nested_value(processed_results, "supplier_vat")

            if not move.partner_id and (vat_val or supplier_val):
                partner = False
                if vat_val:
                    clean_vat = "".join(e for e in str(vat_val) if e.isalnum())
                    partner = self.env["res.partner"].search(
                        [
                            "|",
                            ("vat", "ilike", vat_val),
                            ("vat", "ilike", clean_vat),
                        ],
                        limit=1,
                    )
                if not partner and supplier_val:
                    first_line = str(supplier_val).split("\n")[0].strip()
                    # Clean common prefixes/suffixes for better matching
                    clean_name = re.sub(r'^(st[eé]|sarl|suarl|ste)\b', '', first_line, flags=re.IGNORECASE).strip()
                    clean_name = clean_name.strip(' .:')
                    
                    partner = self.env["res.partner"].search(
                        [("name", "ilike", first_line)], limit=1
                    )
                    if not partner and clean_name:
                        partner = self.env["res.partner"].search(
                            [("name", "ilike", clean_name)], limit=1
                        )
                    if not partner and vat_val:
                        clean_vat = "".join(e for e in str(vat_val) if e.isalnum())
                        # If VAT is in format 1234567X/A/M/000, try searching by the main part 1234567
                        vat_main = re.search(r'\d{5,}', str(vat_val))
                        if vat_main:
                            partner = self.env["res.partner"].search([("vat", "ilike", vat_main.group())], limit=1)
                if partner:
                    vals["partner_id"] = partner.id

            currency_code = self._get_nested_value(processed_results, "currency")
            if currency_code and not move.currency_id:
                currency = self.env["res.currency"].search(
                    [("name", "=", currency_code)], limit=1
                )
                if currency:
                    vals["currency_id"] = currency.id

            extracted_lines = processed_results.get(
                "invoice_lines"
            ) or processed_results.get("lines", [])
            if extracted_lines:
                vals["invoice_line_ids"] = self._build_invoice_lines(
                    extracted_lines, move, processed_results
                )

            if vals:
                _logger.info("OCR applying vals to move %s: %s", move.id, {k: v for k, v in vals.items() if k != 'invoice_line_ids'})
                try:
                    with move._get_edi_creation() as move_form:
                        for field, value in vals.items():
                            try:
                                setattr(move_form, field, value)
                            except Exception as e:
                                _logger.warning("OCR setattr %s failed for move %s: %s", field, move.id, e)
                except Exception as e:
                    _logger.error("OCR Form Error for move %s: %s, falling back to write", move.id, e)
                    move.write(vals)
                move._compute_tax_totals()
                
                # TUNISIAN SCALING LOGIC: 
                # Verification logic moved to trust extracted lines more than summary.
                pass

                # TND SCALE AUTO-CORRECTION (1000x check):
                if move.currency_id.name == "TND":
                    sum_lines = sum(move.invoice_line_ids.mapped("price_subtotal"))
                    if sum_lines > 0 and move.amount_untaxed > (sum_lines * 100):
                         _logger.warning("OCR TND Correction: Total HT (%.2f) is suspiciously larger than lines (%.2f). Dividing by 1000.", move.amount_untaxed, sum_lines)
                         move.write({
                             "amount_untaxed": move.amount_untaxed / 1000.0,
                         })
                         move._compute_tax_totals()

                # Verification message removed as requested by user
                pass

    def _ocr_parse_float(self, val, default=0.0):
        if val is None or val is False:
            return default
        if isinstance(val, (int, float)):
            return float(val)
        try:
            # Clean string: remove %, spaces
            orig_s = str(val)
            s = orig_s.replace("%", "").replace(" ", "").replace("\xa0", "").strip()
            if not s:
                return default
            
            # Find all potential separators
            separators = [i for i, c in enumerate(s) if c in ".,"]
            
            # If only one separator is present and it has 1, 2 or 3 digits after it, it's a decimal
            if len(separators) == 1:
                idx = separators[0]
                after_all = s[idx+1:]
                digits_after = "".join(c for c in after_all if c.isdigit())
                if len(digits_after) <= 3:
                    prefix = "".join(c for c in s[:idx] if c.isdigit() or c == "-")
                    res_s = (prefix or "0") + "." + digits_after
                    result = float(res_s)
                    _logger.info("OCR Parse (Single Sep): '%s' -> '%s' -> %f", orig_s, res_s, result)
                    return result

            # GOLDEN RULE FOR TND: A separator followed by EXACTLY 3 digits is the decimal point.
            millimes_sep_idx = -1
            for idx in reversed(separators):
                after = s[idx+1:]
                digits_after = ""
                for c in after:
                    if c.isdigit():
                        digits_after += c
                    else:
                        break
                
                if len(digits_after) == 3:
                    millimes_sep_idx = idx
                    break
            
            res_s = s
            if millimes_sep_idx != -1:
                prefix = "".join(c for c in s[:millimes_sep_idx] if c.isdigit() or c == "-")
                millimes = s[millimes_sep_idx+1:millimes_sep_idx+4]
                res_s = (prefix or "0") + "." + millimes
            else:
                last_sep_idx = separators[-1] if separators else -1
                if last_sep_idx != -1:
                    last_part = "".join(c for c in s[last_sep_idx+1:] if c.isdigit())
                    if len(last_part) == 2:
                        prefix = "".join(c for c in s[:last_sep_idx] if c.isdigit() or c == "-")
                        res_s = (prefix or "0") + "." + last_part
                    else:
                        res_s = "".join(c for c in s if c.isdigit() or c == "-")
                else:
                    res_s = "".join(c for c in s if c.isdigit() or c == "-")
            
            result = float(res_s)
            _logger.info("OCR Parse: '%s' -> '%s' -> %f", orig_s, res_s, result)
            return result
        except (ValueError, TypeError) as e:
            _logger.warning("OCR Parse Failed: '%s' error: %s", val, e)
            return default

    def _build_invoice_lines(self, extracted_lines, move, parsed_data=None):
        """Build invoice line commands from OCR extracted lines."""
        line_commands = [Command.clear()]
        tax_cache = {}
        
        # Extract default tax rate from tax_summary if available
        default_tax_rate = None
        if parsed_data:
            summary = parsed_data.get("tax_summary") or parsed_data.get("fields", {}).get("tax_summary")
            if summary and isinstance(summary, dict):
                # Take the first available tax rate as default if lines are missing it
                rates = [self._ocr_parse_float(r) for r in summary.keys()]
                if rates:
                    default_tax_rate = rates[0]

        group_by_tax = (
            move.company_id.extract_single_line_per_tax
            if hasattr(move.company_id, "extract_single_line_per_tax")
            else False
        )

        if group_by_tax:
            grouped_data = {}
            for line in extracted_lines:
                if not isinstance(line, dict):
                    line_name = str(line) if line else ""
                    rate_f = 0.0
                    if rate_f not in grouped_data:
                        grouped_data[rate_f] = {"subtotal": 0.0, "name": line_name}
                    continue

                rate_f = 0.0
                tax_rate = line.get("tax_rate")
                if tax_rate:
                    val_rate = self._get_nested_value({"rate": tax_rate}, "rate", 0.0)
                    rate_f = self._ocr_parse_float(val_rate)

                if rate_f not in grouped_data:
                    grouped_data[rate_f] = {"subtotal": 0.0}

                qty = self._ocr_parse_float(self._get_nested_value(line, "quantity", 1.0), 1.0)
                price = self._ocr_parse_float(self._get_nested_value(line, "unit_price", 0.0))
                line_name = str(self._get_nested_value(line, "description", ""))
                discount = self._ocr_parse_float(self._get_nested_value(line, "discount", 0.0))
                
                if any(k in line_name.upper() for k in ["REMISE", "DEDUCTION"]):
                    price = -abs(price)
                
                line_subtotal = (price * qty) * (1 - discount / 100.0)
                grouped_data[rate_f]["subtotal"] += line_subtotal

            sum_subtotals = sum(data["subtotal"] for data in grouped_data.values())
            total_ht = None
            if parsed_data:
                ht_val = self._get_nested_value(parsed_data, "total_ht") or self._get_nested_value(parsed_data, "subtotal")
                total_ht = self._ocr_parse_float(ht_val, None)

            if total_ht is not None and sum_subtotals > 0:
                diff = abs(sum_subtotals - total_ht)
                if diff > 0.5:
                    _logger.warning("OCR auto-correction: Line subtotals sum (%.2f) differs from total_ht (%.2f). Scaling lines.", sum_subtotals, total_ht)
                    ratio = total_ht / sum_subtotals
                    for data in grouped_data.values():
                        data["subtotal"] = round(data["subtotal"] * ratio, 3)

            # Rebalance using tax_summary is disabled to trust detailed lines more.
            pass

            for rate_f, data in grouped_data.items():
                tax_ids = []
                
                # Generalize tax type (sale vs purchase)
                tax_type = "sale" if move.move_type in ("out_invoice", "out_refund") else "purchase"

                # Check if this group looks like a Timbre Fiscal line (often labeled via tax_rate or description)
                # In the grouped scenario, we prioritize finding standard tax rates.
                if rate_f > 0:
                    if rate_f not in tax_cache:
                        tax = self.env["account.tax"].search(
                            [
                                ("type_tax_use", "=", tax_type),
                                ("amount", "=", rate_f),
                                ("company_id", "=", move.company_id.id),
                            ],
                            limit=1,
                        )
                        tax_cache[rate_f] = tax.id if tax else False
                    if tax_cache[rate_f]:
                        tax_ids = [Command.set([tax_cache[rate_f]])]

                line_name = _("Extracted Line (Tax %s%%)") % rate_f if rate_f > 0 else _("Extracted Line (No Tax)")
                price_unit = data["subtotal"]

                # If this tax rate looks like a Timbre Fiscal (1.0 in Tunisia), force price to 0
                if rate_f == 1.0:
                    line_name = _("Timbre fiscal")
                    price_unit = 0.0

                line_vals = {
                    "name": line_name,
                    "quantity": 1.0,
                    "price_unit": price_unit,
                    "tax_ids": tax_ids,
                }
                if tax_type == "purchase":
                    line_vals["account_id"] = move.journal_id.default_account_id.id
                
                line_commands.append(Command.create(line_vals))
        else:
            for line in extracted_lines:
                if not isinstance(line, dict):
                    continue
                line_name = str(
                    self._get_nested_value(line, "description", "")
                ).strip()
                if not line_name or len(line_name) < 2:
                    continue
                tax_ids = []
                tax_rate = line.get("tax_rate") or default_tax_rate
                
                # Generalize for Purchase vs Sale
                tax_type = "sale" if move.move_type in ("out_invoice", "out_refund") else "purchase"

                if "timbre" in line_name.lower():
                    # Special logic for the Stamp Duty line
                    tax = self.env["account.tax"].search([
                        ("type_tax_use", "=", tax_type),
                        ("company_id", "=", move.company_id.id),
                        '|',
                        ("name", "ilike", "Timbre"),
                        ("amount", "=", 1.0)
                    ], limit=1)
                    if tax:
                        tax_ids = [Command.set([tax.id])]
                elif tax_rate:
                    try:
                        rate_f = self._ocr_parse_float(
                            self._get_nested_value({"rate": tax_rate}, "rate", 0.0)
                        )
                        if rate_f > 0:
                            if rate_f not in tax_cache:
                                tax = self.env["account.tax"].search(
                                    [
                                        ("type_tax_use", "=", tax_type),
                                        ("amount", "=", rate_f),
                                        ("company_id", "=", move.company_id.id),
                                    ],
                                    limit=1,
                                )
                                tax_cache[rate_f] = tax.id if tax else False
                            if tax_cache[rate_f]:
                                tax_ids = [Command.set([tax_cache[rate_f]])]
                    except (ValueError, TypeError):
                        pass

                try:
                    unit_price = self._ocr_parse_float(self._get_nested_value(line, "unit_price", 0.0))
                    qty = self._ocr_parse_float(self._get_nested_value(line, "quantity", 1.0), 1.0)
                except (ValueError, TypeError):
                    unit_price, qty = 0.0, 1.0

                if any(k in line_name.upper() for k in ["REMISE", "DEDUCTION"]):
                    unit_price = -abs(unit_price)

                if "timbre" in line_name.lower():
                    unit_price = 0.0
                    qty = 1.0

                if unit_price == 0 and qty == 0 and "timbre" not in line_name.lower():
                    continue

                line_vals = {
                    "name": line_name,
                    "quantity": qty,
                    "price_unit": unit_price,
                    "discount": float(self._get_nested_value(line, "discount", 0.0)),
                    "tax_ids": tax_ids,
                }
                # For purchase, try to set a default account
                if tax_type == "purchase":
                    line_vals["account_id"] = move.journal_id.default_account_id.id
                
                line_commands.append(Command.create(line_vals))

        return line_commands

    @api.model
    def _contact_iap_extract(self, pathinfo, params):
        """
        Redirect standard Odoo OCR calls to the custom local server.
        """
        client = self._get_ocr_client()
        if pathinfo == "parse":
            if not params.get("documents"):
                return {"status": "error_no_document"}
            doc_b64 = params["documents"][0]
            user_infos = {
                "company_name": self.env.company.name if self.env.company else "",
                "currency": self.env.company.currency_id.name if self.env.company else "",
                "supplier_name": self.partner_id.name if self.partner_id else "",
            }
            result = client.parse(doc_b64, service_name="invoice_ocr", user_infos=user_infos)
            if result.get("status") == "success":
                return result
            return {"status": "error", "message": result.get("message", "Unknown error")}
        elif pathinfo == "get_result":
            if not params.get("uuid"):
                return {"status": "error_no_uuid"}
            return client.get_result(params["uuid"])
        return {"status": "success"}
