/**
 * Sanitize-before-store boundary for every user / OCR / LLM-provided value
 * that gets persisted (PostgreSQL via the API routes, or handed to the client
 * for local IndexedDB storage / later re-upload).
 *
 * ─────────────────────────────────────────────────────────────────────────────
 * POLICY (documented, deliberate choices)
 * ─────────────────────────────────────────────────────────────────────────────
 * 1. STRING FIELDS
 *    a) Non-string input where a string is required is REJECTED by the zod
 *       schemas (structurally invalid → HTTP 400 at the route).
 *    b) `sanitizeText` (single-line fields): trim, strip control characters
 *       (C0 minus \t\n\r, C1, U+FEFF), strip HTML-ish markup, collapse
 *       whitespace runs to one space, cap length.
 *    c) `sanitizeMultilineText` (OCR text): same, but \n / \r / \t survive so
 *       the layout of scanned text is preserved.
 *    d) MARKUP POLICY — STRIP, not encode: complete tag-like constructs
 *       (<!-- -->, <!CDATA[…]]>, <!DOCTYPE>, <? … ?>, <…>) are removed and any
 *       remaining '<' or '>' is deleted, so a stored value can physically
 *       never re-form a tag. Encoding (&lt;) was rejected because any
 *       downstream HTML decoder would reconstruct the markup from the stored
 *       value; stripping makes the attack structurally impossible. Acceptable
 *       data loss is limited to characters that are inert on receipts anyway
 *       ("a < b" becomes "a b").
 *    e) Length caps follow the DB column widths (see SANITIZE_LIMITS) so a
 *       stored value can never overflow a varchar and fail the insert.
 * 2. NUMBER FIELDS
 *    a) Non-number and non-finite (NaN / ±Infinity) input is REJECTED (400).
 *    b) Finite values are clamped: amounts to ±1e10 (fits the DB column
 *       numeric(12,2) — the 1e12 bound suggested in the task brief would
 *       overflow that column), quantities to [0, 1e6], percents to [0, 100].
 *    c) Amounts MAY be negative: credit notes (Gutschriften / Stornos / refunds)
 *       are a first-class domain case — the CSV/Excel/PDF generators format
 *       negative amounts explicitly (red in Excel, "-" prefix in CSV/PDF), and
 *       line-item prices are negative for discounts. Clamping them to >= 0
 *       would silently corrupt accounting data.
 * 3. ENUM-LIKE FIELDS
 *    documentType, suggestedCategory and paymentMethod are sanitized FREE TEXT
 *    (not enum-restricted): the dashboard's inline editor (LiveTable.tsx)
 *    writes arbitrary user strings into these fields, so a strict enum would
 *    400 legitimate edits. The canonical enums live in src/lib/schema/receipt.ts
 *    and are enforced at the LLM-extraction layer (ReceiptExtractionSchema).
 *    currency IS strict: a 3-letter ISO-4217 code after sanitize, else 400.
 * 4. ERROR HANDLING
 *    Structurally invalid payload → 400 with a clear message (zod).
 *    Valid-but-dirty strings → sanitized before insert. See
 *    `sanitizeReceiptBatch`, which mirrors the POST /api/receipts body shapes
 *    (array | { receipts } | { receipt } | single receipt) and the existing
 *    "no receipts" / ">500 receipts" 400 responses.
 * ─────────────────────────────────────────────────────────────────────────────
 * OUT OF SCOPE / NOT A SUBSTITUTE
 * - React escapes by default and there is no dangerouslySetInnerHTML in the
 *   app; this module hardens the storage boundary for data that later renders
 *   or gets exported. It is NOT a replacement for output encoding.
 * - The codebase uses drizzle parameterized queries throughout; this module
 *   must never be seen as a license to concatenate SQL.
 * - CSV/Excel formula injection is handled at the export boundary
 *   (src/lib/export/csvGenerator.ts → neutralizeFormulaPrefix). This module
 *   only guarantees markup/control-char/length safety of stored strings.
 */

import { z } from "zod";

// ---------------------------------------------------------------------------
// Limits — aligned with src/lib/schema/db.ts column widths
// ---------------------------------------------------------------------------

