"""Workflow: Document-Schreib-Operationen (upload, update, delete).

Aufruf:
  python documents.py upload --file path/to/Rechnung.pdf --type INCOMING_INVOICE \
         [--company 1234 --number INV-1 --date 2026-04-06 --gross 123.45 --currency EUR --tags "eu,ops" --note "..."]
  python documents.py update --uid 12345 [--number INV-1 --gross 123.45 --currency EUR --note "..." --archived]
  python documents.py delete --uid 12345 [--confirm --confirm-number "INV-1"] | [--confirm --confirm-filename "Rechnung.pdf"]

DELETE-Flow:
  1. Preview via getOneDocument (nur meta_data wird angezeigt, niemals fileContent).
  2. Bestätigung Pflicht:
     - Interaktiv: exakte documentNumber ODER exakter filename muss getippt werden.
     - Non-interaktiv: --confirm UND (--confirm-number ODER --confirm-filename).
  3. Audit-Zeile auf stderr nach erfolgreichem DELETE.

Upload-Body: fileContent ist base64-kodierter Binärinhalt (JSON, kein multipart).
Pflichtfelder laut Spec: fileName, fileContent, documentType. Alle anderen Felder
sind optional. Nach upload/update wird per getOneDocument neu gelesen; Felder, die
die API stillschweigend verschluckt, werden als silently_dropped gemeldet.

SICHERHEIT: fileContent (base64 der Original-PDF) wird niemals in stdout/Logs
geschrieben, weder beim Upload-Response noch beim Preview noch in Fehlermeldungen.
"""
from __future__ import annotations

import argparse
import base64
import datetime as _dt
import hashlib
import json
import sys
from pathlib import Path
from typing import Any

try:
    sys.stdout.reconfigure(encoding="utf-8")
except (AttributeError, Exception):
    pass

sys.path.insert(0, str(Path(__file__).parent.parent))

from gmi_client import GmiClient, GmiHttpError  # noqa: E402


DOCUMENT_TYPES = [
    "INCOMING_INVOICE", "RECEIPT", "PAYMENT_RECEIPT", "EXPENSE_REIMBURSEMENT",
    "SALES_INVOICE", "CREDIT_NOTE", "STATEMENT", "DELIVERY_NOTE",
    "ORDER_CONFIRMATION", "PAYROLL", "COMPANY_REGISTRATION_DOCUMENT", "MISC",
    "TRAVEL_EXPENSES", "REMINDER",
]

PAYMENT_METHODS = [
    "bank_transfer", "cash", "check", "direct_debit", "credit", "cc", "paypal",
    "online_payment", "amazon_pay", "apple_pay", "google_pay",
    "external_receivables_management", "cashbox", "offsetting", "other",
]

PAYMENT_STATUSES = ["Unknown", "Paid", "Partially", "Not paid"]


# ---------- Helpers ----------

def _meta_only(one_document_response: Any) -> dict | None:
    """Extract meta_data dict from getOneDocument response.

    Die API liefert {meta_data: {...}, fileContent: <base64>}. fileContent wird
    NIE zurückgegeben oder geloggt. Diese Helper schneidet sie raus.
    """
    if not isinstance(one_document_response, dict):
        return None
    meta = one_document_response.get("meta_data")
    if isinstance(meta, dict):
        return meta
    return None


def get_document_meta(client: GmiClient, uid: int) -> dict | None:
    """Liefert meta_data der Rechnung, oder None bei 404. fileContent wird verworfen."""
    try:
        r = client.call("getOneDocument", documentUid=uid)
    except GmiHttpError as e:
        if e.status == 404:
            return None
        raise
    return _meta_only(r)


# ---------- Build request bodies ----------

# Keys für die Re-Read-Warnung: alles, was upload/update an die API schickt.
_UPLOAD_FIELD_KEYS = [
    "companyId", "documentType", "documentNumber", "documentDate",
    "documentDueDate", "orderNumber", "paymentMethod", "paymentStatus",
    "paidAt", "netAmount", "grossAmount", "currency", "vat", "note",
]

_UPDATE_FIELD_KEYS = _UPLOAD_FIELD_KEYS + ["isArchived"]

# Mapping von body-Key auf den Key, unter dem es in meta_data zurückkommt.
# Die meisten stimmen überein; nur companyId heißt in der Response companyUid.
_META_KEY_MAP = {"companyId": "companyUid"}


