#!/usr/bin/env python3
# Author: Tom Sapletta · https://tom.sapletta.com
# Part of the ifURI solution.

"""Flatten invoice files into month-level names via URI.

Input is the CSV created by ``invoice_audit.py``. Files confidently classified as
non-invoices by ``move_no_invoice.py`` are skipped, because they belong in the
``no_invoice`` quarantine. Remaining documents are moved from nested paths such
as:

``2026.03/_by_supplier/saas/me/invoice.pdf``

to flat month-level paths such as:

``2026.03/2026.03.17-saas-invoice.pdf``
"""

from __future__ import annotations

import argparse
import csv
import json
import re
import unicodedata
import urllib.error
import urllib.request
from pathlib import Path, PurePosixPath
from typing import Any

from move_no_invoice import classify_no_invoice

DEFAULT_NODE_URL = "http://192.168.188.201:8765"
DEFAULT_MAX_COMPONENT = 240

POLISH_MONTHS = {
    "stycznia": 1,
    "lutego": 2,
    "marca": 3,
    "kwietnia": 4,
    "maja": 5,
    "czerwca": 6,
    "lipca": 7,
    "sierpnia": 8,
    "września": 9,
    "wrzesnia": 9,
    "października": 10,
    "pazdziernika": 10,
    "listopada": 11,
    "grudnia": 12,
}

ENGLISH_MONTHS = {
    "jan": 1,
    "january": 1,
    "feb": 2,
    "february": 2,
    "mar": 3,
    "march": 3,
    "apr": 4,
    "april": 4,
    "may": 5,
    "jun": 6,
    "june": 6,
    "jul": 7,
    "july": 7,
    "aug": 8,
    "august": 8,
    "sep": 9,
    "sept": 9,
    "september": 9,
    "oct": 10,
    "october": 10,
    "nov": 11,
    "november": 11,
    "dec": 12,
    "december": 12,
}


class NodeClient:
    def __init__(self, base_url: str, timeout: int = 120):
        self.base_url = base_url.rstrip("/")
        self.timeout = timeout

    def run(self, uri: str, payload: dict[str, Any]) -> dict[str, Any]:
        body = json.dumps({"uri": uri, "payload": payload}).encode("utf-8")
        request = urllib.request.Request(
            self.base_url + "/run",
            data=body,
            headers={"Content-Type": "application/json"},
            method="POST",
        )
        try:
            with urllib.request.urlopen(request, timeout=self.timeout) as response:
                return json.loads(response.read().decode("utf-8") or "{}")
        except urllib.error.HTTPError as exc:
            raw = exc.read().decode("utf-8", "replace") if exc.fp else ""
            try:
                return json.loads(raw or "{}")
            except json.JSONDecodeError:
                return {"ok": False, "status": exc.code, "error": raw}


def route_value(envelope: dict[str, Any]) -> dict[str, Any]:
    result = envelope.get("result") or {}
    if isinstance(result, dict) and isinstance(result.get("value"), dict):
        return result["value"]
    return result if isinstance(result, dict) else {}


def read_rows(csv_path: Path) -> list[dict[str, str]]:
    with csv_path.open(encoding="utf-8", newline="") as handle:
        return list(csv.DictReader(handle))


def month_from_path(path: str) -> str:
    first = PurePosixPath(path).parts[0] if path else ""
    match = re.fullmatch(r"(20\d{2})[.-](\d{2})", first)
    return f"{match.group(1)}.{match.group(2)}" if match else ""


def _valid_date(year: int, month: int, day: int) -> bool:
    return 2000 <= year <= 2099 and 1 <= month <= 12 and 1 <= day <= 31


def _format_date(year: int, month: int, day: int, separator: str) -> str:
    return f"{year:04d}{separator}{month:02d}{separator}{day:02d}"


def _source_month_tuple(month: str) -> tuple[int, int] | None:
    match = re.fullmatch(r"(20\d{2})[.-](\d{2})", month)
    if not match:
        return None
    return int(match.group(1)), int(match.group(2))