export const SANITIZE_LIMITS = {
  merchantName: 200, // DB varchar(255)
  merchantAddress: 500, // persisted in extraction_json
  taxId: 100, // persisted in extraction_json
  hospitalityText: 200,
  receiptNumber: 100, // DB varchar(128)
  category: 64, // DB varchar(64) — task brief said 100, DB column caps at 64
  currency: 8, // DB varchar(8)
  documentType: 64, // DB varchar(64)
  paymentMethod: 64, // DB varchar(64)
  rawOcrText: 50_000, // DB text — task brief cap
  isoDate: 32, // DB varchar(32) — keeps legacy formats like "15.08.2026"
  time: 8,
  lineItemDescription: 500,
  lineItems: 500,
  taxBreakdownItems: 50,
  validationMessage: 500,
  reviewReason: 500,
  reviewField: 50,
  issues: 100,
  id: 64, // DB varchar(64)
  imageHash: 128, // DB varchar(128) — SHA-256 hex plus `_p{n}` for PDF pages
  fileName: 255,
  // previewUrl holds a full `data:image/...;base64,...` URI (see
  // src/lib/image/processor.ts), not a short external link — it's stored in
  // a Postgres `text` column (unbounded). Cap generously (~3 MB binary) just
  // to bound pathological input; a short cap here truncates the base64
  // payload mid-stream and silently corrupts every receipt image.
  previewUrl: 4_000_000,
  status: 20,
  // numeric bounds
  maxAmountAbs: 1e10, // fits numeric(12,2): max 9_999_999_999.99
  maxQuantityAbs: 1e6,
  maxPercent: 100,
} as const;

// C0 minus \t\n\r (those survive), plus C1 and the zero-width no-break space.
const CONTROL_CHAR_PATTERN = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F\uFEFF]/g;

// ---------------------------------------------------------------------------
// String primitives
// ---------------------------------------------------------------------------

/**
 * Removes dangerous control characters. \t, \n and \r survive (OCR text keeps
 * its line layout; single-line fields collapse them later).
 */
export function stripControlChars(value: string): string {
  return value.replace(CONTROL_CHAR_PATTERN, "");
}

/**
 * Removes HTML-ish markup so the result can never form a tag: comments,
 * CDATA, doctype, processing instructions, any <...> construct, and finally
 * any remaining '<' / '>' (malformed / partial markup). See POLICY above.
 */
export function stripMarkup(value: string): string {
  return value
    .replace(/<!--[\s\S]*?-->/g, "")
    .replace(/<!\[CDATA\[[\s\S]*?\]\]>/gi, "")
    .replace(/<!DOCTYPE[^>]*>/gi, "")
    .replace(/<\?[\s\S]*?\?>/g, "")
    .replace(/<[^>]*>/g, "")
    .replace(/[<>]/g, "");
}

/**
 * Single-line text sanitizer: control chars out, markup out, whitespace runs
 * collapsed, trimmed, capped at `limit`. Non-string input → "".
 */
export function sanitizeText(value: unknown, limit: number): string {
  if (typeof value !== "string") return "";
  return stripMarkup(stripControlChars(value))
    .replace(/\s+/g, " ")
    .trim()
    .slice(0, limit);
}

/**
 * Multiline (OCR) text sanitizer: keeps \n / \r / \t so the scan layout is
 * preserved, strips control chars and markup, trimmed, capped at `limit`.
 */
export function sanitizeMultilineText(value: unknown, limit: number): string {
  if (typeof value !== "string") return "";
  return stripMarkup(stripControlChars(value)).trim().slice(0, limit);
}

/** URL-ish field: sanitized, capped, and dangerous schemes dropped. */
export function sanitizeUrl(value: unknown): string | undefined {
  if (typeof value !== "string") return undefined;
  const cleaned = stripMarkup(stripControlChars(value)).trim();
  if (cleaned.length === 0) return undefined;
  const capped = cleaned.slice(0, SANITIZE_LIMITS.previewUrl);
  if (/^(javascript|vbscript|data:text\/html)/i.test(capped)) return undefined;
  return capped;
}

// ---------------------------------------------------------------------------
// Number primitives
// ---------------------------------------------------------------------------

