#!/usr/bin/env python3
"""Local-first receipt/document importer for knowledge/receipts.

Pipeline:
1. Keep the original image/PDF immutable in knowledge/receipts/images/YY-MM-images/.
2. Generate local OCR derivatives in knowledge/receipts/ocr/<sha-prefix>/.
3. OCR locally with Tesseract/OCRmyPDF/Poppler.
4. Parse likely receipt fields with conservative heuristics.
5. Insert a searchable row into receipts.sqlite, or update an existing receipt id.

No cloud APIs. No deletion. Designed as a wrapper Soren can call quickly from chat.
"""

from __future__ import annotations

import argparse
import datetime as dt
import difflib
import hashlib
import json
import os
import re
import shutil
import sqlite3
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import Any

try:
    from PIL import Image, ImageEnhance, ImageOps
except Exception as e:  # pragma: no cover
    print(f"Pillow/PIL required: {e}", file=sys.stderr)
    raise SystemExit(2)

ROOT = Path(__file__).resolve().parents[1]
DB = ROOT / "receipts.sqlite"
IMAGES = ROOT / "images"
OCR_ROOT = ROOT / "ocr"
TEXT_EXTS = {".txt"}
IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".tif", ".tiff", ".bmp"}
PDF_EXTS = {".pdf"}


def run(cmd: list[str], *, capture: bool = False, check: bool = True, timeout: int = 120) -> subprocess.CompletedProcess[str]:
    try:
        return subprocess.run(cmd, text=True, capture_output=capture, check=check, timeout=timeout)
    except subprocess.CalledProcessError as e:
        if e.stdout:
            print(e.stdout, end="")
        if e.stderr:
            print(e.stderr, end="", file=sys.stderr)
        raise SystemExit(e.returncode)


def have(name: str) -> bool:
    return shutil.which(name) is not None


def sha256_file(path: Path) -> str:
    h = hashlib.sha256()
    with path.open("rb") as f:
        for chunk in iter(lambda: f.read(1024 * 1024), b""):
            h.update(chunk)
    return h.hexdigest()


def now_local() -> dt.datetime:
    return dt.datetime.now().astimezone()


def month_dir(ts: dt.datetime) -> Path:
    return IMAGES / f"{ts:%y-%m}-images"


def slug(s: str) -> str:
    s = re.sub(r"[^A-Za-z0-9._-]+", "-", s.strip()).strip("-_.")
    return s[:80] or "receipt"


def copy_original(src: Path, *, category: str, captured: dt.datetime, sha: str) -> Path:
    ext = src.suffix.lower() or ".jpg"
    dest_dir = month_dir(captured)
    dest_dir.mkdir(parents=True, exist_ok=True)
    cat = slug(category.lower())
    dest = dest_dir / f"{captured:%Y-%m-%d_%H%M}_{cat}_{sha[:8]}{ext}"
    if not dest.exists():
        shutil.copy2(src, dest)
    return dest


def image_derivatives(src: Path, ocr_dir: Path) -> list[Path]:
    ocr_dir.mkdir(parents=True, exist_ok=True)
    im = Image.open(src).convert("RGB")
    outputs: list[Path] = []

    # 1. Original as PNG, useful for consistent tesseract input.
    original = ocr_dir / "original.png"
    im.save(original)
    outputs.append(original)

    # 2. Grayscale + contrast + upscale. Good for screenshots and receipts.
    g = ImageOps.grayscale(im)
    g = ImageEnhance.Contrast(g).enhance(2.2)
    up = g.resize((g.width * 2, g.height * 2))
    gray = ocr_dir / "gray_contrast_2x.png"
    up.save(gray)
    outputs.append(gray)

    # 3. Binary-ish threshold, helps faint receipt paper.
    bw = up.point(lambda p: 255 if p > 175 else 0)
    bw_path = ocr_dir / "threshold_2x.png"
    bw.save(bw_path)
    outputs.append(bw_path)

    # 4. Vertical crops, useful for long phone screenshots/receipts.
    if im.height > im.width * 1.2:
        for i in range(4):
            y0 = i * im.height // 4
            y1 = (i + 1) * im.height // 4
            crop = im.crop((0, y0, im.width, y1))
            crop = ImageEnhance.Contrast(ImageOps.grayscale(crop)).enhance(2.0)
            crop = crop.resize((crop.width * 2, crop.height * 2))
            p = ocr_dir / f"crop_{i+1}_2x.png"
            crop.save(p)
            outputs.append(p)

    return outputs