def extract_date(row: dict[str, str], separator: str = ".") -> str:
    """Extract the best document date, preferring dates in the source month."""
    source_month = month_from_path(row.get("path", ""))
    source_tuple = _source_month_tuple(source_month)
    text = f"{row.get('path', '')}\n{row.get('preview', '')}"
    candidates: list[tuple[int, int, int]] = []

    for year, month, day in re.findall(r"(?<!\d)(20\d{2})[-./_](\d{1,2})[-./_](\d{1,2})(?!\d)", text):
        y, m, d = int(year), int(month), int(day)
        if _valid_date(y, m, d):
            candidates.append((y, m, d))
    for day, month, year in re.findall(r"(?<!\d)(\d{1,2})[-./_](\d{1,2})[-./_](20\d{2})(?!\d)", text):
        y, m, d = int(year), int(month), int(day)
        if _valid_date(y, m, d):
            candidates.append((y, m, d))
    polish_pattern = r"\b(\d{1,2})\s+(" + "|".join(POLISH_MONTHS) + r")\s+(20\d{2})\b"
    for day, month_name, year in re.findall(polish_pattern, text.lower()):
        y, m, d = int(year), POLISH_MONTHS[month_name], int(day)
        if _valid_date(y, m, d):
            candidates.append((y, m, d))
    english_pattern = r"\b(" + "|".join(ENGLISH_MONTHS) + r")\.?\s+(\d{1,2}),?\s+(20\d{2})\b"
    for month_name, day, year in re.findall(english_pattern, text.lower()):
        y, m, d = int(year), ENGLISH_MONTHS[month_name], int(day)
        if _valid_date(y, m, d):
            candidates.append((y, m, d))

    if source_tuple:
        for y, m, d in candidates:
            if (y, m) == source_tuple:
                return _format_date(y, m, d, separator)
        return f"{source_tuple[0]:04d}{separator}{source_tuple[1]:02d}{separator}00"
    if candidates:
        return _format_date(*candidates[0], separator=separator)
    return f"unknown{separator}00{separator}00"


def slug(value: str, fallback: str = "unknown") -> str:
    normalized = unicodedata.normalize("NFKD", value).encode("ascii", "ignore").decode("ascii")
    cleaned = re.sub(r"[^a-zA-Z0-9]+", "-", normalized.lower()).strip("-")
    return cleaned or fallback


def label_from_row(row: dict[str, str]) -> str:
    category = slug(row.get("category", ""))
    if category and category != "unknown":
        return category
    parts = PurePosixPath(row.get("path", "")).parts
    if "_by_supplier" in parts:
        index = parts.index("_by_supplier")
        if index + 1 < len(parts):
            return slug(parts[index + 1])
    return "unknown"


def trim_component(name: str, max_len: int = DEFAULT_MAX_COMPONENT) -> str:
    if len(name) <= max_len:
        return name
    suffix = PurePosixPath(name).suffix
    stem = name[: -len(suffix)] if suffix else name
    keep = max(1, max_len - len(suffix))
    return stem[:keep].rstrip(" .-_") + suffix


def add_collision_suffix(target: str, index: int) -> str:
    path = PurePosixPath(target)
    suffix = path.suffix
    stem = path.name[: -len(suffix)] if suffix else path.name
    name = trim_component(f"{stem}-{index}{suffix}")
    return str(path.with_name(name))


def target_for_row(row: dict[str, str], seen: set[str], date_separator: str = ".") -> dict[str, str]:
    path = row.get("path", "")
    month = month_from_path(path)
    date = extract_date(row, separator=date_separator)
    label = label_from_row(row)
    original = PurePosixPath(path).name.replace("/", "_").strip()
    filename = trim_component(f"{date}-{label}-{original}")
    target = str(PurePosixPath(month) / filename) if month else filename
    base_target = target
    collision_index = 2
    while target in seen:
        target = add_collision_suffix(base_target, collision_index)
        collision_index += 1
    seen.add(target)
    return {
        "path": path,
        "target": target,
        "date": date,
        "label": label,
        "original": original,
    }


