#!/usr/bin/env python3
"""Free Freelancer Receipt Organizer Tool — stdlib only, offline.

Sorts receipt image/PDF files into Year/Month folders, renames each one
``YYYY-MM-DD-vendor-amount.ext`` and writes a CSV log (receipt-log.csv)
in the output folder.

NO OCR: this tool never reads text inside an image or PDF. The date,
vendor and amount all come from the FILE NAME. If a file name does not
contain a usable date or vendor, the tool says so in the CSV log
(needs_review=YES) instead of guessing silently — see README.md for how
to fix those files by renaming them or by supplying a vendor map.

Usage:
    python3 receipt_organizer.py <input_folder> <output_folder>
    python3 receipt_organizer.py <input_folder> <output_folder> --dry-run
    python3 receipt_organizer.py <input_folder> <output_folder> --copy
    python3 receipt_organizer.py <input_folder> <output_folder> \
        --vendor-map vendors.csv

Vendor map format (CSV, header row required):
    original_filename,vendor
    IMG_20260310.jpg,corner-bookshop

Only the Python standard library is used. Nothing is uploaded anywhere.
"""

import argparse
import csv
import os
import re
import shutil
import sys
from datetime import datetime

SUPPORTED_EXTS = {".jpg", ".jpeg", ".png", ".pdf"}
# File-name tokens that carry no vendor information and are stripped out.
GENERIC_TOKENS = {
    "img", "image", "photo", "pic", "scan", "scanned", "receipt",
    "bill", "invoice", "document", "doc", "file", "download",
}

DATE_PATTERNS = [
    # 2026-03-14, 2026/03/14, 2026_03_14
    (re.compile(r"(20\d{2})[-_/](\d{1,2})[-_/](\d{1,2})"), "ymd"),
    # 20260314
    (re.compile(r"(20\d{2})(\d{2})(\d{2})"), "ymd_compact"),
    # 14-03-2026, 14/03/2026 (day first, as commonly written in Pakistan)
    (re.compile(r"(\d{1,2})[-_/](\d{1,2})[-_/](20\d{2})"), "dmy"),
]

# A trailing amount: "-4500", "_4500.50", "-PKR4500", "-Rs4500", "-$12.50"
AMOUNT_RE = re.compile(
    r"[-_](?:PKR|Rs\.?|USD|\$)?\s*(\d[\d,]*\.?\d{0,2})\s*$", re.IGNORECASE
)
CURRENCY_RE = re.compile(r"[-_](PKR|Rs\.?|USD|\$)\s*\d", re.IGNORECASE)


def slugify(text):
    """Lowercase a vendor string into a safe file-name slug."""
    text = re.sub(r"[^A-Za-z0-9]+", "-", text.strip()).strip("-").lower()
    return text


def parse_date(stem):
    """Return (date, source) from a file-name stem, or (None, None)."""
    for pattern, _kind in DATE_PATTERNS:
        match = pattern.search(stem)
        if not match:
            continue
        try:
            if pattern is DATE_PATTERNS[0][0] or pattern is DATE_PATTERNS[1][0]:
                year, month, day = (int(g) for g in match.groups())
            else:
                day, month, year = (int(g) for g in match.groups())
            return datetime(year, month, day).date(), "filename"
        except ValueError:
            continue  # e.g. month 13: not a real date, try the next pattern
    return None, None


def remove_date(text):
    """Remove any date pattern from a file-name stem."""
    for pattern, _kind in DATE_PATTERNS:
        text = pattern.sub(" ", text)
    return text


def parse_amount(stem_no_date):
    """Return (amount_string, currency_string) from a trailing amount.

    Takes the stem AFTER the date has been removed, so date digits can
    never be mistaken for an amount (e.g. IMG_20260310 must not yield
    amount 20260310).
    """
    match = AMOUNT_RE.search(stem_no_date)
    if not match:
        return "", ""
    amount = match.group(1).replace(",", "")
    currency_match = CURRENCY_RE.search(stem_no_date)
    currency = ""
    if currency_match:
        raw = currency_match.group(1)
        currency = "PKR" if raw.lower().startswith("rs") else raw.upper()
        if currency == "$":
            currency = "USD"
    return amount, currency


def parse_vendor(stem_no_date, vendor_map, original_name):
    """Return (vendor_slug, needs_review).

    Vendor comes from the file name after date/amount/generic tokens are
    removed, or from the --vendor-map. If neither gives a vendor, the file
    is kept under 'unknown-vendor' and flagged for manual review — the
    tool never invents a vendor name.
    """
    if original_name in vendor_map:
        return slugify(vendor_map[original_name]), False
    tokens = [t for t in re.split(r"[-_ ]+", AMOUNT_RE.sub(" ", stem_no_date)) if t]
    tokens = [t for t in tokens if t.lower() not in GENERIC_TOKENS]
    tokens = [t for t in tokens if not t.isdigit()]
    if tokens:
        return slugify("-".join(tokens)), False
    return "unknown-vendor", True