def tesseract_text(image: Path, *, psm: int) -> str:
    if not have("tesseract"):
        raise RuntimeError("tesseract not installed")
    cp = run(["tesseract", str(image), "stdout", "-l", "eng", "--psm", str(psm)], capture=True, check=False)
    return cp.stdout or ""


def ocr_image(src: Path, ocr_dir: Path) -> dict[str, Any]:
    variants = image_derivatives(src, ocr_dir)
    runs = []
    combined_parts: list[str] = []
    for img in variants:
        for psm in (6, 11):
            text = tesseract_text(img, psm=psm)
            cleaned = clean_text(text)
            score = score_text(cleaned)
            runs.append({"file": str(img), "psm": psm, "score": score, "chars": len(cleaned)})
            if cleaned:
                combined_parts.append(f"--- OCR {img.name} psm={psm} ---\n{cleaned}")
    combined = dedupe_lines("\n".join(combined_parts))
    (ocr_dir / "ocr_raw.txt").write_text(combined)
    (ocr_dir / "ocr_runs.json").write_text(json.dumps(runs, indent=2))
    return {"text": combined, "runs": runs, "dir": str(ocr_dir)}


def ocr_pdf(src: Path, ocr_dir: Path) -> dict[str, Any]:
    ocr_dir.mkdir(parents=True, exist_ok=True)
    raw_text = ""
    if have("pdftotext"):
        cp = run(["pdftotext", "-layout", str(src), "-"], capture=True, check=False)
        raw_text = clean_text(cp.stdout or "")
    ocr_pdf_path = None
    if len(raw_text.strip()) < 40 and have("ocrmypdf"):
        ocr_pdf_path = ocr_dir / "ocr.pdf"
        run(["ocrmypdf", "--skip-text", "--deskew", "--rotate-pages", str(src), str(ocr_pdf_path)], check=False, timeout=300)
        if ocr_pdf_path.exists() and have("pdftotext"):
            cp = run(["pdftotext", "-layout", str(ocr_pdf_path), "-"], capture=True, check=False)
            raw_text = clean_text(cp.stdout or raw_text)
    (ocr_dir / "ocr_raw.txt").write_text(raw_text)
    return {"text": raw_text, "runs": [{"pdf_ocr": bool(ocr_pdf_path and ocr_pdf_path.exists())}], "dir": str(ocr_dir)}


def clean_text(text: str) -> str:
    text = text.replace("\r", "\n")
    text = re.sub(r"[ \t]+", " ", text)
    text = re.sub(r"\n{3,}", "\n\n", text)
    return text.strip()


def dedupe_lines(text: str) -> str:
    seen = set()
    out = []
    for line in text.splitlines():
        key = re.sub(r"\s+", " ", line.strip()).lower()
        if not key:
            if out and out[-1] != "":
                out.append("")
            continue
        # Preserve OCR section headers even when similar.
        if key.startswith("--- ocr") or key not in seen:
            out.append(line.rstrip())
            seen.add(key)
    return "\n".join(out).strip()


def score_text(text: str) -> int:
    score = len(text)
    for word in ("total", "subtotal", "gst", "receipt", "invoice", "order", "payment", "$"):
        score += text.lower().count(word) * 50
    return score


def money_to_float(s: str | None, *, require_dollars: bool = False, last: bool = False) -> float | None:
    if not s:
        return None
    s = s.replace(",", "")
    pat = r"-?\$\s*([0-9]+(?:\.[0-9]{2})?)" if require_dollars else r"-?\$?\s*([0-9]+(?:\.[0-9]{2})?)"
    matches = list(re.finditer(pat, s))
    if not matches:
        return None
    m = matches[-1] if last else matches[0]
    return float(m.group(1))


def parse_date(text: str) -> str | None:
    patterns = [
        (r"\b(20\d{2})[-/](\d{1,2})[-/](\d{1,2})\b", "%Y-%m-%d"),
        (r"\b(\d{1,2})[-/](\d{1,2})[-/](20\d{2})\b", "%d-%m-%Y"),
        (r"\b([A-Z][a-z]+)\s+(\d{1,2}),\s*(20\d{2})\b", "%B %d %Y"),
        (r"\b(\d{1,2})\s+([A-Z][a-z]+)\s+(20\d{2})\b", "%d %B %Y"),
    ]
    for pat, fmt in patterns:
        for m in re.finditer(pat, text):
            val = " ".join(m.groups()) if "%B" in fmt else "-".join(m.groups())
            try:
                return dt.datetime.strptime(val, fmt).date().isoformat()
            except ValueError:
                continue
    return None


