"""
Xero ↔ Flash Purchases Reconciliation

Accepts a Xero "Account Transactions" XLSX export, parses it,
matches rows against Flash invoices for the same period, and
returns a four-section reconciliation report.

Matching is rule-based first (exact → near-match), with an
optional LLM pass for remaining unmatched items.
"""
from datetime import date, timedelta
from decimal import Decimal, InvalidOperation
from typing import Optional
import io
import re
import logging
import json

from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from pydantic import BaseModel

from database import get_db
from models.user import User
from models.invoice import Invoice, InvoiceStatus
from models.supplier import Supplier
from auth import get_current_user, require_cap

logger = logging.getLogger(__name__)

router = APIRouter()


# ============ Pydantic Response Models ============

class ReconcileMatchedItem(BaseModel):
    date: str
    supplier: str
    ref: str
    amount: str  # formatted £
    flash_id: int | None = None
    match_source: str = "rule"  # "rule" or "ai"

class ReconcileDiscrepancyItem(BaseModel):
    date: str
    supplier: str
    ref: str
    flash_amount: str
    xero_amount: str
    difference: str
    differs: list[str]  # which fields differ: "amount", "date", "ref"
    flash_id: int | None = None
    flash_date: str | None = None
    xero_date: str | None = None
    flash_ref: str | None = None
    xero_ref: str | None = None
    flash_supplier: str | None = None
    xero_description: str | None = None
    match_source: str = "rule"  # "rule" or "ai"
    amount_insight: str | None = None  # explanation for amount discrepancy

class ReconcileUnmatchedFlash(BaseModel):
    date: str
    supplier: str
    ref: str
    net_stock: str
    flash_id: int

class ReconcileUnmatchedXero(BaseModel):
    date: str
    description: str
    ref: str
    net: str
    is_expected_external: bool = False

class ReconcileResponse(BaseModel):
    period_start: str
    period_end: str
    flash_total: str
    xero_total: str
    difference: str
    matched_count: int
    discrepancy_count: int
    flash_only_count: int
    xero_only_count: int
    non_stock_excluded_count: int
    non_stock_excluded_total: str
    matched: list[ReconcileMatchedItem]
    discrepancies: list[ReconcileDiscrepancyItem]
    flash_only: list[ReconcileUnmatchedFlash]
    xero_only: list[ReconcileUnmatchedXero]
    llm_matches_attempted: bool = False


# ============ Supplier alias map for Xero description → Flash supplier ============

# Xero descriptions often look like "Supplier Name - food" or "Supplier - food crn"
# This maps known Xero description variants to the Flash supplier name
SUPPLIER_ALIASES = {
    "j hall & son (bakers) ltd": "Halls",
    "j hall & son bakers ltd": "Halls",
    "halls": "Halls",
    "r & d walker ltd": "R&D Walker",
    "r&d walker ltd": "R&D Walker",
    "r&d walker": "R&D Walker",
    "lambournes of stow-on-the-wold": "Lambournes",
    "lambournes": "Lambournes",
    "bramleys": "Bramleys",
    "cotswold coffee": "Cotswold Coffee",
    "brakes": "Brakes",
    "direct seafoods": "Direct Seafoods",
}

# Xero-only entries that are expected (not from Flash) — collapsible sub-section
EXPECTED_EXTERNAL_PATTERNS = [
    "tesco",
    "revenue jv",
    "journal",
    "internal",
    "petty",
]


# ============ Helpers ============

def normalise_ref(ref: str | None) -> str:
    """Normalise a reference for comparison: uppercase, strip whitespace/leading #, collapse spaces."""
    if not ref:
        return ""
    r = ref.strip().upper()
    r = r.lstrip("#")
    r = re.sub(r"\s+", " ", r).strip()
    return r


def extract_supplier_from_xero_desc(description: str) -> str:
    """
    Extract supplier name from Xero description.
    Xero format: "Supplier Name - food", "Supplier - food crn", etc.
    """
    desc = description.strip()
    # Strip trailing " - food", " - food crn", " - beverage", etc.
    desc = re.sub(r"\s*-\s*(food|beverage|cleaning|sundry)(\s+crn)?\s*$", "", desc, flags=re.IGNORECASE)
    return desc.strip()


def normalise_supplier(name: str) -> str:
    """Normalise supplier name to lowercase for comparison."""
    return name.strip().lower()


def resolve_supplier(xero_desc: str) -> str:
    """Resolve a Xero description to a canonical supplier name."""
    extracted = extract_supplier_from_xero_desc(xero_desc)
    norm = normalise_supplier(extracted)
    if norm in SUPPLIER_ALIASES:
        return SUPPLIER_ALIASES[norm]
    return extracted


def is_expected_external(xero_desc: str) -> bool:
    """Check if a Xero entry is expected to be outside Flash."""
    desc_lower = xero_desc.lower()
    return any(pat in desc_lower for pat in EXPECTED_EXTERNAL_PATTERNS)


