#!/usr/bin/env bash
# Create a customer or supplier invoice (facture) with product/service lines in
# the SANDBOX, optionally validating it — IDEMPOTENT (erp#44): replaying the
# same create returns the existing invoice instead of minting a duplicate.
#
# Input: a JSON object on stdin (or a file path in $1):
#   socid        (required) thirdparty id
#   kind         "customer" | "supplier"        (default "customer")
#   date         "YYYY-MM-DD"                    (default today)
#   ref_supplier supplier's own invoice ref      (supplier invoices)
#   validate     true|false                       (default false = leave draft)
#   lines: [ { desc, qty, price_ht, tva, type: "product"|"service", product_id? } ]
#
# Idempotency (erp#44 — Learning #4 of the 2026-07-11 rehearsal, where a manifest
# that failed mid-run could not be replayed because op 1 would have re-created
# the DARNIS invoice). BEFORE any POST, list the thirdparty's invoices of the
# same kind and dedupe:
#   - supplier kind WITH ref_supplier : by (socid, ref_supplier) — the supplier's
#     own ref is the natural key. A match whose total_ttc DIFFERS from the
#     requested lines ABORTS (same key, different content = a data conflict to
#     resolve by a human, not a dedupe).
#   - customer kind (or supplier without ref_supplier) : by (socid, date,
#     total_ttc ±0.02, line fingerprint) where the fingerprint is the multiset of
#     (desc, qty, subprice, tva_tx) per line — desc HTML-unescaped and
#     whitespace-collapsed, since Dolibarr returns stored descriptions
#     HTML-encoded.
#   - 2+ matches ABORT (ambiguous — the target already has duplicates; never
#     guess). Credit notes (type=2) are never dedupe candidates.
# On a match: if `validate:true` was requested and the match is still a DRAFT
# (statut=0 — e.g. run 1 died between create and validate), it is validated now,
# so a replayed manifest CONVERGES instead of leaving a half-done op behind.
# A listing failure other than 404 ABORTS — POSTing blind would mint duplicates.
#
# Emits {id, ref, ref_supplier, total_ht, total_ttc, statut, deduped} on stdout
# — deduped:true means no invoice was created (the id is the pre-existing one).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
W="${DOL_WRITE:-${SCRIPT_DIR}/dol-write.sh}"

SRC="${1:-}"
if [[ -n "${SRC}" && "${SRC}" != "-" ]]; then INPUT="$(cat "${SRC}")"; else INPUT="$(cat)"; fi

TMPD="$(mktemp -d -t invcre.XXXXXX)"; trap 'rm -rf "${TMPD}"' EXIT
cat > "${TMPD}/map.py" <<'PY'
import json, sys, datetime
d = json.loads(sys.stdin.read())
if not d.get("socid"):
    sys.exit("invoice-create.sh: 'socid' is required")
supplier = d.get("kind", "customer").lower() in ("supplier", "fournisseur")
endpoint = "/supplierinvoices" if supplier else "/invoices"
ds = d.get("date")
dt = datetime.datetime.strptime(ds, "%Y-%m-%d") if ds else datetime.datetime.now()
epoch = int(dt.timestamp())
lines = []
for ln in d.get("lines", []):
    is_product = ln.get("type", "service").lower() in ("product", "produit")
    L = {
        "desc": ln.get("desc", ""),
        "subprice": str(ln.get("price_ht", ln.get("subprice", 0))),
        "qty": str(ln.get("qty", 1)),
        "tva_tx": str(ln.get("tva", ln.get("tva_tx", 20))),
        "product_type": "0" if is_product else "1",
    }
    if ln.get("product_id"):
        L["fk_product"] = str(ln["product_id"])
    lines.append(L)
body = {"socid": d["socid"], "date": epoch, "type": 0, "lines": lines}
if supplier and d.get("ref_supplier"):
    body["ref_supplier"] = d["ref_supplier"]
# Expected total_ttc, Dolibarr-style (per-line rounding), for the dedupe key.
total_ttc = round(sum(round(float(L["qty"]) * float(L["subprice"]), 2)
                      * (1 + float(L["tva_tx"]) / 100.0) for L in lines), 2)
dedupe = {"supplier": supplier, "socid": str(d["socid"]),
          "ref_supplier": str(d.get("ref_supplier") or "") if supplier else "",
          "date": dt.strftime("%Y-%m-%d"), "total_ttc": total_ttc,
          "lines": [[L["desc"], L["qty"], L["subprice"], L["tva_tx"]] for L in lines]}
print(endpoint)
print(json.dumps(body))
print("1" if d.get("validate") else "0")
print(json.dumps(dedupe))
PY