def likely_merchant(lines: list[str], text: str = "") -> str | None:
    # Specific useful hint: many web receipts contain the domain even when the logo OCR is noisy.
    domain = re.search(r"\b(?:www\.)?([a-z0-9-]+)\.com\.au\b", text, re.I)
    if domain:
        known = {"nopong": "No Pong"}
        key = domain.group(1).lower().replace("-", "")
        if key in known:
            return known[key]
        return domain.group(1).replace("-", " ").title()

    bad = re.compile(r"^(--- ocr|receipt|tax invoice|invoice|order|checkout|date|total|subtotal|payment|product|qty|amount|thank you)", re.I)
    for line in lines[:30]:
        l = line.strip(" :-|•")
        if len(l) < 3 or bad.search(l) or re.search(r"@|https?://|www\.|\$|\d{4,}", l):
            continue
        # Prefer line with letters, not mostly symbols.
        if sum(ch.isalpha() for ch in l) >= 3:
            return l[:120]
    return None


def parse_fields(text: str) -> dict[str, Any]:
    lines = [l.strip() for l in text.splitlines() if l.strip()]
    lower = text.lower()
    fields: dict[str, Any] = {
        "merchant_name": likely_merchant(lines, text),
        "receipt_date": parse_date(text),
        "currency": "AUD" if "$" in text else None,
    }

    # Order / invoice / transaction refs. Prefer same-line matches, then label-next-line matches.
    ref_patterns = {
        "docket_number": r"(?:order\s*(?:number|no\.?|#)|receipt\s*(?:number|no\.?|#)|docket\s*(?:number|no\.?|#))\s*[:#]?\s*([A-Z0-9-]{4,})",
        "invoice_number": r"invoice\s*(?:number|no\.?|#)?\s*[:#]?\s*([A-Z0-9-]{4,})",
        "transaction_reference": r"(?:transaction|txn|reference|ref)\s*(?:number|no\.?|#)?\s*[:#]?\s*([A-Z0-9-]{4,})",
    }
    for key, pat in ref_patterns.items():
        m = re.search(pat, text, re.I)
        if m and m.group(1).lower() not in {"date", "total", "email", "payment"}:
            fields[key] = m.group(1)
    for i, line in enumerate(lines[:-1]):
        if "order number" in line.lower() and not fields.get("docket_number"):
            # Common receipt layout: ORDER NUMBER: DATE: / 3465186 May 14, 2026
            nxt = lines[i + 1]
            m = re.search(r"\b([0-9]{4,}|[A-Z0-9-]{6,})\b", nxt)
            if m:
                fields["docket_number"] = m.group(1)

    pm = re.search(r"payment\s*method\s*:?\s*([A-Za-z0-9 /_-]{3,40})", text, re.I)
    if pm:
        fields["payment_method"] = pm.group(1).strip()
    else:
        for method in ("PayPal", "Visa", "Mastercard", "EFTPOS", "Cash", "Amex", "Afterpay", "Apple Pay", "Google Pay"):
            if method.lower() in lower:
                fields["payment_method"] = method
                break

    # Amounts. Line-based parsing avoids confusing PRODUCT TOTAL headers and 85G sizes.
    for line in lines:
        low = line.lower()
        amount_last = money_to_float(line, require_dollars=True, last=True)
        amount_first = money_to_float(line, require_dollars=True, last=False)
        if amount_last is None:
            continue
        if "subtotal" in low:
            fields["subtotal"] = amount_last
        if re.search(r"\bgst\b|\btax\b", low):
            fields["gst"] = amount_last
        if re.search(r"^(grand\s+total|amount\s+paid|total)\b", low):
            # For "Total: $99.98 (includes $9.09 GST)", the total is the first amount.
            fields["total"] = amount_first
    # Embedded form: includes $9.09 GST
    gst_m = re.search(r"includes\s+\$\s*([0-9,]+\.[0-9]{2})\s+GST", text, re.I)
    if gst_m:
        fields["gst"] = float(gst_m.group(1).replace(",", ""))

    # Fallback total = largest money amount, conservative.
    if not fields.get("total"):
        monies = [money_to_float(m.group(0), require_dollars=True) for m in re.finditer(r"\$\s*[0-9,]+\.[0-9]{2}", text)]
        monies = [m for m in monies if m is not None]
        if monies:
            fields["total"] = max(monies)

    # Simple item extraction: lines with money, excluding summary/payment lines.
    item_lines = []
    seen_items = set()
    skip = re.compile(r"subtotal|total|gst|tax|shipping|payment|points|redeemed|balance|change|cash|visa|mastercard|email|@", re.I)
    for line in lines:
        if "$" not in line or skip.search(line):
            continue
        amount = money_to_float(line, require_dollars=True, last=True)
        desc = re.sub(r"\s*-?\$\s*[0-9,]+\.[0-9]{2}.*$", "", line).strip(" :-|•")
        if amount is not None and len(desc) >= 3:
            qty = None
            qm = re.search(r"(?:x|×)\s*(\d+(?:\.\d+)?)\b", desc, re.I)
            if qm:
                qty = float(qm.group(1))
                desc = re.sub(r"\s*(?:x|×)\s*\d+(?:\.\d+)?\b", "", desc, flags=re.I).strip()
            norm_desc = re.sub(r"[^a-z0-9]+", " ", desc.lower()).strip()
            key = (norm_desc, qty, amount)
            if key in seen_items:
                continue
            # OCR variants can repeat the same item from different preprocessing runs.
            duplicate_idx = None
            for idx, existing in enumerate(item_lines):
                if existing.get("quantity") == qty and existing.get("line_total") == amount:
                    ex_norm = re.sub(r"[^a-z0-9]+", " ", existing["description"].lower()).strip()
                    if difflib.SequenceMatcher(None, norm_desc, ex_norm).ratio() >= 0.92:
                        duplicate_idx = idx
                        break
            if duplicate_idx is not None:
                if len(desc) > len(item_lines[duplicate_idx]["description"]):
                    item_lines[duplicate_idx]["description"] = desc[:200]
                continue
            seen_items.add(key)
            item_lines.append({"description": desc[:200], "quantity": qty, "line_total": amount})
    fields["items"] = item_lines[:50]
    return fields