def _build_common_body(args: argparse.Namespace) -> dict[str, Any]:
    body: dict[str, Any] = {}
    if args.company is not None:
        body["companyId"] = args.company
    if args.type is not None:
        body["documentType"] = args.type
    if args.number is not None:
        body["documentNumber"] = args.number
    if args.date is not None:
        body["documentDate"] = args.date
    if args.due_date is not None:
        body["documentDueDate"] = args.due_date
    if args.order_number is not None:
        body["orderNumber"] = args.order_number
    if args.payment_method is not None:
        body["paymentMethod"] = args.payment_method
    if args.payment_status is not None:
        body["paymentStatus"] = args.payment_status
    if args.paid_at is not None:
        body["paidAt"] = args.paid_at
    if args.net is not None:
        body["netAmount"] = args.net
    if args.gross is not None:
        body["grossAmount"] = args.gross
    if args.currency is not None:
        body["currency"] = args.currency
    if args.vat is not None:
        body["vat"] = args.vat
    if args.tags is not None:
        body["tags"] = [t.strip() for t in args.tags.split(",") if t.strip()]
    if args.note is not None:
        body["note"] = args.note
    return body


# ---------- Re-read verification ----------

def _verify_body_fields(client: GmiClient, uid: int | None, body: dict) -> dict:
    """Re-Read nach Write, um silently_dropped Felder zu erkennen.

    Tags werden ignoriert (zu komplex für einfachen Vergleich).
    """
    if not uid:
        return {}
    try:
        fresh = get_document_meta(client, uid)
    except GmiHttpError:
        return {"verified": False}
    if not fresh:
        return {"verified": False, "warning": "re-read returned empty"}
    missing: list[str] = []
    for k, v in body.items():
        if k in ("tags", "fileName", "fileContent", "runOCR", "lineItems",
                 "cashDiscountDate", "cashDiscountValue", "taxRates"):
            continue
        meta_key = _META_KEY_MAP.get(k, k)
        got = fresh.get(meta_key)
        # Numerische Felder werden oft als String zurückgegeben
        if _is_empty(got) and not _is_empty(v):
            missing.append(k)
    out: dict[str, Any] = {"verified": True, "fresh": fresh}
    if missing:
        out["silently_dropped"] = missing
    return out


def _is_empty(v: Any) -> bool:
    return v is None or v == "" or v == 0 or v == "0" or v == "0.00"


# ---------- Write operations ----------

def check_hash(client: GmiClient, file_path: Path) -> dict[str, Any]:
    """Prüft, ob eine Datei bereits in GetMyInvoices existiert (MD5-Hash-basiert).

    Ruft `checkDocumentHash` mit dem lokal berechneten MD5 auf.
    Response der API: {success: true, exists: bool}. Es gibt keine documentUid
    in der Response — nur ja/nein.
    """
    path = Path(file_path)
    if not path.is_file():
        return {"ok": False, "error": "file_not_found", "path": str(path)}

    raw = path.read_bytes()
    md5 = hashlib.md5(raw).hexdigest()

    try:
        resp = client.call("checkDocumentHash", _body={"fileHash": md5})
    except GmiHttpError as e:
        return {"ok": False, "error": f"http_{e.status}", "detail": str(e),
                "md5": md5, "fileName": path.name}

    exists = bool(resp.get("exists")) if isinstance(resp, dict) else False
    return {
        "ok": True,
        "action": "check-hash",
        "md5": md5,
        "fileName": path.name,
        "fileSize": len(raw),
        "exists": exists,
        "response": resp,
    }


def list_deleted(client: GmiClient, since: str | None = None,
                 max_items: int | None = None) -> dict[str, Any]:
    """Listet gelöschte Dokumente via `getDeletedDocumentList` (Papierkorb-Sicht).

    API-Response pro Record hat nur `documentUid` und `deletedAt` — die API
    liefert keine weiteren Meta-Daten zu bereits gelöschten Belegen.
    """
    params: dict[str, Any] = {}
    if since:
        params["deletedSinceFilter"] = since

    records: list[dict] = []
    try:
        for item in client.paginate(
            "getDeletedDocumentList", items_key="records", page_size=100, **params,
        ):
            records.append(item)
            if max_items and len(records) >= max_items:
                break
    except GmiHttpError as e:
        return {"ok": False, "error": f"http_{e.status}", "detail": str(e)}

    return {
        "ok": True,
        "action": "list-deleted",
        "records": records,
        "totalCount": len(records),
        "since": since,
    }