def levenshtein(s1: str, s2: str) -> int:
    """Compute Levenshtein distance between two strings."""
    if len(s1) < len(s2):
        return levenshtein(s2, s1)
    if len(s2) == 0:
        return len(s1)
    prev_row = list(range(len(s2) + 1))
    for i, c1 in enumerate(s1):
        curr_row = [i + 1]
        for j, c2 in enumerate(s2):
            insertions = prev_row[j + 1] + 1
            deletions = curr_row[j] + 1
            substitutions = prev_row[j] + (c1 != c2)
            curr_row.append(min(insertions, deletions, substitutions))
        prev_row = curr_row
    return prev_row[-1]


def alphanumeric_only(s: str) -> str:
    """Strip non-alphanumeric characters for fuzzy ref comparison."""
    return re.sub(r"[^A-Z0-9]", "", s.upper())


def amounts_match(a: Decimal, b: Decimal, tolerance: Decimal = Decimal("0.01")) -> bool:
    return abs(a - b) <= tolerance


def fmt_money(d: Decimal) -> str:
    """Format a decimal as £X,XXX.XX"""
    return f"£{d:,.2f}"


def parse_xero_date(val) -> date | None:
    """Parse a date from an openpyxl cell value."""
    from datetime import datetime
    if isinstance(val, datetime):
        return val.date()
    if isinstance(val, date):
        return val
    if isinstance(val, str):
        val = val.strip()
        for fmt in ("%d %b %Y", "%d/%m/%Y", "%Y-%m-%d", "%d %B %Y"):
            try:
                return datetime.strptime(val, fmt).date()
            except ValueError:
                continue
    return None


def parse_xero_amount(val) -> Decimal:
    """Parse a numeric cell to Decimal, returning 0 for None/empty."""
    if val is None:
        return Decimal("0")
    if isinstance(val, (int, float)):
        return Decimal(str(val)).quantize(Decimal("0.01"))
    if isinstance(val, str):
        val = val.strip().replace(",", "").replace("£", "")
        if not val or val == "-":
            return Decimal("0")
        try:
            return Decimal(val).quantize(Decimal("0.01"))
        except InvalidOperation:
            return Decimal("0")
    return Decimal("0")


# ============ XLSX Parsing ============

def parse_xero_xlsx(file_bytes: bytes) -> tuple[date, date, list[dict]]:
    """
    Parse a Xero Account Transactions XLSX export.

    Returns (period_start, period_end, rows) where each row is:
    {
        "date": date,
        "description": str,
        "ref": str,
        "debit": Decimal,
        "credit": Decimal,
        "net": Decimal,  # debit - credit
    }
    """
    from openpyxl import load_workbook

    wb = load_workbook(filename=io.BytesIO(file_bytes), read_only=True, data_only=True)
    ws = wb.active

    # Extract period from header rows (typically row 3)
    period_start = None
    period_end = None
    for row_idx in range(1, 6):
        cell_val = ws.cell(row=row_idx, column=1).value
        if cell_val and isinstance(cell_val, str) and "period" in cell_val.lower():
            # "For the period 1 March 2026 to 31 March 2026"
            match = re.search(
                r"(\d{1,2}\s+\w+\s+\d{4})\s+to\s+(\d{1,2}\s+\w+\s+\d{4})",
                cell_val, re.IGNORECASE
            )
            if match:
                from datetime import datetime
                for fmt in ("%d %B %Y", "%d %b %Y"):
                    try:
                        period_start = datetime.strptime(match.group(1), fmt).date()
                        period_end = datetime.strptime(match.group(2), fmt).date()
                        break
                    except ValueError:
                        continue
            break

    if not period_start or not period_end:
        raise ValueError("Could not extract report period from XLSX header rows. Expected 'For the period DD Month YYYY to DD Month YYYY' in rows 1-5.")

    # Find header row (look for "Date" in column A)
    header_row = None
    for row_idx in range(1, 10):
        cell_val = ws.cell(row=row_idx, column=1).value
        if cell_val and isinstance(cell_val, str) and cell_val.strip().lower() == "date":
            header_row = row_idx
            break

    if not header_row:
        raise ValueError("Could not find column header row (expected 'Date' in column A within first 10 rows).")

    # Map column headers
    col_map = {}
    for col_idx in range(1, 20):
        val = ws.cell(row=header_row, column=col_idx).value
        if val and isinstance(val, str):
            col_map[val.strip().lower()] = col_idx

    required = ["date", "description", "debit", "credit"]
    for req in required:
        if req not in col_map:
            raise ValueError(f"Missing required column '{req}' in header row {header_row}. Found: {list(col_map.keys())}")

    ref_col = col_map.get("reference", col_map.get("ref"))
    source_col = col_map.get("source")

    # Parse data rows
    rows = []
    for row_idx in range(header_row + 1, ws.max_row + 1):
        date_val = ws.cell(row=row_idx, column=col_map["date"]).value
        parsed_date = parse_xero_date(date_val)
        if not parsed_date:
            continue  # Skip non-data rows (section headers, totals, blanks)

        description = ws.cell(row=row_idx, column=col_map["description"]).value or ""
        ref = ""
        if ref_col:
            ref = ws.cell(row=row_idx, column=ref_col).value or ""
        ref = str(ref).strip()

        source = ""
        if source_col:
            source = str(ws.cell(row=row_idx, column=source_col).value or "").strip()

        debit = parse_xero_amount(ws.cell(row=row_idx, column=col_map["debit"]).value)
        credit = parse_xero_amount(ws.cell(row=row_idx, column=col_map["credit"]).value)
        net = debit - credit  # Credits (credit notes) become negative

        is_credit_note = "credit note" in source.lower() or credit > 0

        rows.append({
            "date": parsed_date,
            "description": str(description).strip(),
            "ref": ref,
            "source": source,
            "debit": debit,
            "credit": credit,
            "net": net,
            "is_credit_note": is_credit_note,
        })

    wb.close()
    return period_start, period_end, rows