def connect() -> sqlite3.Connection:
    con = sqlite3.connect(DB)
    con.row_factory = sqlite3.Row
    return con


def existing_by_sha(con: sqlite3.Connection, sha: str) -> sqlite3.Row | None:
    return con.execute("SELECT * FROM receipts WHERE image_sha256=?", (sha,)).fetchone()


def insert_or_update(con: sqlite3.Connection, *, source: Path, stored: Path, sha: str, parsed: dict[str, Any], text: str, ocr_info: dict[str, Any], args: argparse.Namespace) -> int:
    captured = args.captured_at or now_local().isoformat(timespec="seconds")
    confidence = "Local OCR via Tesseract. Parsed fields are heuristic; verify important warranty/tax data against original image."
    ocr_json = json.dumps({"parsed": parsed, "ocr": ocr_info}, ensure_ascii=False)

    values = {
        "category": args.category,
        "doc_type": args.doc_type,
        "merchant_name": parsed.get("merchant_name"),
        "receipt_date": parsed.get("receipt_date"),
        "docket_number": parsed.get("docket_number"),
        "invoice_number": parsed.get("invoice_number"),
        "transaction_reference": parsed.get("transaction_reference"),
        "payment_method": parsed.get("payment_method"),
        "subtotal": parsed.get("subtotal"),
        "gst": parsed.get("gst"),
        "total": parsed.get("total"),
        "currency": parsed.get("currency") or "AUD",
        "image_path": str(stored),
        "source_media_path": str(source),
        "image_sha256": sha,
        "ocr_raw_text": text,
        "ocr_json": ocr_json,
        "confidence_notes": confidence,
        "captured_at": captured,
        "notes": args.notes,
    }

    if args.receipt_id:
        rid = args.receipt_id
        assignments = ", ".join(f"{k}=?" for k in values.keys()) + ", updated_at=CURRENT_TIMESTAMP"
        con.execute(f"UPDATE receipts SET {assignments} WHERE id=?", [*values.values(), rid])
    else:
        row = existing_by_sha(con, sha)
        if row:
            rid = int(row["id"])
            if not args.update_existing:
                return rid
            assignments = ", ".join(f"{k}=?" for k in values.keys()) + ", updated_at=CURRENT_TIMESTAMP"
            con.execute(f"UPDATE receipts SET {assignments} WHERE id=?", [*values.values(), rid])
        else:
            cols = ", ".join(values.keys())
            qs = ", ".join("?" for _ in values)
            cur = con.execute(f"INSERT INTO receipts ({cols}) VALUES ({qs})", list(values.values()))
            rid = int(cur.lastrowid)

    if args.replace_items:
        con.execute("DELETE FROM receipt_items WHERE receipt_id=?", (rid,))
        for item in parsed.get("items", []):
            con.execute(
                "INSERT INTO receipt_items (receipt_id, description, quantity, unit_price, line_total, notes) VALUES (?, ?, ?, ?, ?, ?)",
                (rid, item.get("description"), item.get("quantity"), item.get("unit_price"), item.get("line_total"), "auto-parsed from OCR"),
            )
    con.commit()
    return rid


