import { Prisma, type $Enums } from "@prisma/client";
import prisma from "../config/database";
import { utcMidnightOfLocalDay } from "../utils/date";
import {
  generateIssuedOrderNumber,
  previewIssuedOrderNumber,
  releaseIssuedOrderNumber,
  assignIssuedOrderNumber,
  isIssuedOrderNumberTaken,
} from "./numbering.service";
import { nasOrdersManager } from "./nas-financials-manager";

export interface IssuedOrderItemInput {
  description?: string | null;
  item_description?: string | null;
  quantity?: number | string | null;
  unit?: string | null;
  unit_price?: number | string | null;
  position?: number | null;
}

export interface IssuedOrderSectionInput {
  title?: string | null;
  title_cz?: string | null;
  content?: string | null;
  position?: number | null;
}

export interface IssuedOrderInput {
  po_number?: string | number | null;
  supplier_id?: number | string | null;
  status?: string;
  currency?: string;
  exchange_rate?: number | string | null;
  order_date?: string | null;
  delivery_date?: string | null;
  language?: string;
  order_text?: string | null;
  internal_notes?: string | null;
  items?: IssuedOrderItemInput[];
  sections?: IssuedOrderSectionInput[];
  [key: string]: unknown;
}

interface IssuedOrderFilterParams {
  search?: string;
  status?: string;
  supplier_id?: number;
  month?: number;
  year?: number;
}

interface ListIssuedOrdersParams extends IssuedOrderFilterParams {
  page: number;
  limit: number;
  skip: number;
  sort: string;
  order: string;
}

export interface CurrencyAmount {
  amount: number;
  currency: string;
}

/**
 * Shared `where` builder for the issued-order list and the per-currency stats
 * aggregation, so both stay in sync. month/year filter on order_date (matching
 * the list).
 */
function buildIssuedOrderWhere(
  params: IssuedOrderFilterParams,
): Record<string, unknown> {
  const { search, status, supplier_id, month, year } = params;
  const where: Record<string, unknown> = {};
  if (status) where.status = status;
  if (supplier_id) where.supplier_id = supplier_id;
  if (search) {
    where.OR = [
      { po_number: { contains: search } },
      { suppliers: { name: { contains: search } } },
      { suppliers: { ico: { contains: search } } },
    ];
  }
  if (month && year) {
    // order_date is @db.Date: Prisma compares by UTC date part, so the month
    // boundaries must be UTC midnights. Local midnights shifted the window a
    // day back (included prev-month's last day, dropped this month's last day).
    const from = new Date(Date.UTC(year, month - 1, 1));
    const to = new Date(Date.UTC(year, month, 1));
    where.order_date = { gte: from, lt: to };
  }
  return where;
}

const VALID_TRANSITIONS: Record<string, string[]> = {
  draft: ["sent", "cancelled"],
  sent: ["confirmed", "cancelled"],
  confirmed: ["completed", "cancelled"],
  completed: [],
  cancelled: [],
};

const ALLOWED_SORT_FIELDS = [
  "id",
  "po_number",
  "status",
  "order_date",
  "created_at",
  "currency",
];

/**
 * Non-status update fields — used by the editable-state guard so a field edit
 * on a confirmed/completed/cancelled order is rejected EXPLICITLY (it used to
 * be silently dropped). `po_number` is stripped by the schema and the service
 * never reads it on update.
 */
const NON_STATUS_UPDATE_FIELDS = [
  "supplier_id",
  "currency",
  "exchange_rate",
  "order_date",
  "delivery_date",
  "language",
  "order_text",
  "internal_notes",
  "items",
  "sections",
] as const;

/**
 * NET-only total: issued orders (PO) are not tax documents — VAT never appears
 * on them. The total is the plain sum of qty × unit_price, rounded to 2dp.
 */
export function computeIssuedOrderTotals(
  items: Array<{ quantity: unknown; unit_price: unknown }>,
) {
  let total = 0;
  for (const it of items) {
    total += (Number(it.quantity) || 0) * (Number(it.unit_price) || 0);
  }
  return { total: Math.round(total * 100) / 100 };
}