def upload_document(client: GmiClient, args: argparse.Namespace) -> dict:
    path = Path(args.file)
    if not path.is_file():
        return {"ok": False, "error": "file_not_found", "path": str(path)}

    # Read + base64-encode lokal, nichts in Logs
    raw = path.read_bytes()

    # Optional: Duplikat-Check vor Upload
    if getattr(args, "skip_if_exists", False):
        md5 = hashlib.md5(raw).hexdigest()
        try:
            resp = client.call("checkDocumentHash", _body={"fileHash": md5})
        except GmiHttpError as e:
            return {"ok": False, "error": f"http_{e.status}", "detail": str(e),
                    "hint": "checkDocumentHash fehlgeschlagen vor dem Upload",
                    "md5": md5, "fileName": path.name}
        if isinstance(resp, dict) and resp.get("exists"):
            return {
                "ok": False,
                "error": "already_exists",
                "hint": ("Datei existiert bereits in GetMyInvoices. "
                         "Ohne --skip-if-exists erneut hochladen, oder Inhalt ändern."),
                "md5": md5,
                "fileName": path.name,
                "fileSize": len(raw),
            }

    b64 = base64.b64encode(raw).decode("ascii")

    body: dict[str, Any] = {
        "fileName": path.name,
        "fileContent": b64,
        "documentType": args.type or "MISC",
    }
    body.update(_build_common_body(args))
    # documentType ist jetzt sicher gesetzt — Re-Build falls _build_common_body es überschrieben hat
    if args.type is not None:
        body["documentType"] = args.type
    if args.ocr:
        body["runOCR"] = True

    try:
        resp = client.call("uploadNewDocument", _body=body)
    except GmiHttpError as e:
        return {"ok": False, "error": f"http_{e.status}", "detail": str(e)}

    uid = resp.get("documentUid") if isinstance(resp, dict) else None
    verification = _verify_body_fields(client, uid, body) if uid else {}
    return {
        "ok": True,
        "action": "upload",
        "documentUid": uid,
        "fileName": path.name,
        "fileSize": len(raw),
        "response": resp,
        **verification,
    }


def update_document(client: GmiClient, args: argparse.Namespace) -> dict:
    body = _build_common_body(args)
    if getattr(args, "archived", False):
        body["isArchived"] = True
    if not body:
        return {"ok": False, "error": "empty_update",
                "hint": "Mindestens ein Feld muss gesetzt werden"}
    try:
        resp = client.call("updateDocument", documentUid=args.uid, _body=body)
    except GmiHttpError as e:
        return {"ok": False, "error": f"http_{e.status}", "detail": str(e)}
    verification = _verify_body_fields(client, args.uid, body)
    return {
        "ok": True,
        "action": "update",
        "documentUid": args.uid,
        "response": resp,
        **verification,
    }


def delete_document(
    client: GmiClient,
    uid: int,
    confirm_number: str | None,
    confirm_filename: str | None,
    interactive: bool,
) -> dict:
    # Layer 1: Preview-Pflicht
    preview = get_document_meta(client, uid)
    if preview is None:
        return {"ok": False, "error": "not_found", "documentUid": uid}

    actual_number = (preview.get("documentNumber") or "").strip()
    actual_filename = (preview.get("filename") or "").strip()

    # Zeig nur Kernfelder des Previews, niemals fileContent oder vollstaendigen Dump
    preview_slim = {
        "documentUid": preview.get("documentUid"),
        "documentNumber": actual_number,
        "filename": actual_filename,
        "documentType": preview.get("documentType"),
        "documentDate": preview.get("documentDate"),
        "companyName": preview.get("companyName"),
        "grossAmount": preview.get("grossAmount"),
        "currency": preview.get("currency"),
        "isLocked": preview.get("isLocked"),
    }

    # Layer 2: Bestätigung Pflicht
    if interactive:
        label = actual_number or actual_filename or "(kein Bezeichner)"
        prompt = (f"\nZu löschen: documentUid={uid}\n"
                  f"  documentNumber: {actual_number!r}\n"
                  f"  filename:       {actual_filename!r}\n"
                  f"  companyName:    {preview_slim['companyName']!r}\n"
                  f"  grossAmount:    {preview_slim['grossAmount']!r} {preview_slim['currency'] or ''}\n"
                  f"Zur Bestätigung bitte documentNumber ODER filename EXAKT eintippen "
                  f"(z.B. {label!r}): ")
        try:
            typed = input(prompt)
        except EOFError:
            return {"ok": False, "error": "not_confirmed", "reason": "no_tty_input",
                    "preview": preview_slim}
        typed = typed.strip()
        if typed == "":
            return {"ok": False, "error": "not_confirmed", "preview": preview_slim}
        if typed != actual_number and typed != actual_filename:
            return {"ok": False, "error": "mismatch", "expected_any_of": [actual_number, actual_filename],
                    "got": typed, "preview": preview_slim}
    else:
        if not (confirm_number or confirm_filename):
            return {"ok": False, "error": "missing_confirm",
                    "hint": "Non-interaktiv --confirm UND (--confirm-number ODER --confirm-filename) setzen",
                    "documentUid": uid, "preview": preview_slim}
        if confirm_number and confirm_number != actual_number:
            return {"ok": False, "error": "number_mismatch", "expected": actual_number,
                    "got": confirm_number, "documentUid": uid}
        if confirm_filename and confirm_filename != actual_filename:
            return {"ok": False, "error": "filename_mismatch", "expected": actual_filename,
                    "got": confirm_filename, "documentUid": uid}

    # Execute
    try:
        resp = client.call("deleteDocument", documentUid=uid)
    except GmiHttpError as e:
        return {"ok": False, "error": f"http_{e.status}", "detail": str(e),
                "documentUid": uid}

    ts = _dt.datetime.now().isoformat(timespec="seconds")
    label = actual_number or actual_filename or "?"
    print(f"[DELETE] documents/{uid} \"{label}\" at {ts}", file=sys.stderr)

    return {"ok": True, "action": "delete", "documentUid": uid,
            "documentNumber": actual_number, "filename": actual_filename,
            "response": resp}