/** Finite guard + clamp to ±SANITIZE_LIMITS.maxAmountAbs. */
export function clampAmount(value: number): number {
  if (!Number.isFinite(value)) return 0;
  return Math.min(SANITIZE_LIMITS.maxAmountAbs, Math.max(-SANITIZE_LIMITS.maxAmountAbs, value));
}

/** Clamp to [0, 100] — tax rates / percents. */
export function clampPercent(value: number): number {
  if (!Number.isFinite(value)) return 0;
  return Math.min(SANITIZE_LIMITS.maxPercent, Math.max(0, value));
}

/** Clamp to [0, 1e6] — line item quantities. */
export function clampQuantity(value: number): number {
  if (!Number.isFinite(value)) return 0;
  return Math.min(SANITIZE_LIMITS.maxQuantityAbs, Math.max(0, value));
}

/** Normalize a currency value: trim / upper / clean; empty → "EUR". */
export function sanitizeCurrency(value: unknown): string {
  if (typeof value !== "string") return "EUR";
  const cleaned = sanitizeText(value, SANITIZE_LIMITS.currency).toUpperCase();
  return cleaned === "" ? "EUR" : cleaned;
}

// ---------------------------------------------------------------------------
// Zod schema helpers
// ---------------------------------------------------------------------------

/** Free-text field: sanitized (markup/control/length), NOT truncated silently for identifiers. */
function textField(limit: number): z.ZodEffects<z.ZodString, string, string> {
  return z.string().transform((v) => sanitizeText(v, limit));
}

/** Nullable free-text field: null/undefined stay null, empty → null. */
function nullableTextField(limit: number): z.ZodEffects<z.ZodOptional<z.ZodNullable<z.ZodString>>, string | null, string | null | undefined> {
  return z
    .string()
    .nullish()
    .transform((v) => (v == null ? null : (sanitizeText(v, limit) || null)));
}

/**
 * Identifier-ish field (id, imageHash): sanitized but NEVER silently truncated —
 * an oversized value is rejected, because mutating a primary key / dedup hash
 * would silently corrupt references. Empty → undefined (caller may fall back).
 */
function identifierField(limit: number, name: string) {
  return z
    .string()
    .nullish()
    .transform((v) => {
      if (v == null) return undefined;
      const s = sanitizeText(v, limit + 1);
      return s === "" ? undefined : s;
    })
    .refine((v) => v === undefined || v.length <= limit, {
      message: `${name} must be at most ${limit} characters`,
    });
}

/** Finite number clamped to [min, max]; NaN/Infinity/non-number → parse error (400). */
function clampedNumber(min: number, max: number) {
  return z.number().finite().transform((v) => Math.min(max, Math.max(min, v)));
}

/** Amount field: finite + clamped (±maxAmountAbs), nullable/optional friendly. */
const amountNumber = z.number().finite().transform(clampAmount);

/** ISO timestamp field: keeps parseable dates, normalizes to ISO; else undefined. */
const timestampField = z
  .union([z.string(), z.number()])
  .nullish()
  .transform((v) => {
    if (v == null) return undefined;
    const d = new Date(typeof v === "number" ? v : v.trim());
    return Number.isNaN(d.getTime()) ? undefined : d.toISOString();
  });

// ---------------------------------------------------------------------------
// Stored-receipt schemas (validation + sanitization, in one pass)
// ---------------------------------------------------------------------------

export const StoredMerchantSchema = z.object({
  name: textField(SANITIZE_LIMITS.merchantName),
  address: nullableTextField(SANITIZE_LIMITS.merchantAddress).optional(),
  taxId: nullableTextField(SANITIZE_LIMITS.taxId).optional(),
  confidence: clampedNumber(0, 1),
});

export const StoredDateSchema = z.object({
  // Legacy IndexedDB rows can carry German "15.08.2026" dates — those are kept
  // (sanitized) instead of rejected; strict ISO enforcement happens at the
  // extraction layer. Cap matches the DB varchar(32).
  isoDate: textField(SANITIZE_LIMITS.isoDate),
  time: nullableTextField(SANITIZE_LIMITS.time).optional(),
  confidence: clampedNumber(0, 1),
});