export async function listIssuedOrders(params: ListIssuedOrdersParams) {
  const { page, limit, skip, sort, order } = params;
  const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "id";

  const where = buildIssuedOrderWhere(params);

  // Normalize the direction so an unexpected value can't reach Prisma at runtime.
  const dir: "asc" | "desc" = order === "asc" ? "asc" : "desc";
  // Tiebreak on id so same-day order_date rows sort deterministically.
  const orderBy: Record<string, "asc" | "desc">[] = [
    { [sortField]: dir },
    { id: dir },
  ];

  const [rows, total] = await Promise.all([
    prisma.issued_orders.findMany({
      where,
      skip,
      take: limit,
      orderBy,
      include: {
        suppliers: { select: { id: true, name: true } },
        issued_order_items: true,
      },
    }),
    prisma.issued_orders.count({ where }),
  ]);

  const enriched = rows.map((o) => {
    const totals = computeIssuedOrderTotals(o.issued_order_items);
    const { issued_order_items, ...rest } = o;
    return {
      ...rest,
      items: issued_order_items,
      supplier_name: o.suppliers?.name || null,
      ...totals,
    };
  });

  return { data: enriched, total, page, limit };
}

/**
 * Sum issued-order NET total per currency across the WHOLE filtered set
 * (not a single page). Reuses `buildIssuedOrderWhere` so filters track the list,
 * and `computeIssuedOrderTotals` so per-order math matches the list/detail.
 * Currency defaults to "CZK" when the column is null. Returns one entry per
 * currency, rounded to 2dp, zero/empty totals dropped.
 */
export async function getIssuedOrderTotals(
  params: IssuedOrderFilterParams,
): Promise<{ totals: CurrencyAmount[] }> {
  const where = buildIssuedOrderWhere(params);

  const rows = await prisma.issued_orders.findMany({
    where,
    include: { issued_order_items: true },
  });

  const byCurrency: Record<string, number> = {};
  for (const o of rows) {
    const { total } = computeIssuedOrderTotals(o.issued_order_items);
    const cur = o.currency || "CZK";
    byCurrency[cur] = (byCurrency[cur] || 0) + (Number(total) || 0);
  }

  const totals: CurrencyAmount[] = Object.entries(byCurrency)
    .map(([currency, amount]) => ({
      currency,
      amount: Math.round(amount * 100) / 100,
    }))
    .filter((t) => t.amount > 0);

  return { totals };
}

export async function getIssuedOrder(id: number) {
  const order = await prisma.issued_orders.findUnique({
    where: { id },
    include: {
      // Same minimal field set as the picker lookup endpoint — the full row
      // would expose internal notes/contact to any orders.view holder.
      suppliers: {
        select: {
          id: true,
          name: true,
          ico: true,
          dic: true,
          street: true,
          city: true,
          postal_code: true,
          country: true,
        },
      },
      issued_order_items: { orderBy: { position: "asc" } },
      // id tiebreak: position can repeat, keep the order deterministic.
      issued_order_sections: {
        orderBy: [{ position: "asc" }, { id: "asc" }],
      },
    },
  });
  if (!order) return null;
  const { issued_order_items, issued_order_sections, ...rest } = order;
  return {
    ...rest,
    items: issued_order_items,
    sections: issued_order_sections,
    supplier: order.suppliers,
    supplier_name: order.suppliers?.name || null,
    valid_transitions: VALID_TRANSITIONS[order.status as string] || [],
  };
}

