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

"""
Account Bank Statement — AMCS OCR Override
-------------------------------------------
Intercepte le flux OCR standard d'Odoo (account_bank_statement_extract → IAP Odoo)
et le redirige vers le serveur OCR AMCS (https://ocr.amcs.tn).

Stratégie :
  - Le serveur AMCS est synchrone : le résultat complet est retourné lors du premier
    appel 'parse' (pas de polling asynchrone nécessaire).
  - On stocke le résultat brut dans `amcs_ocr_raw_result` lors du 'parse'.
  - Lors du 'get_result', on lit ce champ et on convertit le format AMCS → format Odoo.
  - On surcharge `_get_ocr_module_name()` pour pointer vers nos propres crons.
"""

import json
import logging
import uuid

from odoo import api, fields, models, _

_logger = logging.getLogger(__name__)


class AccountBankStatement(models.Model):
    """
    Surcharge de account.bank.statement pour rediriger l'OCR vers le serveur AMCS.
    Ce module est chargé après ds_amcs_ocr_connector, donc sa surcharge a priorité.
    """
    _inherit = 'account.bank.statement'

    amcs_ocr_raw_result = fields.Text(
        string='AMCS OCR Raw Result',
        copy=False,
        help=(
            'Résultat JSON brut retourné par le serveur OCR AMCS lors du traitement synchrone. '
            'Utilisé comme buffer interne entre les phases parse et get_result du flux Odoo. '
            'Automatiquement nettoyé après remplissage du relevé.'
        ),
    )

    # -------------------------------------------------------------------------
    # Point d'interception principal — appelé par extract.mixin._upload_to_extract()
    # -------------------------------------------------------------------------

    def _contact_iap_extract(self, pathinfo, params):
        """
        Override : redirige tous les appels IAP vers le serveur OCR AMCS.

        Pathinfo possibles (définis par extract.mixin) :
          - 'parse'      : envoi du document pour traitement OCR
          - 'get_result' : récupération du résultat
          - 'validate'   : envoi du feedback ML après validation utilisateur
        """
        self.ensure_one()
        _logger.info(
            "AMCS Bank OCR [id=%s]: _contact_iap_extract(pathinfo=%r)",
            self.id, pathinfo,
        )

        if pathinfo == 'parse':
            return self._amcs_parse(params)
        elif pathinfo == 'get_result':
            return self._amcs_get_result(params)
        elif pathinfo == 'validate':
            return self._amcs_validate(params)
        else:
            # Autres pathinfo éventuels (ex: 'set_user_data') — on ignore silencieusement
            _logger.debug("AMCS Bank OCR: pathinfo non géré '%s' → retour success", pathinfo)
            return {'status': 'success'}

    # -------------------------------------------------------------------------
    # Phase 1 : Envoi du document au serveur AMCS
    # -------------------------------------------------------------------------

    def _amcs_parse(self, params):
        """
        Envoie le document PDF/image au serveur OCR AMCS et stocke le résultat brut.

        Le serveur AMCS étant synchrone, le résultat complet est disponible immédiatement.
        On le stocke dans `amcs_ocr_raw_result` pour le restituer lors du 'get_result'.

        Retourne le format attendu par extract.mixin._upload_to_extract() :
          {'status': 'success', 'document_token': '<uuid>'}
        """
        from odoo.addons.ds_amcs_ocr_connector.models.ocr_client import OCRClientFactory

        documents = params.get('documents', [])
        if not documents:
            _logger.warning("AMCS Bank OCR [id=%s]: aucun document dans params", self.id)
            return {'status': 'error_no_document'}

        doc_b64 = documents[0]
        user_infos = params.get('user_infos', {})

        try:
            client = OCRClientFactory.create(self.env)
            # On force le passage du service_name dans user_infos pour le prompt LLM
            user_infos.update({
                'service_name': 'bank_statement_ocr',
                'document_type': 'bank_statement',
            })
            amcs_result = client.parse(
                doc_base64=doc_b64,
                service_name='bank_statement_ocr',
                user_infos=user_infos,
            )
        except Exception as e:
            _logger.error("AMCS Bank OCR [id=%s]: erreur lors du parse: %s", self.id, e)
            return {'status': 'error_no_connection'}

        # Générer ou récupérer un token de document unique
        doc_token = amcs_result.get('document_token') or str(uuid.uuid4())

        # Stocker le résultat brut pour la phase get_result
        self.sudo().write({
            'amcs_ocr_raw_result': json.dumps(amcs_result),
            # Mise à jour préventive du document_uuid (aussi réalisée par _upload_to_extract)
            'extract_document_uuid': doc_token,
        })

        _logger.info(
            "AMCS Bank OCR [id=%s]: parse OK → token=%s, lines=%d, statement_lines=%d",
            self.id,
            doc_token[:8],
            len(amcs_result.get('lines', [])),
            len(amcs_result.get('bank_statement_lines', [])),
        )

        return {
            'status': 'success',
            'document_token': doc_token,
        }

    # -------------------------------------------------------------------------
    # Phase 2 : Récupération du résultat (polling simulé depuis le buffer local)
    # -------------------------------------------------------------------------

    def _amcs_get_result(self, params):
        """
        Restitue le résultat OCR préalablement stocké par _amcs_parse().

        Convertit le format AMCS → format Odoo attendu par
        account.bank.statement._fill_document_with_results().

        Retourne le format attendu par extract.mixin._check_ocr_status() :
          {'status': 'success', 'results': [<odoo_format>]}
        """
        raw = self.sudo().amcs_ocr_raw_result
        if not raw:
            _logger.warning(
                "AMCS Bank OCR [id=%s]: get_result appelé mais amcs_ocr_raw_result est vide. "
                "Le parse n'a peut-être pas encore abouti.",
                self.id,
            )
            # Retourner 'processing' pour que le mixin réessaie plus tard
            return {'status': 'processing'}

        try:
            amcs_result = json.loads(raw)
        except (json.JSONDecodeError, TypeError) as e:
            _logger.error(
                "AMCS Bank OCR [id=%s]: impossible de désérialiser amcs_ocr_raw_result: %s",
                self.id, e,
            )
            return {'status': 'error_internal'}

        # LOG CRITIQUE : afficher toute la structure pour diagnostiquer
        _logger.info(
            "AMCS Bank OCR [id=%s]: STRUCTURE COMPLÈTE amcs_result keys=%s | fields_keys=%s | "
            "bank_statement_lines_count=%s | invoice_lines_count=%s",
            self.id,
            list(amcs_result.keys()) if isinstance(amcs_result, dict) else type(amcs_result),
            list(amcs_result.get('fields', {}).keys()) if isinstance(amcs_result.get('fields'), dict) else amcs_result.get('fields'),
            len(amcs_result.get('fields', {}).get('bank_statement_lines') or amcs_result.get('bank_statement_lines') or []),
            len(amcs_result.get('fields', {}).get('invoice_lines') or amcs_result.get('invoice_lines') or []),
        )
        _logger.info("AMCS Bank OCR [id=%s]: RAW amcs_result (first 2000 chars) = %s", self.id, str(amcs_result)[:2000])

        odoo_result = self._convert_amcs_to_odoo_format(amcs_result)

        _logger.info(
            "AMCS Bank OCR [id=%s]: get_result OK → balance_start=%s, balance_end=%s, lines=%d",
            self.id,
            odoo_result.get('balance_start', {}).get('selected_value', {}).get('content'),
            odoo_result.get('balance_end', {}).get('selected_value', {}).get('content'),
            len(odoo_result.get('bank_statement_lines', [])),
        )

        return {
            'status': 'success',
            'results': [odoo_result],
        }

    # -------------------------------------------------------------------------
    # Phase 3 : Feedback ML (optionnel)
    # -------------------------------------------------------------------------

    def _amcs_validate(self, params):
        """
        Envoie le feedback de validation (données corrigées par l'utilisateur)
        au serveur AMCS pour amélioration continue du modèle ML.
        """
        try:
            from odoo.addons.ds_amcs_ocr_connector.models.ocr_client import OCRClientFactory
            client = OCRClientFactory.create(self.env)
            document_token = params.get('document_token') or self.extract_document_uuid
            values = params.get('values', {})
            if document_token and values:
                client.validate(document_token, values)
                _logger.info(
                    "AMCS Bank OCR [id=%s]: feedback validate envoyé (token=%s)",
                    self.id, str(document_token)[:8],
                )
        except Exception as e:
            # Le feedback ML est optionnel — on ne bloque pas le flux en cas d'erreur
            _logger.warning(
                "AMCS Bank OCR [id=%s]: échec du feedback validate (non bloquant): %s",
                self.id, e,
            )
        return {'status': 'success'}

    # -------------------------------------------------------------------------
    # Conversion format AMCS → format Odoo bank statement
    # -------------------------------------------------------------------------

    def _convert_amcs_to_odoo_format(self, amcs_result):
        """
        Version ultra-robuste : convertit le format AMCS vers Odoo.
        """
        def _parse_float(val):
            if val is None or val is False:
                return 0.0
            if isinstance(val, (int, float)):
                return float(val)
            try:
                # Nettoyage radical pour format tunisien (millimes)
                # Ex: "2,739,610" -> "2739.610"
                s = str(val).replace(' ', '').replace('\xa0', '')
                
                # Si on a plusieurs virgules ou points, ce sont des séparateurs de milliers
                # On ne garde que le dernier comme séparateur décimal si sa position est cohérente
                if s.count(',') + s.count('.') > 1:
                    # On retire tout sauf le dernier séparateur
                    last_pos = max(s.rfind(','), s.rfind('.'))
                    prefix = s[:last_pos].replace(',', '').replace('.', '')
                    suffix = s[last_pos+1:]
                    s = prefix + '.' + suffix
                elif ',' in s:
                    s = s.replace(',', '.')
                
                return float(s)
            except (ValueError, TypeError):
                return 0.0

        def _wrap(value, default=None):
            return {'selected_value': {'content': value if value is not None else default}}

        def _parse_date(val):
            """Convertit une date en format libre → 'YYYY-MM-DD' (requis par Odoo)."""
            if not val:
                return ''
            s = str(val).strip()
            if len(s) == 10 and s[4] == '-' and s[7] == '-':
                return s  # Déjà au bon format
            months = {
                'jan': '01', 'feb': '02', 'mar': '03', 'apr': '04',
                'may': '05', 'jun': '06', 'jul': '07', 'aug': '08',
                'sep': '09', 'oct': '10', 'nov': '11', 'dec': '12',
                'janv': '01', 'fevr': '02', 'mars': '03', 'avr': '04',
                'mai': '05', 'juin': '06', 'juil': '07', 'aout': '08',
                'sept': '09', 'octo': '10', 'nove': '11', 'dece': '12',
            }
            import re
            # "06 MAY 26" ou "13 APR 2026"
            m = re.match(r'(\d{1,2})\s+([A-Za-z]+)\s+(\d{2,4})$', s)
            if m:
                day, mon, year = m.group(1), m.group(2).lower()[:4], m.group(3)
                month_num = months.get(mon, '00')
                year = '20' + year if len(year) == 2 else year
                if month_num != '00':
                    return f"{year}-{month_num}-{day.zfill(2)}"
            # "DD/MM/YYYY", "YYYY-MM-DD", ou "DD/MM/YY"
            m2 = re.match(r'(\d{1,4})[/\-\.](\d{1,2})[/\-\.](\d{2,4})$', s)
            if m2:
                a, b, c = m2.group(1), m2.group(2), m2.group(3)
                if len(a) == 4:  # YYYY-MM-DD
                    return f"{a}-{b.zfill(2)}-{c.zfill(2)}"
                else:  # DD/MM/YYYY ou DD/MM/YY
                    year = ('20' + c) if len(c) == 2 else c
                    return f"{year}-{b.zfill(2)}-{a.zfill(2)}"
            _logger.warning("AMCS Bank OCR: date non parsée '%s'", val)
            return ''

        # CORRECTION : le stockage contient la réponse serveur complète
        # {'status': 'success', 'result': {'lines': [...], 'fields': {...}}, ...}
        # Les données OCR réelles sont dans amcs_result['result']
        ocr_data = amcs_result.get('result') or amcs_result

        # Les données structurées (extraites par LLM) sont dans ocr_data['fields']
        fields = ocr_data.get('fields') or {}

        # --- Fallback : si fields est vide, parser les lignes OCR brutes ---
        if not fields and (ocr_data.get('words') or ocr_data.get('lines')):
            _logger.info(
                "AMCS Bank OCR [id=%s]: fields empty (no LLM refinement), "
                "falling back to raw OCR parser",
                self.id,
            )
            return self._parse_raw_ocr_lines(ocr_data)

        # --- Soldes ---
        balance_start = _parse_float(fields.get('balance_start'))
        balance_end   = _parse_float(fields.get('balance_end') or fields.get('total_ttc'))

        # --- Date ---
        date_val = _parse_date(fields.get('date') or '')

        # --- Lignes ---
        # On préfère bank_statement_lines pour un extrait comptable.
        # Fallback sur invoice_lines car le serveur AMCS peut y placer les données
        # selon le type de document détecté. Les lignes timbre fiscal sont filtrées plus bas.
        raw_lines = fields.get('bank_statement_lines') or fields.get('invoice_lines') or []
        statement_lines = []

        # Mots-clés à exclure — seuls les éléments de facture sans équivalent
        # dans un extrait de compte bancaire.
        INVOICE_KEYWORDS = {
            'timbre fiscal', 'timbre',
        }

        from datetime import date as _date
        seen_lines = set()  # Contrainte anti-doublons : (date, reference, amount)

        for line in raw_lines:
            if not isinstance(line, dict):
                _logger.warning("AMCS Bank OCR [id=%s]: ligne ignorée (type %s)", self.id, type(line))
                continue

            amount = _parse_float(
                line.get('montant') or line.get('amount') or line.get('unit_price')
                or line.get('debit') or line.get('credit') or line.get('Debit') or line.get('Credit')
            )
            libelle = line.get('libelle_operation') or line.get('libelle') or line.get('description') or line.get('name') or line.get('operation') or ''
            ref = line.get('reference') or line.get('ref') or line.get('référence') or ''

            # Si l'OCR n'a pas de champ référence, essayer d'extraire depuis la description
            # (ex: "FRAIS INTERCHANGE AC-TND133310001" → ref="AC-TND133310001")
            if not ref:
                import re
                ref_match = re.search(r'\b(?:AC[\-][\w]+|FT\w+|CHG\d+)\b', libelle)
                if ref_match:
                    ref = ref_match.group()

            # --- Exclure les lignes qui ressemblent à des éléments de facture ---
            label_lower = f"{libelle} {ref}".strip().lower()
            if any(kw in label_lower for kw in INVOICE_KEYWORDS):
                _logger.info(
                    "AMCS Bank OCR [id=%s]: ligne exclue (élément de facture) → '%s'",
                    self.id, label_lower[:80],
                )
                continue

            full_label = f"{libelle} {ref}".strip() or 'Transaction OCR'
            date_op = _parse_date(
                line.get('date_operation') or line.get('date_valeur') or line.get('date') or ''
            ) or date_val or str(_date.today())

            # --- Déduplication : inclure le libellé pour éviter de confondre
            # deux transactions différentes avec le même montant (ex: 2 RETRAIT DAB de -100,000)
            fingerprint = (date_op, ref.strip(), libelle.strip(), round(amount, 3))
            if fingerprint in seen_lines:
                _logger.warning(
                    "AMCS Bank OCR [id=%s]: ligne dupliquée ignorée → date=%s, ref=%s, libelle=%s, amount=%s",
                    self.id, date_op, ref, libelle[:40], amount,
                )
                continue
            seen_lines.add(fingerprint)

            statement_lines.append({
                'amount':      amount,
                'date':        date_op,
                'payment_ref': full_label,
                'description': full_label,
            })

        _logger.info("AMCS Bank OCR: Extracted %d lines for statement %s", len(statement_lines), self.id)

        return {
            'balance_start':         _wrap(balance_start, 0.0),
            'balance_end':           _wrap(balance_end, 0.0),
            'date':                  _wrap(date_val, ''),
            'bank_statement_lines':  statement_lines,
            'words':                 self._convert_words_to_boxes(ocr_data.get('words')),
            'numbers':               self._convert_list_to_dict(ocr_data.get('numbers')),
            'dates':                 self._convert_list_to_dict(ocr_data.get('dates')),
            'full_text_annotation':  '\n'.join(str(l) for l in ocr_data.get('lines', [])),
        }

    # -------------------------------------------------------------------------
    # Converters pour le format attendu par extract.mixin.with.words
    # -------------------------------------------------------------------------

    @api.model
    def _convert_words_to_boxes(self, words_data):
        """
        Convertit les words du format PaddleOCR (liste de dicts avec
        x1/y1/x2/y2) vers le format dict attendu par get_boxes() :
        {page_nbr: [{content, coords: [cx, cy, w, h, angle]}, ...]}
        """
        if not words_data or not isinstance(words_data, (list, dict)):
            return {}
        if isinstance(words_data, dict):
            # Déjà au bon format
            return words_data
        boxes = {}
        for w in words_data:
            page = w.get('page', 0)
            x1 = w.get('x1', 0.0)
            y1 = w.get('y1', 0.0)
            x2 = w.get('x2', x1 + 1.0)
            y2 = w.get('y2', y1 + 1.0)
            cx = (x1 + x2) / 2.0
            cy = (y1 + y2) / 2.0
            width = x2 - x1
            height = y2 - y1
            boxes.setdefault(page, []).append({
                'content': w.get('text', ''),
                'coords': [cx, cy, max(width, 1.0), max(height, 1.0), 0.0],
            })
        return boxes

    @api.model
    def _convert_list_to_dict(self, data):
        """Ensure numbers/dates are in dict format."""
        if isinstance(data, dict):
            return data
        return {}

    # -------------------------------------------------------------------------
    # Fallback OCR parser (quand le server retourne raw lines sans fields)
    # -------------------------------------------------------------------------

    def _reconstruct_lines_from_words(self, words, y_tolerance=15):
        """
        Reconstruit des lignes de texte complètes à partir des mots OCR
        avec leurs coordonnées (x1, y1, x2, y2).

        Les mots sont groupés par Y (tolérance ~15px) pour retrouver
        chaque ligne du document original, puis triés par X pour
        respecter l'ordre gauche→droite.
        """
        if not words:
            return []
        sorted_words = sorted(words, key=lambda w: (w.get('y1', 0), w.get('x1', 0)))
        y_groups = []
        cur_group = []
        cur_y = None
        for w in sorted_words:
            y = w.get('y1', 0)
            if cur_y is None or abs(y - cur_y) <= y_tolerance:
                cur_group.append(w)
                if cur_y is None:
                    cur_y = y
            else:
                if cur_group:
                    y_groups.append(cur_group)
                cur_group = [w]
                cur_y = y
        if cur_group:
            y_groups.append(cur_group)

        lines = []
        for group in y_groups:
            group.sort(key=lambda w: w.get('x1', 0))
            text = ' '.join(w.get('text', '') for w in group).strip()
            if text:
                lines.append(text)
        return lines

    def _parse_raw_ocr_lines(self, ocr_data):
        """
        Fallback parser : convertit les lignes OCR brutes (raw lines/words)
        en format Odoo bank statement.

        Activé quand le server retourne fields:{} (pas de raffinement LLM).
        - Phase A : reconstruction via `words` avec coordonnées (meilleure précision)
        - Phase B : fallback via `lines` brutes (regex)
        """
        import re
        from datetime import date as _date

        # --- Helpers (copie locale des helpers de _convert_amcs_to_odoo_format) ---
        def _parse_float(val):
            if val is None or val is False:
                return 0.0
            if isinstance(val, (int, float)):
                return float(val)
            try:
                s = str(val).replace(' ', '').replace('\xa0', '')
                if s.count(',') + s.count('.') > 1:
                    last_pos = max(s.rfind(','), s.rfind('.'))
                    prefix = s[:last_pos].replace(',', '').replace('.', '')
                    suffix = s[last_pos + 1:]
                    s = prefix + '.' + suffix
                elif ',' in s:
                    s = s.replace(',', '.')
                return float(s)
            except (ValueError, TypeError):
                return 0.0

        def _wrap(value, default=None):
            return {'selected_value': {'content': value if value is not None else default}}

        def _parse_date(val):
            if not val:
                return ''
            s = str(val).strip()
            if len(s) == 10 and s[4] == '-' and s[7] == '-':
                return s
            months = {
                'jan': '01', 'feb': '02', 'mar': '03', 'apr': '04',
                'may': '05', 'jun': '06', 'jul': '07', 'aug': '08',
                'sep': '09', 'oct': '10', 'nov': '11', 'dec': '12',
                'janv': '01', 'fevr': '02', 'mars': '03', 'avr': '04',
                'mai': '05', 'juin': '06', 'juil': '07', 'aout': '08',
                'sept': '09', 'octo': '10', 'nove': '11', 'dece': '12',
            }
            m = re.match(r'(\d{1,2})\s+([A-Za-z]+)\s+(\d{2,4})$', s)
            if m:
                day, mon, year = m.group(1), m.group(2).lower()[:4], m.group(3)
                month_num = months.get(mon, '00')
                year = '20' + year if len(year) == 2 else year
                if month_num != '00':
                    return f"{year}-{month_num}-{day.zfill(2)}"
            m2 = re.match(r'(\d{1,4})[/\-\.](\d{1,2})[/\-\.](\d{2,4})$', s)
            if m2:
                a, b, c = m2.group(1), m2.group(2), m2.group(3)
                if len(a) == 4:
                    return f"{a}-{b.zfill(2)}-{c.zfill(2)}"
                else:
                    year = ('20' + c) if len(c) == 2 else c
                    return f"{year}-{b.zfill(2)}-{a.zfill(2)}"
            return ''

        # --- Priorité : utiliser lines_raw (1 champ/ligne, compatible parser) ---
        words = ocr_data.get('words', [])
        lines_raw = ocr_data.get('lines', [])

        lines = []
        if lines_raw:
            lines = lines_raw
            _logger.info(
                "AMCS Bank OCR [id=%s]: Fallback — %d lignes OCR brutes (1 champ/ligne)",
                self.id, len(lines),
            )

        # --- Si pas de lines_raw, tenter reconstruction via words ---
        if not lines and words:
            lines = self._reconstruct_lines_from_words(words)
            _logger.info(
                "AMCS Bank OCR [id=%s]: Fallback — %d lignes reconstruites depuis %d words",
                self.id, len(lines), len(words),
            )

        if not lines:
            _logger.warning("AMCS Bank OCR [id=%s]: Fallback — aucune donnée OCR disponible", self.id)
            return {
                'balance_start': _wrap(0.0),
                'balance_end': _wrap(0.0),
                'date': _wrap(''),
                'bank_statement_lines': [],
                'full_text_annotation': '\n'.join(str(l) for l in lines_raw),
            }

        # --- Patterns ---
        date_pat = re.compile(
            r'^\s*(\d{1,2})\s+'
            r'(JANV?|F[EÉ]VR?|MARS|AVR|MAI|JUIN|JUIL|AOU[ÛU]T?|SEPT?|OCTO?|NOVE?|D[EÉ]CE?'
            r'|JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC)'
            r'[A-ZÉÈÊËÎÏÔÛÙ]*\.?\s+(\d{2,4})$',
            re.IGNORECASE,
        )
        amt_pat = re.compile(r'^[\+\-]?\s*[\d\s]{1,12}[,\.]\d{3}$')

        balance_start = 0.0
        balance_end = 0.0
        date_val = ''
        statement_lines = []
        seen_fingerprints = set()
        in_tx_block = False

        i = 0
        while i < len(lines):
            line = lines[i].strip()
            if not line:
                i += 1
                continue

            line_lower = line.lower()

            # --- Ignorer les lignes d'en-tête / bruit / artefacts facture ---
            if any(h in line_lower for h in [
                'extrait de compte', 'compte numéro', 'devise', 'title',
                'biatnet', 'sauf erreur', 'http', 'www.', '1/1',
                'la firme de', 'b4-10-', 'timbre fiscal', 'timbre',
                'facture', 'tva', 'total ht', 'total ttc', 'net à payer',
            ]):
                i += 1
                continue

            # --- Détecter les en-têtes de colonnes pour activer le mode transaction ---
            if any(h in line_lower for h in [
                'date valeur', 'libellé', 'référence', 'date opération', 'montant',
            ]):
                in_tx_block = True
                i += 1
                continue

            # --- Solde départ ---
            if 'solde d' in line_lower and 'part' in line_lower:
                # Le montant est sur la ligne précédente
                if i > 0:
                    prev = lines[i - 1].strip()
                    if amt_pat.match(prev.replace(' ', '')):
                        balance_start = _parse_float(prev)
                        _logger.info(
                            "AMCS Bank OCR [id=%s]: Fallback balance_start=%s",
                            self.id, balance_start,
                        )
                i += 1
                continue

            # --- Solde fin (détection robuste) ---
            if 'solde fin' in line_lower:
                # Chercher le montant sur la ligne courante ou les 3 suivantes
                for offset in range(0, 3):
                    candidate = lines[i + offset].strip() if i + offset < len(lines) else ''
                    if not candidate:
                        continue
                    # Nettoyage agressif
                    clean_val = candidate.replace(' ', '').replace('\xa0', '')
                    try:
                        parsed = _parse_float(clean_val)
                        if parsed != 0.0 or clean_val == '0':
                            balance_end = parsed
                            _logger.info(
                                "AMCS Bank OCR [id=%s]: Fallback balance_end=%s (ligne %d: '%s')",
                                self.id, balance_end, i + offset, candidate,
                            )
                            break
                    except (ValueError, TypeError):
                        continue
                i += 1
                continue

            # --- Solde disponible (ignorer, on garde solde fin) ---
            if 'solde disponible' in line_lower:
                i += 1
                continue

            # --- Tentative d'extraction d'une transaction ---
            if in_tx_block:
                m = date_pat.match(line)
                if m:
                    date_v = line
                    i += 1

                    # Collecter les lignes suivantes jusqu'à trouver un montant
                    parts = []
                    while i < len(lines):
                        nxt = lines[i].strip()
                        if not nxt:
                            i += 1
                            continue
                        # Stop si on arrive au solde ou footer
                        if any(h in nxt.lower() for h in ['solde', 'sauf erreur']):
                            break
                        parts.append(nxt)
                        i += 1
                        # Si cette partie ressemble à un montant, on arrête la collecte
                        if amt_pat.match(nxt.replace(' ', '')) or self._looks_like_amount(nxt):
                            break

                    # Parser les parties collectées
                    libelle = ''
                    reference = ''
                    date_op = ''
                    montant = ''

                    for p in parts:
                        p = p.strip()
                        if not p:
                            continue
                        p_clean = p.replace(' ', '')
                        # Montant (prioritaire)
                        if amt_pat.match(p_clean) or self._looks_like_amount(p):
                            montant = p
                        # Date
                        elif date_pat.match(p):
                            date_op = p
                        # Référence : commence par REF ou 2 lettres + chiffres
                        elif p.startswith('REF') or re.match(r'^[A-Za-z]{2}\d', p):
                            reference = p
                        # Libellé (premier texte)
                        elif not libelle:
                            libelle = p
                        elif not reference:
                            reference = p

                    if montant:
                        amount = _parse_float(montant)
                        date_use = _parse_date(date_v or date_op) or str(_date.today())
                        desc = ' '.join(filter(None, [libelle, reference])).strip() or 'Transaction OCR'

                        # Exclure les lignes timbre fiscal (élément de facture, pas de relevé bancaire)
                        desc_lower = desc.lower()
                        if any(kw in desc_lower for kw in [
                            'timbre fiscal', 'timbre',
                        ]):
                            _logger.info(
                                "AMCS Bank OCR [id=%s]: Fallback — ligne exclue (élément de facture) → '%s'",
                                self.id, desc[:80],
                            )
                            continue

                        fingerprint = (date_use, reference.strip(), libelle.strip(), round(amount, 3))
                        if fingerprint not in seen_fingerprints:
                            seen_fingerprints.add(fingerprint)
                            statement_lines.append({
                                'amount': amount,
                                'date': date_use,
                                'payment_ref': desc,
                                'description': desc,
                            })
                    continue

            i += 1

        _logger.info(
            "AMCS Bank OCR [id=%s]: Fallback parser — %d lignes extraites "
            "(balance_start=%s, balance_end=%s)",
            self.id, len(statement_lines), balance_start, balance_end,
        )

        return {
            'balance_start': _wrap(balance_start, 0.0),
            'balance_end': _wrap(balance_end, 0.0),
            'date': _wrap(date_val, ''),
            'bank_statement_lines': statement_lines,
            'words': self._convert_words_to_boxes(ocr_data.get('words')),
            'numbers': self._convert_list_to_dict(ocr_data.get('numbers')),
            'dates': self._convert_list_to_dict(ocr_data.get('dates')),
            'full_text_annotation': '\n'.join(str(l) for l in lines_raw),
        }

    @api.model
    def _looks_like_amount(self, text):
        """Check if text looks like a monetary amount (loose check)."""
        if not text or not isinstance(text, str):
            return False
        clean = text.replace(' ', '').replace('\xa0', '').replace('(', '').replace(')', '')
        # Must contain at least one digit and one separator (comma or dot)
        if not any(c.isdigit() for c in clean):
            return False
        if ',' not in clean and '.' not in clean:
            return False
        # Must end with 3 digits after last separator (millimes)
        if ',' in clean:
            after = clean.split(',')[-1]
        elif '.' in clean:
            after = clean.split('.')[-1]
        else:
            return False
        return after.isdigit() and len(after) == 3

    # -------------------------------------------------------------------------
    # Surcharges du mixin pour pointer vers ce module
    # -------------------------------------------------------------------------

    def _get_ocr_module_name(self):
        """
        Override : pointe vers notre module pour que le mixin retrouve nos crons.
        Le mixin cherche : {module_name}.ir_cron_ocr_validate
        """
        return 'ds_amcs_bank_statement_extract'

    def _get_ocr_option_can_extract(self):
        """
        Override : réutilise l'option de digitalisation définie par le module standard.
        L'option est configurée dans Comptabilité → Configuration → Paramètres.
        """
        ocr_option = self.env.company.extract_bank_statement_digitalization_mode
        return bool(ocr_option and ocr_option != 'no_send')

    # -------------------------------------------------------------------------
    # Nettoyage après remplissage
    # -------------------------------------------------------------------------

    def _fill_document_with_results(self, ocr_results):
        """
        Override minimal : délègue au parent puis nettoie le buffer OCR brut.
        """
        super()._fill_document_with_results(ocr_results)
        # Nettoyage du buffer de résultat synchrone après remplissage réussi
        if self.amcs_ocr_raw_result:
            self.sudo().write({'amcs_ocr_raw_result': False})
            _logger.debug(
                "AMCS Bank OCR [id=%s]: amcs_ocr_raw_result nettoyé après remplissage",
                self.id,
            )