export const StoredTaxBreakdownItemSchema = z.object({
  ratePercent: z.number().finite().transform(clampPercent),
  taxAmount: z.number().finite().transform(clampAmount),
  netAmount: z.number().finite().transform(clampAmount).nullable(),
});

export const StoredLineItemSchema = z.object({
  description: textField(SANITIZE_LIMITS.lineItemDescription),
  quantity: z.number().finite().transform(clampQuantity),
  price: z.number().finite().transform(clampAmount),
  unitPrice: amountNumber.nullable().optional(),
  taxRate: z.number().finite().transform(clampPercent).nullable(),
  confidence: clampedNumber(0, 1).optional(),
  sourceView: z.number().int().min(1).max(1000).nullable().optional(),
  rowOrder: z.number().int().min(1).max(1000).nullable().optional(),
});

export const StoredValidationIssueSchema = z.object({
  field: z.string().transform((v) => sanitizeText(v, SANITIZE_LIMITS.reviewField)),
  severity: z.enum(["error", "warning"]),
  message: textField(SANITIZE_LIMITS.validationMessage),
});

export const StoredValidationSchema = z
  .object({
    isMathValid: z.boolean(),
    isDuplicateSuspected: z.boolean(),
    needsUserReview: z.boolean(),
    reviewField: z.string().transform((v) => sanitizeText(v, SANITIZE_LIMITS.reviewField)),
    reviewReason: nullableTextField(SANITIZE_LIMITS.reviewReason),
    issues: z
      .array(StoredValidationIssueSchema)
      .nullish()
      .transform((v) => (v == null ? undefined : v.slice(0, SANITIZE_LIMITS.issues))),
    userConfirmed: z.boolean().nullish(),
  })
  .nullish();

export const StoredCurrencySchema = z
  .string()
  .nullish()
  .transform((v) => (v == null ? "EUR" : sanitizeCurrency(v)))
  .refine((v) => /^[A-Z]{3}$/.test(v), {
    message: "currency must be a 3-letter ISO 4217 code (e.g. EUR, USD, CHF)",
  });

/**
 * The stored-receipt shape: validates structure (→ 400 on failure) and
 * sanitizes every string / number (→ cleaned before insert). `.passthrough()`
 * keeps client-only extras (boundingBoxes, editedFields, originalExtraction)
 * untouched; hospitality is a first-class stored field.
 */
export const StoredReceiptSchema = z
  .object({
    id: z
      .string()
      .transform((v) => sanitizeText(v, 256))
      .refine((v) => v.length > 0 && v.length <= SANITIZE_LIMITS.id, {
        message: "receipt id must be a non-empty string of at most 64 characters",
      }),
    imageHash: identifierField(SANITIZE_LIMITS.imageHash, "imageHash"),
    /** Optional Pro-only project (folder) to file this receipt under at creation time. */
    projectId: identifierField(SANITIZE_LIMITS.id, "projectId"),
    merchant: StoredMerchantSchema,
    date: StoredDateSchema,
    documentType: z
      .string()
      .nullish()
      .transform((v) => (v == null ? undefined : (sanitizeText(v, SANITIZE_LIMITS.documentType) || undefined))),
    receiptNumber: nullableTextField(SANITIZE_LIMITS.receiptNumber),
    currency: StoredCurrencySchema,
    totalAmount: z.object({
      value: z.number().finite().transform(clampAmount),
      confidence: clampedNumber(0, 1),
    }),
    netAmount: amountNumber.nullable().optional(),
    tipAmount: amountNumber.nullable().optional(),
    taxBreakdown: z
      .array(StoredTaxBreakdownItemSchema)
      .default([])
      .transform((arr) => arr.slice(0, SANITIZE_LIMITS.taxBreakdownItems)),
    lineItems: z
      .array(StoredLineItemSchema)
      .default([])
      .transform((arr) => arr.slice(0, SANITIZE_LIMITS.lineItems)),
    suggestedCategory: z
      .string()
      .nullish()
      .transform((v) => (v == null ? undefined : (sanitizeText(v, SANITIZE_LIMITS.category) || undefined))),
    hospitality: z
      .object({
        occasion: nullableTextField(SANITIZE_LIMITS.hospitalityText),
        participants: nullableTextField(SANITIZE_LIMITS.hospitalityText),
      })
      .nullable()
      .optional(),
    validation: StoredValidationSchema,
    paymentMethod: nullableTextField(SANITIZE_LIMITS.paymentMethod),
    rawText: z
      .string()
      .nullish()
      .transform((v) => (v == null ? undefined : (sanitizeMultilineText(v, SANITIZE_LIMITS.rawOcrText) || undefined))),
    previewUrl: z.string().nullish().transform((v) => (v == null ? undefined : sanitizeUrl(v))),
    originalFileName: z
      .string()
      .nullish()
      .transform((v) => (v == null ? undefined : (sanitizeText(v, SANITIZE_LIMITS.fileName) || undefined))),
    fileSizeBytes: z
      .number()
      .finite()
      .nullish()
      .transform((v) => (v == null ? undefined : Math.max(0, Math.min(1e12, Math.round(v))))),
    status: z
      .string()
      .nullish()
      .transform((v) => (v == null ? undefined : (sanitizeText(v, SANITIZE_LIMITS.status) || undefined))),
    createdAt: timestampField,
    updatedAt: timestampField,
  })
  .passthrough();