MAPPED="$(printf '%s' "${INPUT}" | python3 "${TMPD}/map.py")"
ENDPOINT="$(sed -n 1p <<<"${MAPPED}")"
BODY="$(sed -n 2p <<<"${MAPPED}")"
VALIDATE="$(sed -n 3p <<<"${MAPPED}")"
DEDUPE="$(sed -n 4p <<<"${MAPPED}")"
printf '%s' "${DEDUPE}" > "${TMPD}/dedupe.json"

# Final read-back: shared by the create and dedupe paths.
emit_summary() {  # $1 = invoice id, $2 = deduped true|false
  "${W}" GET "${ENDPOINT}/$1" | DEDUPED="$2" python3 -c "import json,sys,os
d=json.load(sys.stdin)
o={k:d.get(k) for k in ('id','ref','ref_supplier','total_ht','total_ttc','statut')}
o['deduped']=os.environ['DEDUPED']=='true'
print(json.dumps(o))"
}

# --- Dedupe lookup (erp#44): list this thirdparty's invoices of the same kind ---
SOCID="$(python3 -c "import json,sys; print(json.load(sys.stdin)['socid'])" < "${TMPD}/dedupe.json")"
set +e
"${W}" GET "${ENDPOINT}?thirdparty_ids=${SOCID}&limit=500" \
  > "${TMPD}/list.json" 2> "${TMPD}/list.err"
rc=$?
set -e
if [[ ${rc} -ne 0 ]]; then
  if grep -q "HTTP 404" "${TMPD}/list.err"; then
    printf '[]' > "${TMPD}/list.json"   # empty lists can answer 404 (dolibarr skill gotcha)
  else
    cat "${TMPD}/list.err" >&2
    echo "invoice-create.sh: could not list ${ENDPOINT} for socid ${SOCID} — refusing to POST blind (dedupe impossible)" >&2
    exit 1
  fi
fi

cat > "${TMPD}/match.py" <<'PY'
import json, sys, html, datetime
ded = json.load(open(sys.argv[1]))
try:
    rows = json.load(open(sys.argv[2]))
except Exception:
    rows = []
rows = [r for r in (rows if isinstance(rows, list) else [])
        if str(r.get("type", "0")) != "2"]          # credit notes never dedupe a create

def norm_desc(s):
    return " ".join(html.unescape(str(s or "")).split())
def fnum(v, nd):
    try: return round(float(v), nd)
    except Exception: return None
def fingerprint(lines):
    return sorted((norm_desc(l[0]), fnum(l[1], 3), fnum(l[2], 2), fnum(l[3], 3))
                  for l in lines)

TOL = 0.02
want_fp = fingerprint(ded["lines"])
hits = []
if ded["supplier"] and ded["ref_supplier"]:
    key = ded["ref_supplier"].strip()
    hits = [r for r in rows if str(r.get("ref_supplier") or "").strip() == key]
    if len(hits) == 1 and ded["lines"]:
        got = fnum(hits[0].get("total_ttc"), 2)
        if got is not None and abs(got - ded["total_ttc"]) > TOL:
            sys.exit("invoice-create.sh: ABORT — supplier invoice with ref_supplier "
                     "%r already exists on socid %s (id %s) but its total_ttc %.2f "
                     "differs from the requested %.2f. Same key, different content "
                     "is a conflict to resolve, not a dedupe." %
                     (key, ded["socid"], hits[0].get("id"), got, ded["total_ttc"]))
else:
    for r in rows:
        try:
            rdate = datetime.datetime.fromtimestamp(int(r.get("date"))).strftime("%Y-%m-%d")
        except Exception:
            continue
        if rdate != ded["date"]:
            continue
        got = fnum(r.get("total_ttc"), 2)
        if got is None or abs(got - ded["total_ttc"]) > TOL:
            continue
        rl = r.get("lines")
        if not isinstance(rl, list):
            sys.exit("invoice-create.sh: ABORT — candidate invoice id %s matches "
                     "(date, total) but carries no inline lines to fingerprint; "
                     "refusing to guess" % r.get("id"))
        if fingerprint([[l.get("desc"), l.get("qty"), l.get("subprice"), l.get("tva_tx")]
                        for l in rl]) == want_fp:
            hits.append(r)
if len(hits) > 1:
    sys.exit("invoice-create.sh: ABORT — dedupe key matches %d invoices on socid %s "
             "(ids %s): the target already holds duplicates; refusing to guess."
             % (len(hits), ded["socid"], ", ".join(str(h.get("id")) for h in hits)))
if hits:
    print(json.dumps({"id": int(hits[0]["id"]), "statut": str(hits[0].get("statut"))}))
PY
MATCH="$(python3 "${TMPD}/match.py" "${TMPD}/dedupe.json" "${TMPD}/list.json")"