def build_plan(rows: list[dict[str, str]], date_separator: str = ".", max_files: int = 0) -> tuple[list[dict[str, str]], int]:
    plan: list[dict[str, str]] = []
    skipped_no_invoice = 0
    seen: set[str] = set()
    for row in rows:
        path = row.get("path", "")
        if not path or path.startswith("no_invoice/"):
            skipped_no_invoice += 1
            continue
        is_no_invoice, reason = classify_no_invoice(row)
        if is_no_invoice:
            skipped_no_invoice += 1
            continue
        item = target_for_row(row, seen=seen, date_separator=date_separator)
        item.update({
            "category": row.get("category", ""),
            "reason": row.get("reason", ""),
            "skip_reason": reason if is_no_invoice else "",
        })
        plan.append(item)
        if max_files and len(plan) >= max_files:
            break
    return plan, skipped_no_invoice


def write_csv(path: Path, rows: list[dict[str, Any]]) -> None:
    fields = ["path", "target", "ok", "moved", "dryRun", "date", "label", "category", "reason", "error"]
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("w", encoding="utf-8", newline="") as handle:
        writer = csv.DictWriter(handle, fieldnames=fields)
        writer.writeheader()
        for row in rows:
            writer.writerow({field: row.get(field, "") for field in fields})


def flatten_invoices(args: argparse.Namespace, client: NodeClient | None = None) -> dict[str, Any]:
    client = client or NodeClient(args.node_url, timeout=args.timeout)
    rows = read_rows(Path(args.input_csv))
    plan, skipped_no_invoice = build_plan(rows, date_separator=args.date_separator, max_files=args.max_files)

    results: list[dict[str, Any]] = []
    for item in plan:
        if item["path"] == item["target"]:
            data = {
                "ok": True,
                "dryRun": not args.execute,
                "moved": False,
                "reason": "already at destination",
                "target": item["target"],
            }
        else:
            envelope = client.run(args.move_uri, {
                "path": item["path"],
                "target_path": item["target"],
                "dry_run": not args.execute,
                "overwrite": args.overwrite,
                "make_parents": True,
            })
            data = route_value(envelope)
        results.append({
            **item,
            "ok": bool(data.get("ok")),
            "moved": bool(data.get("moved")),
            "dryRun": data.get("dryRun", not args.execute),
            "target": data.get("target", item["target"]),
            "error": data.get("error", ""),
        })

    ok_count = sum(1 for row in results if row["ok"])
    moved_count = sum(1 for row in results if row["moved"])
    payload = {
        "ok": all(row["ok"] for row in results),
        "dryRun": not args.execute,
        "candidate_count": len(plan),
        "skipped_no_invoice": skipped_no_invoice,
        "ok_count": ok_count,
        "moved_count": moved_count,
        "results": results,
    }

    out_dir = Path(args.output_dir)
    out_dir.mkdir(parents=True, exist_ok=True)
    stem = "flat_invoice_manifest" if args.execute else "flat_invoice_plan"
    json_path = out_dir / f"{stem}.json"
    csv_path = out_dir / f"{stem}.csv"
    json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
    write_csv(csv_path, results)
    payload["reports"] = {"json": str(json_path), "csv": str(csv_path)}
    return payload


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description="Flatten nested invoice files into month-level names via URI.")
    parser.add_argument("--node-url", default=DEFAULT_NODE_URL)
    parser.add_argument("--input-csv", default=".state/invoice_audit_files.csv")
    parser.add_argument("--output-dir", default=".state")
    parser.add_argument("--move-uri", default="fs://host/file/command/move")
    parser.add_argument("--date-separator", default=".", choices=[".", "-"])
    parser.add_argument("--max-files", type=int, default=0)
    parser.add_argument("--timeout", type=int, default=120)
    parser.add_argument("--overwrite", action="store_true")
    parser.add_argument("--execute", action="store_true", help="actually move/rename files; default is dry-run")
    return parser


def main(argv: list[str] | None = None) -> int:
    args = build_parser().parse_args(argv)
    result = flatten_invoices(args)
    print(json.dumps({
        "ok": result["ok"],
        "dryRun": result["dryRun"],
        "candidate_count": result["candidate_count"],
        "skipped_no_invoice": result["skipped_no_invoice"],
        "ok_count": result["ok_count"],
        "moved_count": result["moved_count"],
        "reports": result["reports"],
    }, ensure_ascii=False, indent=2))
    return 0 if result["ok"] else 1


if __name__ == "__main__":
    raise SystemExit(main())