export async function createIssuedOrder(body: IssuedOrderInput) {
  try {
    return await prisma.$transaction(async (tx) => {
      const status = body.status ? String(body.status) : "draft";

      // Validate the referenced supplier exists BEFORE the insert — a dangling
      // FK would otherwise surface as a P2003 500 instead of a clean 400.
      const supplierId = body.supplier_id ? Number(body.supplier_id) : null;
      if (supplierId) {
        const supplier = await tx.sklad_suppliers.findUnique({
          where: { id: supplierId },
          select: { id: true },
        });
        if (!supplier) return { error: "supplier_not_found" as const };
      }

      // Deferred numbering: a draft carries NO po_number (the column is
      // nullable-unique so many drafts coexist). The official number is consumed
      // only when the draft is finalized (assignIssuedOrderNumber on draft->sent).
      // A caller-supplied number, or a doc created already-finalized, numbers now.
      const explicit =
        body.po_number !== undefined &&
        body.po_number !== null &&
        body.po_number !== ""
          ? String(body.po_number)
          : null;

      // Re-check caller-supplied uniqueness INSIDE the transaction so the
      // check-then-insert window can't race a concurrent create (mirrors
      // createOffer). generateIssuedOrderNumber verifies its own numbers,
      // so this guards only the explicit path.
      if (explicit !== null && (await isIssuedOrderNumberTaken(explicit, tx))) {
        return { error: "po_number_taken" as const };
      }

      const poNumber =
        explicit ??
        (status === "draft"
          ? null
          : (await generateIssuedOrderNumber(undefined, tx)).number);

      const order = await tx.issued_orders.create({
        data: {
          po_number: poNumber,
          supplier_id: supplierId,
          status: status as $Enums.issued_orders_status,
          currency: body.currency ? String(body.currency) : "CZK",
          exchange_rate:
            body.exchange_rate != null ? Number(body.exchange_rate) : 1.0,
          // order_date is @db.Date (truncated to the UTC date part) — the
          // "today" default must be utcMidnightOfLocalDay, or it stores
          // yesterday during the 00:00–02:00 Prague window.
          order_date: body.order_date
            ? new Date(String(body.order_date))
            : utcMidnightOfLocalDay(),
          delivery_date: body.delivery_date
            ? new Date(String(body.delivery_date))
            : null,
          language: body.language ? String(body.language) : "cs",
          order_text: body.order_text ? String(body.order_text) : null,
          internal_notes: body.internal_notes
            ? String(body.internal_notes)
            : null,
        },
      });

      if (Array.isArray(body.items)) {
        await tx.issued_order_items.createMany({
          data: body.items.map((item, i) => ({
            issued_order_id: order.id,
            description: item.description ?? null,
            item_description: item.item_description ?? null,
            quantity: item.quantity ?? 1,
            unit: item.unit ?? null,
            unit_price: item.unit_price ?? 0,
            position: item.position ?? i,
          })),
        });
      }

      if (Array.isArray(body.sections)) {
        await tx.issued_order_sections.createMany({
          data: body.sections.map((s, i) => ({
            issued_order_id: order.id,
            title: s.title ?? null,
            title_cz: s.title_cz ?? null,
            content: s.content ?? null,
            position: s.position ?? i,
          })),
        });
      }

      return order;
    });
  } catch (err) {
    // P2002 on the po_number unique index = a concurrent insert won the race
    // between the in-tx check and our insert — surface the same clean
    // conflict token instead of a 500 (mirrors offers).
    if (
      err instanceof Prisma.PrismaClientKnownRequestError &&
      err.code === "P2002"
    ) {
      return { error: "po_number_taken" as const };
    }
    throw err;
  }
}