def group_xero_by_ref(rows: list[dict]) -> list[dict]:
    """
    Group Xero rows by reference (Xero sometimes splits an invoice across
    stock/non-stock lines with the same ref). Sum the nets.

    Rows with empty refs are NOT grouped — each stays separate.
    """
    grouped = {}
    no_ref = []

    for row in rows:
        norm = normalise_ref(row["ref"])
        if not norm:
            no_ref.append({
                "date": row["date"],
                "description": row["description"],
                "ref": row["ref"],
                "net": row["net"],
                "is_credit_note": row.get("is_credit_note", False),
                "raw_rows": [row],
            })
        else:
            if norm not in grouped:
                grouped[norm] = {
                    "date": row["date"],
                    "description": row["description"],
                    "ref": row["ref"],  # keep original formatting from first row
                    "net": Decimal("0"),
                    "is_credit_note": row.get("is_credit_note", False),
                    "raw_rows": [],
                }
            grouped[norm]["net"] += row["net"]
            grouped[norm]["raw_rows"].append(row)
            # If any row is a credit note, mark the group
            if row.get("is_credit_note"):
                grouped[norm]["is_credit_note"] = True

    return list(grouped.values()) + no_ref


# ============ Flash Invoice Querying ============

async def get_flash_invoices(
    db: AsyncSession,
    kitchen_id: int,
    period_start: date,
    period_end: date,
) -> tuple[list[dict], int, Decimal]:
    """
    Get Flash invoices for the period. Returns:
    - stock_invoices: list of dicts for matching
    - non_stock_count: count of excluded non-stock-only invoices
    - non_stock_total: total of excluded non-stock amounts

    Each stock invoice dict has:
    {
        "id": int,
        "date": date,
        "supplier_name": str,
        "ref": str (invoice_number),
        "net_stock": Decimal,
        "document_type": str,
    }
    """
    from models.line_item import LineItem

    result = await db.execute(
        select(Invoice)
        .where(
            Invoice.kitchen_id == kitchen_id,
            Invoice.status == InvoiceStatus.CONFIRMED,
        )
        .options(selectinload(Invoice.line_items))
        .order_by(Invoice.invoice_date.desc().nullslast())
    )
    all_invoices = result.scalars().all()

    # Get supplier map
    supplier_result = await db.execute(
        select(Supplier).where(Supplier.kitchen_id == kitchen_id)
    )
    suppliers_map = {s.id: s for s in supplier_result.scalars().all()}

    stock_invoices = []
    non_stock_count = 0
    non_stock_total = Decimal("0")

    for inv in all_invoices:
        inv_date = inv.invoice_date or inv.created_at.date()
        if not (period_start <= inv_date <= period_end):
            continue

        # Calculate net_stock (stock items only)
        net_stock = Decimal("0")
        net_non_stock = Decimal("0")
        if inv.line_items:
            for item in inv.line_items:
                item_net = item.amount or Decimal("0")
                if item.is_non_stock:
                    net_non_stock += item_net
                else:
                    net_stock += item_net

        # Credit notes: negate if positive
        if inv.document_type == "credit_note":
            if net_stock > 0:
                net_stock = -net_stock
            if net_non_stock > 0:
                net_non_stock = -net_non_stock

        # Get supplier name
        supplier_name = ""
        if inv.supplier_id and inv.supplier_id in suppliers_map:
            supplier_name = suppliers_map[inv.supplier_id].name
        elif inv.vendor_name:
            supplier_name = inv.vendor_name
        else:
            supplier_name = "Unknown"

        if net_stock == 0 and net_non_stock != 0:
            # Entirely non-stock — exclude from match pool
            non_stock_count += 1
            non_stock_total += net_non_stock
            continue

        if net_stock == 0 and net_non_stock == 0:
            # Zero-value invoice — nothing to reconcile
            continue

        # Build line item summaries for insight generation
        line_summaries = []
        if inv.line_items:
            for item in inv.line_items:
                line_summaries.append({
                    "description": item.description or "",
                    "amount": str(item.amount or 0),
                    "is_non_stock": item.is_non_stock,
                })

        stock_invoices.append({
            "id": inv.id,
            "date": inv_date,
            "supplier_name": supplier_name,
            "ref": inv.invoice_number or "",
            "net_stock": net_stock.quantize(Decimal("0.01")),
            "net_non_stock": net_non_stock.quantize(Decimal("0.01")),
            "document_type": inv.document_type or "invoice",
            "line_items": line_summaries,
        })

        # Track non-stock portion of mixed invoices separately
        if net_non_stock != 0:
            non_stock_count += 1
            non_stock_total += net_non_stock

    return stock_invoices, non_stock_count, non_stock_total.quantize(Decimal("0.01"))