# ---------- Formatting ----------

def format_write_md(data: dict) -> str:
    action = data.get("action", "?")
    lines = [f"## {action} — OK", ""]
    if data.get("documentUid"):
        lines.append(f"- **documentUid:** {data['documentUid']}")
    if data.get("fileName"):
        lines.append(f"- **fileName:** {data['fileName']}")
    if data.get("fileSize") is not None:
        lines.append(f"- **fileSize:** {data['fileSize']} bytes")
    if data.get("documentNumber"):
        lines.append(f"- **documentNumber:** {data['documentNumber']}")
    if data.get("filename"):
        lines.append(f"- **filename:** {data['filename']}")
    if data.get("silently_dropped"):
        lines.append(f"- **Warnung:** API hat folgende Felder nicht übernommen: "
                     f"{', '.join(data['silently_dropped'])}")
    resp = data.get("response")
    if resp:
        lines.append("")
        lines.append("```json")
        lines.append(json.dumps(resp, indent=2, ensure_ascii=False))
        lines.append("```")
    return "\n".join(lines)


def format_check_hash_md(data: dict) -> str:
    if not data.get("ok"):
        return format_error_md(data)
    marker = "JA" if data.get("exists") else "NEIN"
    lines = [
        f"## check-hash — existiert: **{marker}**", "",
        f"- **fileName:** {data.get('fileName')}",
        f"- **fileSize:** {data.get('fileSize')} bytes",
        f"- **md5:** `{data.get('md5')}`",
        f"- **exists:** {data.get('exists')}",
    ]
    return "\n".join(lines)


def format_deleted_list_md(data: dict) -> str:
    if not data.get("ok"):
        return format_error_md(data)
    recs = data.get("records", [])
    if not recs:
        return "## Gelöschte Dokumente\n\nKeine Einträge."
    lines = [f"## Gelöschte Dokumente ({data.get('totalCount', len(recs))})", ""]
    lines.append("| documentUid | deletedAt |")
    lines.append("|---:|---|")
    for r in recs:
        lines.append(f"| {r.get('documentUid', '')} | {r.get('deletedAt', '')} |")
    return "\n".join(lines)


def format_error_md(data: dict) -> str:
    return f"## Fehler\n\n```json\n{json.dumps(data, indent=2, ensure_ascii=False, default=str)}\n```"


def _print(data: dict, args: argparse.Namespace) -> None:
    if args.format == "json":
        print(json.dumps(data, indent=2, ensure_ascii=False, default=str))
        return
    if not data.get("ok"):
        print(format_error_md(data))
        return
    # Formatter je nach Action
    action = data.get("action")
    if action == "check-hash":
        print(format_check_hash_md(data))
        return
    if action == "list-deleted":
        print(format_deleted_list_md(data))
        return
    print(format_write_md(data))


# ---------- CLI ----------