def load_vendor_map(path):
    vendor_map = {}
    with open(path, newline="", encoding="utf-8") as handle:
        for row in csv.DictReader(handle):
            name = (row.get("original_filename") or "").strip()
            vendor = (row.get("vendor") or "").strip()
            if name and vendor:
                vendor_map[name] = vendor
    return vendor_map


def unique_path(path):
    """If path exists, append -2, -3, ... before the extension."""
    if not os.path.exists(path):
        return path
    root, ext = os.path.splitext(path)
    counter = 2
    while True:
        candidate = "%s-%d%s" % (root, counter, ext)
        if not os.path.exists(candidate):
            return candidate
        counter += 1


def organise(input_dir, output_dir, dry_run=False, copy=False, vendor_map=None):
    vendor_map = vendor_map or {}
    rows = []
    skipped = []
    for name in sorted(os.listdir(input_dir)):
        src = os.path.join(input_dir, name)
        if not os.path.isfile(src):
            continue
        stem, ext = os.path.splitext(name)
        ext = ext.lower()
        if ext not in SUPPORTED_EXTS:
            skipped.append(name)
            continue
        date, date_source = parse_date(stem)
        needs_review = False
        if date is None:
            # No date in the name: fall back to the file's last-modified
            # date and flag it, so the user can correct it by renaming.
            mtime = datetime.fromtimestamp(os.path.getmtime(src)).date()
            date, date_source = mtime, "file-mtime (no date in filename)"
            needs_review = True
        amount, currency = parse_amount(remove_date(stem))
        vendor, vendor_unknown = parse_vendor(remove_date(stem), vendor_map, name)
        if vendor_unknown:
            needs_review = True
        new_name = "%s-%s" % (date.isoformat(), vendor)
        if amount:
            new_name += "-%s" % amount
        new_name += ext
        dest_dir = os.path.join(output_dir, "%04d" % date.year, "%02d" % date.month)
        dest = unique_path(os.path.join(dest_dir, new_name))
        if not dry_run:
            os.makedirs(dest_dir, exist_ok=True)
            if copy:
                shutil.copy2(src, dest)
            else:
                shutil.move(src, dest)
        rows.append({
            "original_filename": name,
            "new_filename": os.path.basename(dest),
            "date": date.isoformat(),
            "vendor": vendor,
            "amount": amount,
            "currency": currency,
            "year": date.year,
            "month": "%02d" % date.month,
            "destination_path": os.path.relpath(dest, output_dir),
            "needs_review": "YES" if needs_review else "NO",
            "date_source": date_source,
        })
    if not dry_run:
        log_path = os.path.join(output_dir, "receipt-log.csv")
        os.makedirs(output_dir, exist_ok=True)
        with open(log_path, "w", newline="", encoding="utf-8") as handle:
            writer = csv.DictWriter(handle, fieldnames=list(rows[0].keys()) if rows
                                    else ["original_filename"])
            writer.writeheader()
            writer.writerows(rows)
    return rows, skipped


def main(argv=None):
    parser = argparse.ArgumentParser(
        description="Sort receipt images/PDFs into Year/Month folders, "
                    "rename them date-vendor-amount, and write a CSV log. "
                    "No OCR is performed.")
    parser.add_argument("input_dir", help="Folder containing receipt files")
    parser.add_argument("output_dir", help="Folder to organise receipts into")
    parser.add_argument("--dry-run", action="store_true",
                        help="Show what would happen without moving files")
    parser.add_argument("--copy", action="store_true",
                        help="Copy files instead of moving them")
    parser.add_argument("--vendor-map",
                        help="CSV mapping original_filename,vendor for "
                             "files whose names carry no vendor")
    args = parser.parse_args(argv)

    if not os.path.isdir(args.input_dir):
        print("Error: input folder not found: %s" % args.input_dir, file=sys.stderr)
        return 2
    vendor_map = load_vendor_map(args.vendor_map) if args.vendor_map else {}
    rows, skipped = organise(args.input_dir, args.output_dir,
                             dry_run=args.dry_run, copy=args.copy,
                             vendor_map=vendor_map)
    for row in rows:
        print("%s -> %s%s" % (row["original_filename"],
                              row["destination_path"],
                              " [NEEDS REVIEW]" if row["needs_review"] == "YES" else ""))
    for name in skipped:
        print("Skipped (not a receipt image/PDF): %s" % name)
    if args.dry_run:
        print("Dry run: no files were moved. %d receipt(s) planned." % len(rows))
    else:
        print("Done: %d receipt(s) organised into %s (log: receipt-log.csv)"
              % (len(rows), args.output_dir))
    flagged = sum(1 for r in rows if r["needs_review"] == "YES")
    if flagged:
        print("Note: %d file(s) flagged NEEDS REVIEW — check the CSV log, "
              "rename the file with a date and vendor, and re-run." % flagged)
    return 0


if __name__ == "__main__":
    sys.exit(main())