# ============ Build supplier alias map from DB ============

async def build_supplier_alias_map(db: AsyncSession, kitchen_id: int) -> dict[str, str]:
    """
    Build a normalised-name → canonical-name map from the Supplier table aliases.
    Merges with the hardcoded SUPPLIER_ALIASES.
    """
    alias_map = dict(SUPPLIER_ALIASES)  # start with hardcoded

    result = await db.execute(
        select(Supplier).where(Supplier.kitchen_id == kitchen_id)
    )
    suppliers = result.scalars().all()

    for s in suppliers:
        # Map canonical name
        alias_map[normalise_supplier(s.name)] = s.name
        # Map aliases
        if s.aliases:
            for alias in s.aliases:
                alias_map[normalise_supplier(alias)] = s.name

    return alias_map


def resolve_supplier_with_map(xero_desc: str, alias_map: dict[str, str]) -> str:
    """Resolve a Xero description to a canonical supplier name using dynamic alias map."""
    extracted = extract_supplier_from_xero_desc(xero_desc)
    norm = normalise_supplier(extracted)
    if norm in alias_map:
        return alias_map[norm]
    # Try partial match — check if any alias key is contained in the extracted name
    for alias_key, canonical in alias_map.items():
        if alias_key in norm or norm in alias_key:
            return canonical
    return extracted


def suppliers_match(flash_supplier: str, xero_supplier: str) -> bool:
    """Check if a Flash supplier and resolved Xero supplier match."""
    return normalise_supplier(flash_supplier) == normalise_supplier(xero_supplier)


# ============ Matching Engine ============