def _add_doc_fields(parser: argparse.ArgumentParser, include_archived: bool = False) -> None:
    parser.add_argument("--company", type=int, help="companyId (int)")
    parser.add_argument("--type", choices=DOCUMENT_TYPES, help="documentType")
    parser.add_argument("--number", help="documentNumber")
    parser.add_argument("--date", help="documentDate (YYYY-MM-DD)")
    parser.add_argument("--due-date", dest="due_date", help="documentDueDate (YYYY-MM-DD)")
    parser.add_argument("--order-number", dest="order_number")
    parser.add_argument("--payment-method", dest="payment_method", choices=PAYMENT_METHODS)
    parser.add_argument("--payment-status", dest="payment_status", choices=PAYMENT_STATUSES)
    parser.add_argument("--paid-at", dest="paid_at", help="paidAt (YYYY-MM-DD)")
    parser.add_argument("--net", help="netAmount als String (z.B. 100.00)")
    parser.add_argument("--gross", help="grossAmount als String (z.B. 119.00)")
    parser.add_argument("--currency", help="ISO-Code (z.B. EUR)")
    parser.add_argument("--vat", help="VAT als String (z.B. 19.00)")
    parser.add_argument("--tags", help="Kommagetrennte Tag-Liste")
    parser.add_argument("--note", help="Freitext-Notiz")
    if include_archived:
        parser.add_argument("--archived", action="store_true", help="isArchived=true setzen")


def main() -> int:
    common = argparse.ArgumentParser(add_help=False)
    common.add_argument("--format", choices=["json", "markdown"], default="markdown")

    p = argparse.ArgumentParser(parents=[common], description="Document-Writes (upload/update/delete)")
    sub = p.add_subparsers(dest="cmd", required=True)

    up = sub.add_parser("upload", parents=[common], help="Rechnung hochladen (JSON + base64)")
    up.add_argument("--file", required=True, help="Pfad zur Datei (PDF/XML/JPG...)")
    up.add_argument("--ocr", action="store_true", help="runOCR=true setzen")
    up.add_argument("--skip-if-exists", dest="skip_if_exists", action="store_true",
                    help="Vor dem Upload per MD5-Hash prüfen, ob Datei schon existiert "
                         "— bei Treffer abbrechen (kein Upload).")
    _add_doc_fields(up, include_archived=False)

    udp = sub.add_parser("update", parents=[common], help="Metadaten einer Rechnung aktualisieren")
    udp.add_argument("--uid", type=int, required=True, help="documentUid")
    _add_doc_fields(udp, include_archived=True)

    dp = sub.add_parser("delete", parents=[common], help="Rechnung löschen (Confirm-Pflicht)")
    dp.add_argument("--uid", type=int, required=True, help="documentUid")
    dp.add_argument("--confirm", action="store_true",
                    help="Non-interaktiv: zusammen mit --confirm-number ODER --confirm-filename")
    dp.add_argument("--confirm-number", dest="confirm_number",
                    help="Exakte documentNumber der Rechnung")
    dp.add_argument("--confirm-filename", dest="confirm_filename",
                    help="Exakter filename der Rechnung")

    chp = sub.add_parser("check-hash", parents=[common],
                         help="Lokal MD5 berechnen und per checkDocumentHash prüfen")
    chp.add_argument("--file", required=True, help="Pfad zur Datei")

    ldp = sub.add_parser("list-deleted", parents=[common],
                         help="Papierkorb-Sicht: gelöschte Dokumente auflisten")
    ldp.add_argument("--since", help="deletedSinceFilter (YYYY-MM-DD)")
    ldp.add_argument("--max-items", dest="max_items", type=int,
                     help="Maximale Anzahl Records (Pagination-Cap)")

    args = p.parse_args()

    try:
        client = GmiClient()
    except Exception as e:
        print(json.dumps({"error": "config", "detail": str(e)}))
        return 1

    try:
        if args.cmd == "upload":
            data = upload_document(client, args)
        elif args.cmd == "update":
            data = update_document(client, args)
        elif args.cmd == "delete":
            interactive = sys.stdin.isatty() and not args.confirm
            data = delete_document(
                client, args.uid,
                confirm_number=args.confirm_number,
                confirm_filename=args.confirm_filename,
                interactive=interactive,
            )
        elif args.cmd == "check-hash":
            data = check_hash(client, Path(args.file))
        elif args.cmd == "list-deleted":
            data = list_deleted(
                client, since=args.since,
                max_items=getattr(args, "max_items", None),
            )
        else:
            print(json.dumps({"error": "unknown_command", "cmd": args.cmd}))
            return 1
    except GmiHttpError as e:
        print(json.dumps({"error": "api", "status": e.status, "detail": str(e)}))
        return 1

    _print(data, args)
    return 0 if data.get("ok") else 1


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