export async function updateIssuedOrder(id: number, body: IssuedOrderInput) {
  const existing = await prisma.issued_orders.findUnique({ where: { id } });
  if (!existing) return { error: "not_found" as const };

  const currentStatus = existing.status as string;

  if (body.status !== undefined && body.status !== currentStatus) {
    const newStatus = String(body.status);
    const allowed = VALID_TRANSITIONS[currentStatus] || [];
    if (!allowed.includes(newStatus)) {
      return { error: "invalid_transition" as const, currentStatus, newStatus };
    }
  }

  // Editable-state guard: non-status fields may change only in draft/sent.
  // In confirmed/completed/cancelled a field edit is REJECTED explicitly
  // (offers parity — it used to be silently ignored). Status-only transition
  // payloads (e.g. confirmed -> completed) still work.
  const editable = currentStatus === "draft" || currentStatus === "sent";
  if (
    !editable &&
    NON_STATUS_UPDATE_FIELDS.some((f) => body[f] !== undefined)
  ) {
    return { error: "not_editable" as const, currentStatus };
  }

  const data: Record<string, unknown> = { modified_at: new Date() };

  if (editable) {
    const strFields = ["currency", "language", "order_text"];
    for (const f of strFields) {
      if (body[f] !== undefined) data[f] = body[f] ? String(body[f]) : null;
    }
    if (body.supplier_id !== undefined) {
      const supplierId = body.supplier_id ? Number(body.supplier_id) : null;
      if (supplierId) {
        // Same dangling-FK guard as create: 400, not a P2003 500.
        const supplier = await prisma.sklad_suppliers.findUnique({
          where: { id: supplierId },
          select: { id: true },
        });
        if (!supplier) return { error: "supplier_not_found" as const };
      }
      data.supplier_id = supplierId;
    }
    if (body.exchange_rate !== undefined)
      data.exchange_rate =
        body.exchange_rate != null ? Number(body.exchange_rate) : null;
    if (body.order_date !== undefined)
      data.order_date = body.order_date
        ? new Date(String(body.order_date))
        : null;
    if (body.delivery_date !== undefined)
      data.delivery_date = body.delivery_date
        ? new Date(String(body.delivery_date))
        : null;
    if (body.internal_notes !== undefined)
      data.internal_notes = body.internal_notes
        ? String(body.internal_notes)
        : null;
  }

  if (body.status !== undefined) data.status = String(body.status);

  // Finalize transition draft -> sent: consume + write the official number
  // ATOMICALLY with the status change, so there is never a window where the
  // order is "sent" but still has a null po_number. assignIssuedOrderNumber is
  // idempotent (won't re-number an order that already has a number).
  const finalizing =
    currentStatus === "draft" &&
    body.status !== undefined &&
    String(body.status) === "sent";

  // ONE transaction for the header write, the finalize numbering AND the
  // items/sections full-replace. The items replace used to run in a SECOND
  // transaction after the header tx — a failure between the two left a torn
  // write (new header, old items). Now either everything lands or nothing.
  const replaceItems = editable && Array.isArray(body.items);
  const replaceSections = editable && Array.isArray(body.sections);

  let assignedNumber: string | null = null;
  if (finalizing || replaceItems || replaceSections) {
    await prisma.$transaction(async (tx) => {
      await tx.issued_orders.update({ where: { id }, data });
      if (finalizing) assignedNumber = await assignIssuedOrderNumber(id, tx);
      if (replaceItems) {
        await tx.issued_order_items.deleteMany({
          where: { issued_order_id: id },
        });
        await tx.issued_order_items.createMany({
          data: body.items!.map((item, i) => ({
            issued_order_id: id,
            description: item.description ?? null,
            item_description: item.item_description ?? null,
            quantity: item.quantity ?? 1,
            unit: item.unit ?? null,
            unit_price: item.unit_price ?? 0,
            position: item.position ?? i,
          })),
        });
      }
      if (replaceSections) {
        await tx.issued_order_sections.deleteMany({
          where: { issued_order_id: id },
        });
        await tx.issued_order_sections.createMany({
          data: body.sections!.map((s, i) => ({
            issued_order_id: id,
            title: s.title ?? null,
            title_cz: s.title_cz ?? null,
            content: s.content ?? null,
            position: s.position ?? i,
          })),
        });
      }
    });
  } else {
    await prisma.issued_orders.update({ where: { id }, data });
  }

  // newValues for the audit diff: the header fields actually written plus the
  // replaced collections (JSON.stringify drops the undefined = untouched keys).
  const newValues: Record<string, unknown> = { ...data };
  if (replaceItems) newValues.items = body.items;
  if (replaceSections) newValues.sections = body.sections;

  // Reflect the number the finalize transition just assigned (existing was read
  // before the assign, so its po_number is still the pre-finalize null).
  return {
    id,
    po_number: assignedNumber ?? existing.po_number,
    oldValues: existing as unknown as Record<string, unknown>,
    newValues,
  };
}

export async function deleteIssuedOrder(id: number) {
  const existing = await prisma.issued_orders.findUnique({ where: { id } });
  if (!existing) return null;
  await prisma.issued_orders.delete({ where: { id } });
  // Reclaim the number against the year it was allocated in (the creation year),
  // not the current year — a cross-year delete must release the right sequence
  // or the number is never freed.
  const year = existing.created_at
    ? new Date(existing.created_at).getFullYear()
    : new Date().getFullYear();
  await releaseIssuedOrderNumber(year, existing.po_number ?? undefined);

  // Remove the archived PDF from the NAS so the DB delete doesn't orphan it
  // (mirrors deleteOffer). cleanIssued sweeps every Vydané/YYYY/MM folder, so
  // an order whose order_date moved after archiving is still found.
  // Best-effort: log, never throw.
  if (existing.po_number && nasOrdersManager.isConfigured()) {
    try {
      nasOrdersManager.cleanIssued(existing.po_number);
    } catch (e) {
      console.error(
        "[issued-orders.service] NAS issued-order PDF delete failed for",
        existing.po_number,
        e,
      );
    }
  }

  return existing;
}

export async function getNextIssuedOrderNumberPreview() {
  return previewIssuedOrderNumber();
}