def run_matching(
    flash_items: list[dict],
    xero_items: list[dict],
    alias_map: dict[str, str],
) -> tuple[list, list, list[dict], list[dict]]:
    """
    Three-pass matching:
    1. Exact: same normalised ref + same date + amount within £0.01
    2. Near-match: one field differs (flagged as discrepancy)
    3. Leftovers: unmatched on each side

    Returns (matched, discrepancies, flash_unmatched, xero_unmatched)
    """
    matched = []
    discrepancies = []

    flash_used = set()
    xero_used = set()

    # Resolve Xero suppliers up front
    for xi, xero in enumerate(xero_items):
        xero["_resolved_supplier"] = resolve_supplier_with_map(xero["description"], alias_map)
        xero["_norm_ref"] = normalise_ref(xero["ref"])
        xero["_idx"] = xi

    for fi, flash in enumerate(flash_items):
        flash["_norm_ref"] = normalise_ref(flash["ref"])
        flash["_idx"] = fi

    # ---- Pass 1: Exact match ----
    for fi, flash in enumerate(flash_items):
        if fi in flash_used:
            continue
        if not flash["_norm_ref"]:
            continue  # Can't exact-match without a ref
        for xi, xero in enumerate(xero_items):
            if xi in xero_used:
                continue
            if flash["_norm_ref"] == xero["_norm_ref"] and \
               flash["date"] == xero["date"] and \
               amounts_match(flash["net_stock"], xero["net"]):
                matched.append({
                    "date": flash["date"].isoformat(),
                    "supplier": flash["supplier_name"],
                    "ref": flash["ref"] or xero["ref"],
                    "amount": fmt_money(flash["net_stock"]),
                    "flash_id": flash["id"],
                })
                flash_used.add(fi)
                xero_used.add(xi)
                break

    # ---- Pass 2: Near-match (discrepancies) ----
    for fi, flash in enumerate(flash_items):
        if fi in flash_used:
            continue
        best_match = None
        best_score = 0  # higher = more confident

        for xi, xero in enumerate(xero_items):
            if xi in xero_used:
                continue

            differs = []
            score = 0

            f_ref = flash["_norm_ref"]
            x_ref = xero["_norm_ref"]
            f_supplier = flash["supplier_name"]
            x_supplier = xero["_resolved_supplier"]
            same_supplier = suppliers_match(f_supplier, x_supplier)

            # 2a: Same ref, same date, amount differs
            if f_ref and x_ref and f_ref == x_ref and flash["date"] == xero["date"]:
                if not amounts_match(flash["net_stock"], xero["net"]):
                    differs.append("amount")
                    score = 10

            # 2b: Same ref, amount matches, date differs ≤ 3 days
            elif f_ref and x_ref and f_ref == x_ref and \
                 amounts_match(flash["net_stock"], xero["net"]):
                date_diff = abs((flash["date"] - xero["date"]).days)
                if date_diff <= 3:
                    differs.append("date")
                    score = 9

            # 2c: Same supplier, amount matches (within £0.01), date within 3 days, refs differ
            # This catches cases where refs are completely different
            # (e.g. Cotswold Coffee 126621 vs 145323, R&D Walker Nº4 vs 106891)
            elif same_supplier and amounts_match(flash["net_stock"], xero["net"], Decimal("0.01")):
                date_diff = abs((flash["date"] - xero["date"]).days)
                if date_diff == 0:
                    differs.append("ref")
                    score = 8
                elif date_diff <= 3:
                    differs.extend(["ref", "date"])
                    score = 7

            # 2d: Same supplier, same ref, both amount and date differ slightly
            elif f_ref and x_ref and f_ref == x_ref and same_supplier:
                date_diff = abs((flash["date"] - xero["date"]).days)
                amt_diff = abs(flash["net_stock"] - xero["net"])
                if date_diff <= 3 and amt_diff <= Decimal("5.00"):
                    differs.extend(["amount", "date"])
                    score = 5

            # 2e: Credit note matching — same supplier, same amount,
            # but refs differ completely (Xero uses CN prefix, Flash uses supplier ref)
            elif same_supplier and amounts_match(flash["net_stock"], xero["net"]) and \
                 (flash["document_type"] == "credit_note" or xero.get("is_credit_note", False)):
                date_diff = abs((flash["date"] - xero["date"]).days)
                if date_diff <= 5:
                    differs.append("ref")
                    if date_diff > 0:
                        differs.append("date")
                    score = 8  # High confidence — credit notes with matching supplier+amount

            # 2f: Same supplier, date within 3 days, amounts close but not exact
            elif same_supplier:
                date_diff = abs((flash["date"] - xero["date"]).days)
                amt_diff = abs(flash["net_stock"] - xero["net"])
                if date_diff <= 3 and amt_diff <= Decimal("5.00") and amt_diff > Decimal("0.01"):
                    differs_list = ["amount"]
                    if date_diff > 0:
                        differs_list.append("date")
                    if f_ref != x_ref:
                        differs_list.append("ref")
                    differs.extend(differs_list)
                    score = 4

            if score > best_score:
                best_score = score
                best_match = (xi, xero, differs)

        if best_match:
            xi, xero, differs = best_match
            f_net = flash["net_stock"]
            x_net = xero["net"]
            diff_val = f_net - x_net
            amt_diff_abs = abs(float(diff_val))

            # Safety check: reject any near-match where amount differs by more than £5
            # AND refs are different — these are almost certainly different invoices
            f_ref_norm = normalise_ref(flash["ref"])
            x_ref_norm = normalise_ref(xero["ref"])
            refs_differ = f_ref_norm != x_ref_norm

            if refs_differ and amt_diff_abs > 5.0:
                logger.warning(
                    f"REJECTED false discrepancy: Flash {flash['supplier_name']} "
                    f"{flash['ref']}={f_net} vs Xero {xero['ref']}={x_net} "
                    f"diff={amt_diff_abs:.2f} score={best_score}"
                )
                continue  # Skip — leave both as unmatched

            # Add "amount" to differs if amounts don't actually match
            if amt_diff_abs > 0.01 and "amount" not in differs:
                differs.append("amount")

            logger.info(
                f"Discrepancy: Flash {flash['supplier_name']} {flash['ref']}={f_net} "
                f"vs Xero {xero['ref']}={x_net} differs={differs} score={best_score}"
            )

            discrepancies.append({
                "date": flash["date"].isoformat(),
                "supplier": flash["supplier_name"],
                "ref": flash["ref"] or xero["ref"],
                "flash_amount": fmt_money(f_net),
                "xero_amount": fmt_money(x_net),
                "difference": fmt_money(diff_val),
                "differs": differs,
                "flash_id": flash["id"],
                "flash_date": flash["date"].isoformat(),
                "xero_date": xero["date"].isoformat(),
                "flash_ref": flash["ref"],
                "xero_ref": xero["ref"],
                "flash_supplier": flash["supplier_name"],
                "xero_description": xero["description"],
            })
            flash_used.add(fi)
            xero_used.add(xi)

    # ---- Leftovers ----
    flash_unmatched = [f for fi, f in enumerate(flash_items) if fi not in flash_used]
    xero_unmatched = [x for xi, x in enumerate(xero_items) if xi not in xero_used]

    return matched, discrepancies, flash_unmatched, xero_unmatched


# ============ Amount Discrepancy Insights ============