export type StoredReceipt = z.infer<typeof StoredReceiptSchema>;
export type StoredValidation = z.infer<typeof StoredValidationSchema>;
export type StoredMerchant = z.infer<typeof StoredMerchantSchema>;
export type StoredDate = z.infer<typeof StoredDateSchema>;
export type StoredTaxBreakdownItem = z.infer<typeof StoredTaxBreakdownItemSchema>;
export type StoredLineItem = z.infer<typeof StoredLineItemSchema>;

// ---------------------------------------------------------------------------
// Batch helpers (mirrors POST /api/receipts body handling)
// ---------------------------------------------------------------------------

export interface ReceiptBatchResult {
  ok: boolean;
  receipts?: StoredReceipt[];
  error?: string;
}

/** Human-readable first issues of a ZodError, e.g. "receipts[2].merchant.name: …". */
export function formatZodIssues(error: z.ZodError): string {
  return error.issues
    .slice(0, 5)
    .map((issue) => `${issue.path.length ? issue.path.join(".") : "receipt"}: ${issue.message}`)
    .join("; ");
}

/**
 * Validates + sanitizes a full POST /api/receipts payload. Accepts the same
 * body shapes the route historically accepted (array, { receipts }, { receipt },
 * single receipt) and preserves its "no receipts" / "too many receipts" 400s.
 * One invalid receipt rejects the whole batch (no silent data loss).
 */
export function sanitizeReceiptBatch(payload: unknown, maxBatchSize = 500): ReceiptBatchResult {
  let list: unknown[];
  if (Array.isArray(payload)) {
    list = payload;
  } else if (payload && typeof payload === "object" && Array.isArray((payload as { receipts?: unknown }).receipts)) {
    list = (payload as { receipts: unknown[] }).receipts;
  } else if (payload && typeof payload === "object" && (payload as { receipt?: unknown }).receipt) {
    list = [(payload as { receipt: unknown }).receipt];
  } else if (payload && typeof payload === "object" && (payload as { id?: unknown }).id) {
    list = [payload];
  } else {
    return { ok: false, error: "No receipts provided in payload." };
  }

  if (list.length === 0) return { ok: false, error: "No receipts provided in payload." };
  if (list.length > maxBatchSize) return { ok: false, error: "Too many receipts in payload." };

  const receipts: StoredReceipt[] = [];
  for (let i = 0; i < list.length; i++) {
    const parsed = StoredReceiptSchema.safeParse(list[i]);
    if (!parsed.success) {
      return {
        ok: false,
        error: `Invalid receipt payload at index ${i}: ${formatZodIssues(parsed.error)}`,
      };
    }
    receipts.push(parsed.data);
  }
  return { ok: true, receipts };
}

/** Single-receipt variant for routes that produce (not yet persist) receipts. */
export function sanitizeReceipt(input: unknown): StoredReceipt | null {
  const parsed = StoredReceiptSchema.safeParse(input);
  return parsed.success ? parsed.data : null;
}