if [[ -n "${MATCH}" ]]; then
  ID="$(python3 -c "import json,sys; print(json.load(sys.stdin)['id'])" <<<"${MATCH}")"
  STATUT="$(python3 -c "import json,sys; print(json.load(sys.stdin)['statut'])" <<<"${MATCH}")"
  echo "invoice-create.sh: invoice already exists on socid ${SOCID} (id ${ID}) — deduped, no POST" >&2
  # Converge an interrupted run: validate the matched draft if validation was asked.
  if [[ "${VALIDATE}" == "1" && "${STATUT}" == "0" ]]; then
    echo "invoice-create.sh: matched invoice ${ID} is still a draft — validating it now (completes the interrupted op)" >&2
    "${W}" POST "${ENDPOINT}/${ID}/validate" '{}' >/dev/null
  fi
  emit_summary "${ID}" true
  exit 0
fi

# --- Create (no match) ----------------------------------------------------------
# --- Chronology guard (CGI art. 289: numbering must be chronological) --------
# Dolibarr assigns the next number at validation, in creation order — so issuing
# a document dated BEFORE the last one already issued yields a higher number on
# an earlier date, which is a numbering break. This bites whenever two documents
# of the same cycle are issued on different days (a deferred part issued after
# the next fixed part, for instance). Refuse rather than create the break.
#
# CUSTOMER INVOICES ONLY. L'article 289 impose une numérotation chronologique et
# continue aux factures que la société ÉMET. Il ne dit rien de la référence de
# classement que Dolibarr attribue aux factures qu'elle REÇOIT : le numéro qui
# fait foi pour celles-là est celui du fournisseur, porté par ref_supplier, et la
# séquence FAF suit l'ordre d'ENREGISTREMENT — c'est sa construction normale.
#
# Appliquer la garde aux deux registres produisait un faux positif systématique :
# la production porte déjà cinq ruptures dans la séquence FAF (FAF2026003 daté du
# 4 janvier suit FAF2026002 daté du 9), et zéro dans la séquence FAC. Toute
# facture fournisseur enregistrée après coup — le cas ordinaire, cf. adc-008 qui
# prescrit d'enregistrer à la date du document — se heurtait au refus et exigeait
# ARCO_ALLOW_BACKDATE. Une garde qu'on outrepasse par routine ne garde plus rien.
if [[ "${ENDPOINT}" != "/invoices" ]]; then
  CHRONO_SKIP=1
fi
NEW_DATE="$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['date'])" "${BODY}")"
LAST="$("${W}" GET "${ENDPOINT}?sortfield=t.rowid&sortorder=DESC&limit=1" 2>/dev/null \
  | python3 -c "
import json,sys
try:
    d=json.load(sys.stdin)
    if isinstance(d,list) and d: print(f\"{d[0].get('date','0')}|{d[0].get('ref','?')}\")
    else: print('0|-')
except Exception: print('0|-')" 2>/dev/null || echo "0|-")"
LAST_DATE="${LAST%%|*}"; LAST_REF="${LAST##*|}"
if [[ -z "${CHRONO_SKIP:-}" ]] && [[ "${LAST_DATE}" =~ ^[0-9]+$ ]] && (( LAST_DATE > 0 )) && (( NEW_DATE < LAST_DATE )); then
  if [[ "${ARCO_ALLOW_BACKDATE:-}" != "I-UNDERSTAND-THIS-BREAKS-CHRONOLOGY" ]]; then
    printf 'invoice-create.sh: REFUSED — chronology break.\n' >&2
    printf '  new document dated %s, but %s is already issued at %s.\n' \
      "$(date -r "${NEW_DATE}" +%d/%m/%Y 2>/dev/null || echo "${NEW_DATE}")" \
      "${LAST_REF}" "$(date -r "${LAST_DATE}" +%d/%m/%Y 2>/dev/null || echo "${LAST_DATE}")" >&2
    printf '  Numbering follows creation order, so this would give a higher number to an\n' >&2
    printf '  earlier date (CGI art. 289). Issue in chronological order, or set\n' >&2
    printf '  ARCO_ALLOW_BACKDATE=I-UNDERSTAND-THIS-BREAKS-CHRONOLOGY to override.\n' >&2
    exit 1
  fi
  printf 'invoice-create.sh: WARNING — backdating past %s (%s), override accepted.\n' \
    "${LAST_REF}" "$(date -r "${LAST_DATE}" +%d/%m/%Y 2>/dev/null)" >&2
fi

ID="$("${W}" POST "${ENDPOINT}" "${BODY}")"
if [[ ! "${ID}" =~ ^[0-9]+$ ]]; then
  echo "invoice-create.sh: create did not return an id: ${ID}" >&2
  exit 1
fi
if [[ "${VALIDATE}" == "1" ]]; then
  "${W}" POST "${ENDPOINT}/${ID}/validate" '{}' >/dev/null
fi
emit_summary "${ID}" false