def check_non_stock_explains_diff(flash: dict, diff_val: Decimal) -> str | None:
    """
    Check if the amount discrepancy is explained by non-stock items.
    If Xero includes the full invoice (stock + non-stock) but Flash only shows stock,
    the difference should equal the non-stock total.
    """
    net_non_stock = flash.get("net_non_stock", Decimal("0"))
    if net_non_stock == 0:
        return None

    # diff_val = flash_stock - xero_net (negative when Xero is higher)
    # If Xero has full invoice, diff = -net_non_stock
    if amounts_match(abs(diff_val), abs(net_non_stock), Decimal("0.02")):
        non_stock_items = [
            li for li in flash.get("line_items", []) if li.get("is_non_stock")
        ]
        item_names = ", ".join(
            li["description"][:40] for li in non_stock_items if li.get("description")
        )
        return (
            f"Non-stock items account for the difference "
            f"({fmt_money(abs(net_non_stock))}). "
            f"Xero likely includes full invoice total. "
            f"Non-stock: {item_names}" if item_names else
            f"Non-stock items account for the difference "
            f"({fmt_money(abs(net_non_stock))}). "
            f"Xero likely includes full invoice total."
        )

    # Check if non-stock is a partial explanation (diff is larger but non-stock is a chunk)
    if abs(net_non_stock) > Decimal("1.00") and abs(diff_val) > abs(net_non_stock):
        remainder = abs(diff_val) - abs(net_non_stock)
        return (
            f"Non-stock items total {fmt_money(abs(net_non_stock))} "
            f"but difference is {fmt_money(abs(diff_val))} — "
            f"non-stock explains part, {fmt_money(remainder)} remains unexplained."
        )

    return None


def check_line_item_combinations(flash: dict, diff_val: Decimal) -> str | None:
    """
    Check if any single line item or small combination matches the difference.
    This catches cases where a specific item was excluded/included differently.
    """
    line_items = flash.get("line_items", [])
    if not line_items:
        return None

    target = abs(diff_val)

    # Check single items
    for li in line_items:
        amt = abs(Decimal(li["amount"]))
        if amounts_match(amt, target, Decimal("0.02")) and amt > Decimal("0.50"):
            desc = li["description"][:50] if li["description"] else "unnamed item"
            ns = " (non-stock)" if li.get("is_non_stock") else ""
            return f'Single line item matches difference: "{desc}"{ns} = {fmt_money(amt)}'

    return None


async def generate_llm_insight(
    db: AsyncSession,
    kitchen_id: int,
    flash: dict,
    xero_net: Decimal,
    diff_val: Decimal,
) -> str | None:
    """Use LLM to analyse line items and suggest cause of amount variance."""
    from services.llm_service import call_llm

    line_items = flash.get("line_items", [])
    if not line_items:
        return None

    # Build concise line item list
    li_summary = []
    for li in line_items:
        li_summary.append({
            "description": li["description"][:60] if li["description"] else "—",
            "amount": li["amount"],
            "non_stock": li["is_non_stock"],
        })

    system_msg = (
        "You are a kitchen accounts assistant. Flash is a food stock/GP system that "
        "tracks invoices — it separates stock (food) items from non-stock items "
        "(chemicals, packaging, equipment, etc). Xero is the accounting system that "
        "records the full invoice total posted to the food purchases account. "
        "An amount discrepancy means Flash stock total differs from the Xero net. "
        "Common causes: non-stock items not split out in Xero, line items missing, "
        "rounding, or Xero posting error."
    )

    user_msg = (
        f"Invoice: {flash['supplier_name']} ref {flash['ref']} dated {flash['date']}\n"
        f"Flash stock total: {fmt_money(flash['net_stock'])}\n"
        f"Xero net: {fmt_money(xero_net)}\n"
        f"Difference: {fmt_money(diff_val)} (Flash - Xero)\n\n"
        f"Flash line items:\n{json.dumps(li_summary, indent=2)}\n\n"
        f"Can you identify which line items or combination likely accounts for "
        f"the {fmt_money(abs(diff_val))} difference? "
        f"Reply in ONE short sentence (max 120 chars). "
        f"If unclear, say 'Unable to determine cause'."
    )

    result = await call_llm(
        db=db,
        kitchen_id=kitchen_id,
        feature="reconciliation_insight",
        messages=[{"role": "user", "content": user_msg}],
        system_message=system_msg,
    )

    if result["status"] not in ("success", "cached"):
        return None

    text = result.get("result", "")
    if isinstance(text, dict):
        text = str(text)
    text = text.strip().strip('"').strip("'")
    if text and len(text) < 200:
        return text
    return text[:200] + "..." if text else None


async def generate_amount_insights(
    db: AsyncSession,
    kitchen_id: int,
    discrepancies: list[dict],
    flash_lookup: dict[int, dict],
) -> list[dict]:
    """
    For each amount discrepancy, try to explain the variance:
    1. Code check: does non-stock total match the difference?
    2. Code check: does a single line item match the difference?
    3. LLM fallback: send line items for analysis
    """
    for disc in discrepancies:
        if "amount" not in disc.get("differs", []):
            continue

        flash_id = disc.get("flash_id")
        if not flash_id or flash_id not in flash_lookup:
            continue

        flash = flash_lookup[flash_id]

        # Parse the difference back to Decimal
        diff_str = disc["difference"].replace("£", "").replace(",", "")
        try:
            diff_val = Decimal(diff_str)
        except InvalidOperation:
            continue

        # 1. Non-stock check
        insight = check_non_stock_explains_diff(flash, diff_val)
        if insight:
            disc["amount_insight"] = insight
            continue

        # 2. Single line item check
        insight = check_line_item_combinations(flash, diff_val)
        if insight:
            disc["amount_insight"] = insight
            continue

        # 3. LLM fallback
        xero_str = disc["xero_amount"].replace("£", "").replace(",", "")
        try:
            xero_net = Decimal(xero_str)
        except InvalidOperation:
            continue

        insight = await generate_llm_insight(db, kitchen_id, flash, xero_net, diff_val)
        if insight:
            disc["amount_insight"] = f"🤖 {insight}"

    return discrepancies


