"""
Fusion Accounting - Invoice OCR Extraction

Extends ``account.move`` with the ability to extract invoice data from
attached PDF / image scans using the :class:`FusionDocumentExtractor`
engine.  Extracted fields (vendor, amounts, dates, line items) are
parsed via regex heuristics and then applied to the invoice form.

A manual-review wizard (:class:`FusionExtractionReviewWizard`) is
available so the user can validate and correct fields before they are
committed.

Original implementation by Nexa Systems Inc.
"""

import base64
import io
import logging
import re
from datetime import datetime

from odoo import api, fields, models, _
from odoo.exceptions import UserError

_log = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Optional imports
# ---------------------------------------------------------------------------
try:
    from PIL import Image
    _PILLOW_AVAILABLE = True
except ImportError:
    _PILLOW_AVAILABLE = False


class FusionInvoiceExtractor(models.Model):
    """
    Adds OCR-extraction capabilities to journal entries (invoices / bills).

    The workflow is:

    1. User clicks **Extract from Attachment**.
    2. The first PDF / image attachment is sent to the configured
       :class:`FusionDocumentExtractor`.
    3. Raw OCR text is stored and parsed for key invoice fields.
    4. A review wizard is shown so the user can inspect / correct before
       the fields are written to the invoice.
    """

    _inherit = "account.move"

    # ------------------------------------------------------------------
    # Fields
    # ------------------------------------------------------------------
    fusion_extraction_status = fields.Selection(
        selection=[
            ("to_extract", "Pending Extraction"),
            ("extracting", "Extracting…"),
            ("done", "Extraction Complete"),
            ("failed", "Extraction Failed"),
        ],
        string="OCR Status",
        copy=False,
        tracking=True,
        help="Tracks the current stage of the document extraction pipeline.",
    )
    fusion_extraction_confidence = fields.Float(
        string="Extraction Confidence",
        digits=(5, 2),
        copy=False,
        readonly=True,
        help=(
            "A score from 0–100 indicating how confident the extraction "
            "engine is in the accuracy of the parsed fields."
        ),
    )
    fusion_ocr_raw_text = fields.Text(
        string="OCR Raw Text",
        copy=False,
        readonly=True,
        help="The full plain-text output returned by the OCR engine.",
    )
    fusion_extractor_id = fields.Many2one(
        comodel_name="fusion.document.extractor",
        string="Extractor Used",
        copy=False,
        readonly=True,
        help="The extraction provider that produced the OCR result.",
    )
    fusion_extracted_fields_json = fields.Text(
        string="Extracted Fields (JSON)",
        copy=False,
        readonly=True,
        help="JSON-serialised dict of all structured fields returned by the extraction.",
    )

    # ------------------------------------------------------------------
    # Main action: Extract from Attachment
    # ------------------------------------------------------------------
    def action_extract_from_attachment(self):
        """Run OCR extraction on the first PDF / image attachment.

        This method:
        1. Locates the first suitable attachment on the invoice.
        2. Selects the active extractor for the current company.
        3. Sends the binary content to the extraction engine.
        4. Stores raw text and parsed fields.
        5. Opens the review wizard so the user can validate results.

        Returns:
            dict: A window action for the extraction review wizard,
            or a notification dict on error.
        """
        self.ensure_one()

        # ---- Find a suitable attachment ----
        attachment = self._find_extractable_attachment()
        if not attachment:
            raise UserError(
                _("No PDF or image attachment found on this document.  "
                  "Please attach a scanned invoice first.")
            )

        # ---- Locate the active extractor ----
        extractor = self._get_active_extractor()
        if not extractor:
            raise UserError(
                _("No active Document Extraction provider is configured.  "
                  "Go to Accounting → Configuration → Document Extraction to set one up.")
            )

        # ---- Run extraction ----
        self.fusion_extraction_status = "extracting"
        self.fusion_extractor_id = extractor

        image_bytes = base64.b64decode(attachment.datas)

        # If it's a PDF we attempt to convert the first page to an image
        image_bytes = self._pdf_to_image_if_needed(image_bytes, attachment.mimetype)

        try:
            doc_type = "invoice" if self.is_purchase_document() else "invoice"
            result = extractor.extract_fields(image_bytes, document_type=doc_type)
        except UserError:
            self.fusion_extraction_status = "failed"
            raise
        except Exception as exc:
            self.fusion_extraction_status = "failed"
            _log.exception("Fusion OCR extraction failed for move %s", self.id)
            raise UserError(
                _("OCR extraction failed unexpectedly: %s", str(exc))
            ) from exc

        # ---- Store results ----
        raw_text = result.get("raw_text", "")
        self.fusion_ocr_raw_text = raw_text

        # Parse structured fields (regex fallback + provider fields)
        parsed = self._parse_invoice_fields(raw_text)
        # Merge any provider-supplied structured fields (e.g. from Azure)
        provider_fields = result.get("fields", {})
        if provider_fields:
            for key, value in provider_fields.items():
                if value and not parsed.get(key):
                    parsed[key] = value

        import json
        self.fusion_extracted_fields_json = json.dumps(parsed, default=str, indent=2)
        self.fusion_extraction_confidence = self._compute_extraction_confidence(parsed)
        self.fusion_extraction_status = "done"

        # ---- Open review wizard ----
        return self.action_manual_review()

    # ------------------------------------------------------------------
    # Attachment helpers
    # ------------------------------------------------------------------
    def _find_extractable_attachment(self):
        """Return the first attachment that looks like a scan.

        Returns:
            recordset: An ``ir.attachment`` record, or empty recordset.
        """
        self.ensure_one()
        domain = [
            ("res_model", "=", "account.move"),
            ("res_id", "=", self.id),
        ]
        attachments = self.env["ir.attachment"].search(domain, order="id asc")

        image_mimes = {"image/png", "image/jpeg", "image/tiff", "image/bmp", "image/gif"}
        for att in attachments:
            mime = (att.mimetype or "").lower()
            if mime == "application/pdf" or mime in image_mimes:
                return att
        return self.env["ir.attachment"]

    def _get_active_extractor(self):
        """Return the first active extractor for the current company.

        Returns:
            recordset: A ``fusion.document.extractor`` record, or empty.
        """
        return self.env["fusion.document.extractor"].search([
            ("is_active", "=", True),
            "|",
            ("company_id", "=", self.company_id.id),
            ("company_id", "=", False),
        ], limit=1)

    @staticmethod
    def _pdf_to_image_if_needed(raw_bytes, mimetype):
        """Convert a PDF's first page to a PNG image if applicable.

        Uses Pillow to open the image; if the bytes represent a PDF and
        Pillow cannot open it directly, the raw bytes are returned
        unchanged (the cloud providers handle PDFs natively).

        Args:
            raw_bytes (bytes): File content.
            mimetype (str): MIME type of the attachment.

        Returns:
            bytes: Image bytes (PNG) or the original bytes.
        """
        if not _PILLOW_AVAILABLE:
            return raw_bytes

        if mimetype and "pdf" in mimetype.lower():
            # Cloud providers accept PDF natively, so return as-is.
            # For Tesseract, pdf2image (poppler) would be needed;
            # we skip this dependency and let Tesseract raise a clear
            # error if the user sends a PDF to a local-only extractor.
            return raw_bytes

        # Verify it's a valid image
        try:
            img = Image.open(io.BytesIO(raw_bytes))
            # Re-encode as PNG to normalise the format
            buf = io.BytesIO()
            img.save(buf, format="PNG")
            return buf.getvalue()
        except Exception:
            return raw_bytes

    # ------------------------------------------------------------------
    # Regex-based invoice field parser
    # ------------------------------------------------------------------
    def _parse_invoice_fields(self, raw_text):
        """Extract structured fields from OCR raw text using regex.

        This is a best-effort heuristic parser.  It handles the most
        common North-American and European invoice layouts.

        Args:
            raw_text (str): Full OCR text output.

        Returns:
            dict: Keys may include ``vendor_name``, ``invoice_number``,
            ``invoice_date``, ``due_date``, ``total_amount``,
            ``tax_amount``, ``subtotal``, ``currency``, ``line_items``.
        """
        if not raw_text:
            return {}

        fields_dict = {}

        # ---- Invoice Number ----
        inv_patterns = [
            r"(?:Invoice|Inv|Bill)\s*(?:#|No\.?|Number)\s*[:\s]*([A-Z0-9][\w\-\/]+)",
            r"(?:Facture|Rechnung)\s*(?:#|Nr\.?|Nummer)\s*[:\s]*([A-Z0-9][\w\-\/]+)",
            r"(?:Reference|Ref)\s*[:\s]*([A-Z0-9][\w\-\/]+)",
        ]
        for pattern in inv_patterns:
            match = re.search(pattern, raw_text, re.IGNORECASE)
            if match:
                fields_dict["invoice_number"] = match.group(1).strip()
                break

        # ---- Dates (Invoice Date, Due Date) ----
        date_formats = [
            # YYYY-MM-DD / YYYY/MM/DD
            r"(\d{4}[-/]\d{1,2}[-/]\d{1,2})",
            # DD/MM/YYYY or MM/DD/YYYY
            r"(\d{1,2}[-/]\d{1,2}[-/]\d{4})",
            # Month DD, YYYY
            r"((?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\.?\s+\d{1,2},?\s+\d{4})",
            # DD Month YYYY
            r"(\d{1,2}\s+(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\.?\s+\d{4})",
        ]
        date_regex = "|".join(date_formats)

        # Invoice date
        inv_date_match = re.search(
            r"(?:Invoice\s*Date|Date\s*d['\u2019]?\s*émission|Rechnungsdatum|Date)"
            r"\s*[:\s]*(" + date_regex + r")",
            raw_text, re.IGNORECASE,
        )
        if inv_date_match:
            fields_dict["invoice_date"] = self._normalise_date(
                inv_date_match.group(1).strip()
            )

        # Due date
        due_date_match = re.search(
            r"(?:Due\s*Date|Payment\s*Due|Date\s*d['\u2019]?\s*échéance|Fälligkeitsdatum)"
            r"\s*[:\s]*(" + date_regex + r")",
            raw_text, re.IGNORECASE,
        )
        if due_date_match:
            fields_dict["due_date"] = self._normalise_date(
                due_date_match.group(1).strip()
            )

        # If no labelled date was found, try to grab the first date in the text
        if "invoice_date" not in fields_dict:
            generic_date = re.search(date_regex, raw_text, re.IGNORECASE)
            if generic_date:
                fields_dict["invoice_date"] = self._normalise_date(
                    generic_date.group(0).strip()
                )

        # ---- Monetary amounts ----
        money_re = r"[\$€£¥]?\s*[\d,]+\.?\d{0,2}"

        # Total
        total_match = re.search(
            r"(?:Total\s*(?:Due|Amount|Payable)?|Grand\s*Total|Amount\s*Due|Balance\s*Due)"
            r"\s*[:\s]*(" + money_re + r")",
            raw_text, re.IGNORECASE,
        )
        if total_match:
            fields_dict["total_amount"] = self._parse_amount(total_match.group(1))

        # Tax / VAT
        tax_match = re.search(
            r"(?:Tax|VAT|GST|HST|Sales\s*Tax|TVA|MwSt)"
            r"(?:\s*\(?\d+\.?\d*%?\)?)?"
            r"\s*[:\s]*(" + money_re + r")",
            raw_text, re.IGNORECASE,
        )
        if tax_match:
            fields_dict["tax_amount"] = self._parse_amount(tax_match.group(1))

        # Subtotal
        subtotal_match = re.search(
            r"(?:Sub\s*-?\s*Total|Net\s*Amount|Montant\s*HT|Netto)"
            r"\s*[:\s]*(" + money_re + r")",
            raw_text, re.IGNORECASE,
        )
        if subtotal_match:
            fields_dict["subtotal"] = self._parse_amount(subtotal_match.group(1))

        # ---- Vendor name ----
        # Usually the first non-empty line or the "From:" block
        vendor_match = re.search(
            r"(?:From|Vendor|Supplier|Sold\s*By|Fournisseur)\s*[:\s]*(.+)",
            raw_text, re.IGNORECASE,
        )
        if vendor_match:
            fields_dict["vendor_name"] = vendor_match.group(1).strip()
        else:
            # Fallback: first non-blank line that looks like a company name
            for line in raw_text.split("\n"):
                line = line.strip()
                if line and len(line) > 3 and not re.match(r"^[\d\s\-/]+$", line):
                    fields_dict["vendor_name"] = line
                    break

        # ---- Currency detection ----
        currency_match = re.search(r"\b(USD|CAD|EUR|GBP|CHF|AUD|JPY)\b", raw_text, re.IGNORECASE)
        if currency_match:
            fields_dict["currency"] = currency_match.group(1).upper()
        elif "$" in raw_text:
            fields_dict["currency"] = "USD"
        elif "€" in raw_text:
            fields_dict["currency"] = "EUR"
        elif "£" in raw_text:
            fields_dict["currency"] = "GBP"

        # ---- Line items (best-effort) ----
        fields_dict["line_items"] = self._parse_line_items(raw_text)

        return fields_dict

    # ------------------------------------------------------------------
    # Line-item parser
    # ------------------------------------------------------------------
    @staticmethod
    def _parse_line_items(raw_text):
        """Attempt to extract tabular line items from OCR text.

        Looks for lines matching patterns like::

            Description   Qty   Unit Price   Amount
            Widget A       2      15.00      30.00

        Returns:
            list[dict]: Each dict has ``description``, ``quantity``,
            ``unit_price``, ``amount``.
        """
        items = []
        # Pattern: description text followed by numeric columns
        line_pattern = re.compile(
            r"^(.{3,}?)\s+"          # description (at least 3 chars)
            r"(\d+(?:\.\d+)?)\s+"    # quantity
            r"(\d[\d,]*\.?\d*)\s+"   # unit price
            r"(\d[\d,]*\.?\d*)\s*$", # line total
            re.MULTILINE,
        )
        for match in line_pattern.finditer(raw_text):
            desc = match.group(1).strip()
            # Skip header-like lines
            if re.match(r"(?:Desc|Item|Product|Qty|Quantity|Unit|Price|Amount)", desc, re.IGNORECASE):
                continue
            items.append({
                "description": desc,
                "quantity": float(match.group(2)),
                "unit_price": float(match.group(3).replace(",", "")),
                "amount": float(match.group(4).replace(",", "")),
            })
        return items

    # ------------------------------------------------------------------
    # Normalisation helpers
    # ------------------------------------------------------------------
    @staticmethod
    def _normalise_date(date_str):
        """Try to parse a date string into YYYY-MM-DD format.

        Args:
            date_str (str): A date string in various formats.

        Returns:
            str | None: ISO-formatted date string, or ``None``.
        """
        if not date_str:
            return None
        # Strip surrounding whitespace and common artefacts
        date_str = date_str.strip(" \t:,")

        formats = [
            "%Y-%m-%d",
            "%Y/%m/%d",
            "%d/%m/%Y",
            "%m/%d/%Y",
            "%d-%m-%Y",
            "%m-%d-%Y",
            "%B %d, %Y",
            "%B %d %Y",
            "%b %d, %Y",
            "%b %d %Y",
            "%d %B %Y",
            "%d %b %Y",
        ]
        for fmt in formats:
            try:
                dt = datetime.strptime(date_str, fmt)
                return dt.strftime("%Y-%m-%d")
            except ValueError:
                continue
        return date_str  # Return as-is if no format matched

    @staticmethod
    def _parse_amount(amount_str):
        """Convert a money string like ``$1,234.56`` to a float.

        Args:
            amount_str (str): Monetary string with optional currency symbol.

        Returns:
            float | None: Parsed amount, or ``None``.
        """
        if not amount_str:
            return None
        cleaned = re.sub(r"[^\d.,]", "", amount_str.strip())
        # Handle European comma-as-decimal: "1.234,56" → "1234.56"
        if "," in cleaned and "." in cleaned:
            if cleaned.rindex(",") > cleaned.rindex("."):
                cleaned = cleaned.replace(".", "").replace(",", ".")
            else:
                cleaned = cleaned.replace(",", "")
        elif "," in cleaned:
            # Could be thousands separator or decimal – heuristic
            parts = cleaned.split(",")
            if len(parts[-1]) == 2:
                cleaned = cleaned.replace(",", ".")
            else:
                cleaned = cleaned.replace(",", "")
        try:
            return float(cleaned)
        except ValueError:
            return None

    # ------------------------------------------------------------------
    # Confidence scoring
    # ------------------------------------------------------------------
    @staticmethod
    def _compute_extraction_confidence(parsed_fields):
        """Compute a simple confidence score (0–100) based on how many
        key fields were successfully extracted.

        Args:
            parsed_fields (dict): The parsed extraction result.

        Returns:
            float: Confidence percentage.
        """
        key_fields = [
            "vendor_name", "invoice_number", "invoice_date",
            "total_amount", "due_date", "tax_amount",
        ]
        found = sum(1 for k in key_fields if parsed_fields.get(k))
        return round((found / len(key_fields)) * 100, 2)

    # ------------------------------------------------------------------
    # Apply extracted fields to the invoice
    # ------------------------------------------------------------------
    def _apply_extracted_fields(self, fields_dict):
        """Write extracted data to the invoice form fields.

        This method maps the parsed extraction dict to the appropriate
        ``account.move`` fields.  It is typically called from the
        review wizard after the user has validated the data.

        Args:
            fields_dict (dict): Validated field dict – same structure as
                returned by :meth:`_parse_invoice_fields`.
        """
        self.ensure_one()
        vals = {}

        # ---- Partner (vendor) matching ----
        vendor_name = fields_dict.get("vendor_name")
        if vendor_name:
            partner = self.env["res.partner"].search([
                "|",
                ("name", "ilike", vendor_name),
                ("commercial_company_name", "ilike", vendor_name),
            ], limit=1)
            if partner:
                vals["partner_id"] = partner.id

        # ---- Reference / Invoice Number ----
        inv_number = fields_dict.get("invoice_number")
        if inv_number:
            vals["ref"] = inv_number

        # ---- Dates ----
        inv_date = fields_dict.get("invoice_date")
        if inv_date:
            try:
                vals["invoice_date"] = fields.Date.to_date(inv_date)
            except Exception:
                pass

        due_date = fields_dict.get("due_date")
        if due_date:
            try:
                vals["invoice_date_due"] = fields.Date.to_date(due_date)
            except Exception:
                pass

        # ---- Currency ----
        currency_code = fields_dict.get("currency")
        if currency_code:
            currency = self.env["res.currency"].search([
                ("name", "=", currency_code),
            ], limit=1)
            if currency:
                vals["currency_id"] = currency.id

        # Write header-level fields
        if vals:
            self.write(vals)

        # ---- Line items ----
        line_items = fields_dict.get("line_items", [])
        if line_items:
            self._apply_extracted_line_items(line_items)

        _log.info(
            "Fusion OCR: applied extracted fields to move %s – %s",
            self.id, list(vals.keys()),
        )

    def _apply_extracted_line_items(self, line_items):
        """Create invoice lines from extracted line item data.

        Existing lines are **not** deleted; new lines are appended.

        Args:
            line_items (list[dict]): Each dict may have ``description``,
                ``quantity``, ``unit_price``, ``amount``.
        """
        self.ensure_one()
        from odoo import Command

        new_lines = []
        for item in line_items:
            description = item.get("description", "")
            quantity = item.get("quantity", 1)
            unit_price = item.get("unit_price") or item.get("amount", 0)
            if not description:
                continue
            new_lines.append(Command.create({
                "name": description,
                "quantity": quantity,
                "price_unit": unit_price,
            }))

        if new_lines:
            self.write({"invoice_line_ids": new_lines})

    # ------------------------------------------------------------------
    # Review wizard launcher
    # ------------------------------------------------------------------
    def action_manual_review(self):
        """Open the extraction-review wizard pre-populated with the
        extracted (or last-extracted) field values.

        Returns:
            dict: Window action for the review wizard.
        """
        self.ensure_one()
        import json

        extracted = {}
        if self.fusion_extracted_fields_json:
            try:
                extracted = json.loads(self.fusion_extracted_fields_json)
            except (json.JSONDecodeError, TypeError):
                extracted = {}

        wizard = self.env["fusion.extraction.review.wizard"].create({
            "move_id": self.id,
            "vendor_name": extracted.get("vendor_name", ""),
            "invoice_number": extracted.get("invoice_number", ""),
            "invoice_date": self._safe_date(extracted.get("invoice_date")),
            "due_date": self._safe_date(extracted.get("due_date")),
            "total_amount": extracted.get("total_amount", 0.0),
            "tax_amount": extracted.get("tax_amount", 0.0),
            "subtotal": extracted.get("subtotal", 0.0),
            "currency_code": extracted.get("currency", ""),
            "raw_text": self.fusion_ocr_raw_text or "",
            "confidence": self.fusion_extraction_confidence or 0.0,
            "line_items_json": json.dumps(
                extracted.get("line_items", []), default=str, indent=2,
            ),
        })

        return {
            "type": "ir.actions.act_window",
            "name": _("Review Extracted Data"),
            "res_model": "fusion.extraction.review.wizard",
            "res_id": wizard.id,
            "view_mode": "form",
            "target": "new",
        }

    @staticmethod
    def _safe_date(val):
        """Convert a string to a date, returning False on failure."""
        if not val:
            return False
        try:
            return fields.Date.to_date(val)
        except Exception:
            return False