def main() -> None:
    ap = argparse.ArgumentParser(description="Import/OCR a receipt image/PDF into knowledge/receipts")
    ap.add_argument("source", help="Path to receipt image/PDF")
    ap.add_argument("--category", default="Uncategorised")
    ap.add_argument("--doc-type", default="receipt")
    ap.add_argument("--captured-at", help="ISO timestamp; default now")
    ap.add_argument("--receipt-id", type=int, help="Update an existing receipts.id instead of inserting")
    ap.add_argument("--notes", help="Receipt-level note from Michael/Soren, separate from OCR confidence notes")
    ap.add_argument("--update-existing", action="store_true", help="If SHA already exists, update that row")
    ap.add_argument("--replace-items", action=argparse.BooleanOptionalAction, default=True)
    ap.add_argument("--dry-run", action="store_true", help="OCR/parse only; no DB write/copy")
    ap.add_argument("--json", action="store_true", help="Print machine-readable result")
    args = ap.parse_args()

    src = Path(args.source).expanduser().resolve()
    if not src.exists():
        raise SystemExit(f"source not found: {src}")
    if not DB.exists():
        raise SystemExit(f"database not found: {DB}")

    ext = src.suffix.lower()
    sha = sha256_file(src)
    captured = dt.datetime.fromisoformat(args.captured_at) if args.captured_at else now_local()
    ocr_dir = OCR_ROOT / sha[:16]

    if ext in IMAGE_EXTS:
        ocr_info = ocr_image(src, ocr_dir)
    elif ext in PDF_EXTS:
        ocr_info = ocr_pdf(src, ocr_dir)
    elif ext in TEXT_EXTS:
        ocr_dir.mkdir(parents=True, exist_ok=True)
        ocr_info = {"text": clean_text(src.read_text(errors="replace")), "runs": [], "dir": str(ocr_dir)}
        (ocr_dir / "ocr_raw.txt").write_text(ocr_info["text"])
    else:
        raise SystemExit(f"unsupported file type: {ext}")

    text = ocr_info["text"]
    parsed = parse_fields(text)
    result: dict[str, Any] = {"source": str(src), "sha256": sha, "ocr_dir": str(ocr_dir), "parsed": parsed}

    if args.dry_run:
        result["dry_run"] = True
    else:
        try:
            # If the source is already inside the receipts image archive, don't duplicate it.
            src.relative_to(IMAGES.resolve())
            stored = src.relative_to(Path.cwd()) if src.is_relative_to(Path.cwd()) else src
        except Exception:
            stored = copy_original(src, category=args.category, captured=captured, sha=sha)
        with connect() as con:
            rid = insert_or_update(con, source=src, stored=stored, sha=sha, parsed=parsed, text=text, ocr_info=ocr_info, args=args)
        result.update({"receipt_id": rid, "image_path": str(stored)})

    if args.json:
        print(json.dumps(result, indent=2, ensure_ascii=False))
    else:
        print(f"OCR dir: {result['ocr_dir']}")
        if "receipt_id" in result:
            print(f"Receipt ID: {result['receipt_id']}")
            print(f"Image: {result['image_path']}")
        print("Parsed:")
        for k, v in parsed.items():
            if k == "items":
                print(f"  items: {len(v)}")
                for item in v[:10]:
                    print(f"    - {item}")
            else:
                print(f"  {k}: {v}")


if __name__ == "__main__":
    main()