# ============ LLM Fallback Matching ============

async def llm_match_remaining(
    db: AsyncSession,
    kitchen_id: int,
    flash_unmatched: list[dict],
    xero_unmatched: list[dict],
) -> tuple[list, list, list[dict], list[dict]]:
    """
    Use LLM to attempt matching remaining unmatched items.
    Returns (new_discrepancies, new_exact, remaining_flash, remaining_xero)
    """
    from services.llm_service import call_llm

    if not flash_unmatched or not xero_unmatched:
        return [], [], flash_unmatched, xero_unmatched

    # Build concise representations
    flash_summary = []
    for i, f in enumerate(flash_unmatched):
        flash_summary.append({
            "idx": i,
            "date": f["date"].isoformat(),
            "supplier": f["supplier_name"],
            "ref": f["ref"],
            "amount": str(f["net_stock"]),
        })

    xero_summary = []
    for i, x in enumerate(xero_unmatched):
        xero_summary.append({
            "idx": i,
            "date": x["date"].isoformat(),
            "description": x["description"],
            "ref": x["ref"],
            "amount": str(x["net"]),
        })

    system_msg = """You are a bookkeeping reconciliation assistant. You are given two lists of unmatched invoice entries — one from Flash (the kitchen invoice system) and one from Xero (the accounting system).

Your job is to identify probable matches between the two lists. These are entries that likely represent the same real-world invoice but have data discrepancies (different reference numbers, slightly different amounts, date offsets, supplier name variants, etc).

For each probable match, explain which fields differ and why you think they are the same invoice.

IMPORTANT: Only suggest matches you are reasonably confident about. It is better to leave items unmatched than to create false matches. Consider supplier names, dates, amounts, and reference numbers holistically."""

    user_msg = f"""Here are the unmatched Flash invoices:
{json.dumps(flash_summary, indent=2)}

Here are the unmatched Xero entries:
{json.dumps(xero_summary, indent=2)}

Return a JSON array of matches. Each match should be:
{{
  "flash_idx": <index in flash list>,
  "xero_idx": <index in xero list>,
  "confidence": "high" or "medium",
  "differs": ["field1", "field2"],
  "reasoning": "brief explanation"
}}

Only include matches with high or medium confidence. Return an empty array [] if no good matches found."""

    result = await call_llm(
        db=db,
        kitchen_id=kitchen_id,
        feature="reconciliation_matching",
        messages=[{"role": "user", "content": user_msg}],
        system_message=system_msg,
    )

    if result["status"] not in ("success", "cached"):
        logger.info(f"LLM reconciliation matching unavailable: {result['status']}")
        return [], [], flash_unmatched, xero_unmatched

    # Parse LLM response
    new_discrepancies = []
    llm_text = result.get("result", "")
    if isinstance(llm_text, dict):
        llm_text = json.dumps(llm_text)
    if not llm_text:
        return [], [], flash_unmatched, xero_unmatched

    try:
        # Extract JSON from response (may be wrapped in markdown code block)
        json_match = re.search(r"\[.*\]", str(llm_text), re.DOTALL)
        if not json_match:
            return [], [], flash_unmatched, xero_unmatched
        matches = json.loads(json_match.group())
    except (json.JSONDecodeError, AttributeError):
        logger.warning("Failed to parse LLM reconciliation response")
        return [], [], flash_unmatched, xero_unmatched

    flash_matched = set()
    xero_matched = set()

    for m in matches:
        fi = m.get("flash_idx")
        xi = m.get("xero_idx")
        confidence = m.get("confidence", "medium")

        if fi is None or xi is None:
            continue
        if fi >= len(flash_unmatched) or xi >= len(xero_unmatched):
            continue
        if fi in flash_matched or xi in xero_matched:
            continue

        flash = flash_unmatched[fi]
        xero = xero_unmatched[xi]

        # Safety check: reject LLM matches where refs differ AND amount diff > £5
        f_ref_norm = normalise_ref(flash["ref"])
        x_ref_norm = normalise_ref(xero["ref"])
        amt_diff_abs = abs(float(flash["net_stock"] - xero["net"]))
        if f_ref_norm != x_ref_norm and amt_diff_abs > 5.0:
            logger.warning(
                f"REJECTED LLM false match: Flash {flash['supplier_name']} "
                f"{flash['ref']}={flash['net_stock']} vs Xero {xero['ref']}={xero['net']} "
                f"diff={amt_diff_abs:.2f}"
            )
            continue

        differs = m.get("differs", [])
        if not differs:
            differs = ["unknown"]

        diff_val = flash["net_stock"] - xero["net"]
        new_discrepancies.append({
            "date": flash["date"].isoformat(),
            "supplier": flash["supplier_name"],
            "ref": flash["ref"] or xero["ref"],
            "flash_amount": fmt_money(flash["net_stock"]),
            "xero_amount": fmt_money(xero["net"]),
            "difference": fmt_money(diff_val),
            "differs": differs,
            "flash_id": flash["id"],
            "flash_date": flash["date"].isoformat(),
            "xero_date": xero["date"].isoformat(),
            "flash_ref": flash["ref"],
            "xero_ref": xero["ref"],
            "match_source": "ai",
        })
        flash_matched.add(fi)
        xero_matched.add(xi)

    remaining_flash = [f for i, f in enumerate(flash_unmatched) if i not in flash_matched]
    remaining_xero = [x for i, x in enumerate(xero_unmatched) if i not in xero_matched]

    return new_discrepancies, [], remaining_flash, remaining_xero


# ============ Main Endpoint ============

@router.post("/purchases/reconcile", response_model=ReconcileResponse)
async def reconcile_purchases(
    file: UploadFile = File(...),
    current_user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    """
    Upload a Xero Account Transactions XLSX and reconcile against Flash invoices.
    """
    # Validate file type
    if not file.filename or not file.filename.lower().endswith(".xlsx"):
        raise HTTPException(status_code=400, detail="Please upload an XLSX file")

    # Read file into memory (no persistence)
    file_bytes = await file.read()
    if len(file_bytes) > 10 * 1024 * 1024:  # 10MB limit
        raise HTTPException(status_code=400, detail="File too large (max 10MB)")

    # Parse XLSX
    try:
        period_start, period_end, xero_rows = parse_xero_xlsx(file_bytes)
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))
    except Exception as e:
        logger.error(f"XLSX parsing failed: {e}")
        raise HTTPException(status_code=400, detail=f"Failed to parse XLSX: {str(e)}")

    # Group Xero rows by reference
    xero_items = group_xero_by_ref(xero_rows)

    # Get Flash invoices for the period
    flash_items, non_stock_count, non_stock_total = await get_flash_invoices(
        db, current_user.kitchen_id, period_start, period_end
    )

    # Build supplier alias map from DB
    alias_map = await build_supplier_alias_map(db, current_user.kitchen_id)

    # Run rule-based matching
    matched, discrepancies, flash_unmatched, xero_unmatched = run_matching(
        flash_items, xero_items, alias_map
    )

    # LLM fallback for remaining unmatched
    llm_attempted = False
    if flash_unmatched and xero_unmatched:
        llm_disc, llm_exact, flash_unmatched, xero_unmatched = await llm_match_remaining(
            db, current_user.kitchen_id, flash_unmatched, xero_unmatched
        )
        if llm_disc or llm_exact:
            llm_attempted = True
            discrepancies.extend(llm_disc)
            matched.extend(llm_exact)

    # Generate insights for amount discrepancies
    flash_lookup = {f["id"]: f for f in flash_items}
    discrepancies = await generate_amount_insights(
        db, current_user.kitchen_id, discrepancies, flash_lookup
    )

    # Calculate totals
    flash_total = sum(f["net_stock"] for f in flash_items)
    xero_total = sum(x["net"] for x in xero_items)
    difference = flash_total - xero_total

    # Build response
    matched_response = [ReconcileMatchedItem(**m) for m in matched]

    discrepancy_response = [ReconcileDiscrepancyItem(**d) for d in discrepancies]

    flash_only_response = [
        ReconcileUnmatchedFlash(
            date=f["date"].isoformat(),
            supplier=f["supplier_name"],
            ref=f["ref"],
            net_stock=fmt_money(f["net_stock"]),
            flash_id=f["id"],
        )
        for f in flash_unmatched
    ]

    xero_only_response = [
        ReconcileUnmatchedXero(
            date=x["date"].isoformat(),
            description=x["description"],
            ref=x["ref"],
            net=fmt_money(x["net"]),
            is_expected_external=is_expected_external(x["description"]),
        )
        for x in xero_unmatched
    ]

    return ReconcileResponse(
        period_start=period_start.isoformat(),
        period_end=period_end.isoformat(),
        flash_total=fmt_money(flash_total),
        xero_total=fmt_money(xero_total),
        difference=fmt_money(difference),
        matched_count=len(matched_response),
        discrepancy_count=len(discrepancy_response),
        flash_only_count=len(flash_only_response),
        xero_only_count=len(xero_only_response),
        non_stock_excluded_count=non_stock_count,
        non_stock_excluded_total=fmt_money(non_stock_total),
        matched=matched_response,
        discrepancies=discrepancy_response,
        flash_only=flash_only_response,
        xero_only=xero_only_response,
        llm_matches_attempted=llm_attempted,
    )
