import { Prisma } from "@prisma/client";
import prisma from "../config/database";
import {
  generateOfferNumber,
  releaseOfferNumber,
  isOfferNumberTaken,
  assignOfferNumber,
} from "./numbering.service";
import { nasOffersManager } from "./nas-offers-manager";

interface QuotationItemInput {
  description?: string;
  item_description?: string;
  quantity?: number;
  unit?: string;
  unit_price?: number;
  is_included_in_total?: boolean;
  position?: number;
}
interface ScopeSectionInput {
  title?: string;
  title_cz?: string;
  content?: string;
  position?: number;
}

// Re-export for convenience
export { previewOfferNumber as getNextOfferNumber } from "./numbering.service";

/**
 * Offer status lifecycle (mirrors issued orders' table): a draft must be
 * finalized to `active` first (that's when the number is assigned), an active
 * offer can be ordered or invalidated, an ordered one only invalidated, and
 * `invalidated` is terminal. The create-order flow (orders.service
 * createOrderFromQuotation) consults this table too — an offer can only become
 * `ordered` from a status that allows the transition.
 */
export const VALID_TRANSITIONS: Record<string, string[]> = {
  draft: ["active"],
  active: ["ordered", "invalidated"],
  ordered: ["invalidated"],
  invalidated: [],
};

/** Fields (non-status) are editable only while the offer is draft/active. */
function isEditableStatus(status: string): boolean {
  return status === "draft" || status === "active";
}

/**
 * Non-status update fields — used by the editable-state guard so a field edit
 * on an ordered/invalidated offer is rejected EXPLICITLY (not silently
 * dropped). `quotation_number` is stripped by the schema and is not listed.
 */
const NON_STATUS_UPDATE_FIELDS = [
  "project_code",
  "customer_id",
  "created_at",
  "valid_until",
  "currency",
  "language",
  "scope_title",
  "scope_description",
  "items",
  "sections",
] as const;

const ALLOWED_SORT_FIELDS = [
  "id",
  "quotation_number",
  "project_code",
  "created_at",
  "valid_until",
  "currency",
  "status",
];

interface OfferFilterParams {
  search?: string;
  status?: string;
  customer_id?: number;
}

interface ListOffersParams extends OfferFilterParams {
  page: number;
  limit: number;
  skip: number;
  sort: string;
  order: "asc" | "desc";
  search: string;
}

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

/**
 * Shared `where` builder for the offers list and the per-currency stats
 * aggregation, so both stay in sync (any new filter applies to both). Offers
 * have NO month/year filter — the list filters by search + status + customer_id.
 */
function buildOfferWhere(params: OfferFilterParams): Record<string, unknown> {
  const { search, status, customer_id } = params;
  const where: Record<string, unknown> = {};
  if (status) where.status = status;
  if (customer_id) where.customer_id = customer_id;
  if (search) {
    where.OR = [
      { quotation_number: { contains: search } },
      { project_code: { contains: search } },
      { customers: { name: { contains: search } } },
    ];
  }
  return where;
}

// Offers are NOT tax documents — totals are NET only (no VAT anywhere).
function enrichQuotation(q: any) {
  const total = q.quotation_items
    .filter((i: any) => i.is_included_in_total !== false)
    .reduce(
      (s: number, i: any) =>
        s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0),
      0,
    );
  const { quotation_items, scope_sections, ...rest } = q;
  return {
    ...rest,
    items: quotation_items,
    sections: scope_sections,
    customer_name: q.customers?.name || null,
    total: Math.round(total * 100) / 100,
  };
}

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

  const where = buildOfferWhere(params);

  // Tiebreak on id: created_at is second-precision, so same-second rows would
  // otherwise paginate non-deterministically (project ordering rule).
  const orderBy: Record<string, "asc" | "desc">[] = [
    { [sortField]: order },
    { id: order },
  ];

  const [quotations, total] = await Promise.all([
    prisma.quotations.findMany({
      where,
      skip,
      take: limit,
      orderBy,
      include: {
        customers: { select: { id: true, name: true } },
        quotation_items: { orderBy: { position: "asc" } },
        // id tiebreak: position can repeat, keep the order deterministic.
        scope_sections: { orderBy: [{ position: "asc" }, { id: "asc" }] },
      },
    }),
    prisma.quotations.count({ where }),
  ]);

  // Batch-fetch linked order statuses
  const orderIds = quotations
    .map((q) => q.order_id)
    .filter((id): id is number => id != null);
  const orderStatusMap = new Map<number, string>();
  if (orderIds.length > 0) {
    const orders = await prisma.orders.findMany({
      where: { id: { in: orderIds } },
      select: { id: true, status: true },
    });
    for (const o of orders) {
      orderStatusMap.set(o.id, o.status || "");
    }
  }

  const enriched = quotations.map((q) => ({
    ...enrichQuotation(q),
    order_status:
      q.order_id != null ? orderStatusMap.get(q.order_id) || null : null,
  }));

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

/**
 * Sum offer NET total per currency across the WHOLE filtered set (not a
 * single page). Reuses `buildOfferWhere` so the filters track the list, and
 * `enrichQuotation` so the per-offer math matches the list/detail exactly.
 * Returns one entry per currency, rounded to 2dp, zero/empty totals dropped.
 */
export async function getOfferTotals(
  params: OfferFilterParams,
): Promise<{ totals: CurrencyAmount[] }> {
  const where = buildOfferWhere(params);

  // Only quotation_items — the per-currency NET math needs nothing else
  // (customers/scope_sections would just inflate the full-set scan).
  const quotations = await prisma.quotations.findMany({
    where,
    include: {
      quotation_items: true,
    },
  });

  const byCurrency: Record<string, number> = {};
  for (const q of quotations) {
    const { total, currency } = enrichQuotation(q);
    const cur = 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 getOffer(id: number) {
  const quotation = await prisma.quotations.findUnique({
    where: { id },
    include: {
      customers: true,
      quotation_items: { orderBy: { position: "asc" } },
      // id tiebreak: position can repeat, keep the order deterministic.
      scope_sections: { orderBy: [{ position: "asc" }, { id: "asc" }] },
    },
  });
  if (!quotation) return null;

  // Fetch linked order if exists
  let orderInfo = null;
  if (quotation.order_id) {
    const order = await prisma.orders.findUnique({
      where: { id: quotation.order_id },
      select: { id: true, order_number: true, status: true },
    });
    orderInfo = order;
  }

  const { quotation_items, scope_sections, ...rest } = quotation;
  return {
    ...rest,
    items: quotation_items,
    sections: scope_sections,
    customer: quotation.customers,
    customer_name: quotation.customers?.name || null,
    order: orderInfo,
    // Computed server-side so the frontend renders only legal status buttons
    // (same contract as getIssuedOrder).
    valid_transitions: VALID_TRANSITIONS[quotation.status] || [],
  };
}

export async function createOffer(body: Record<string, any>) {
  try {
    return await prisma.$transaction(async (tx) => {
      const status = body.status ? String(body.status) : "draft";
      const explicitNumber =
        body.quotation_number !== undefined && body.quotation_number !== null
          ? String(body.quotation_number)
          : null;

      // Validate the referenced customer exists BEFORE the insert — a dangling
      // FK would otherwise surface as a P2003 500 instead of a clean 400
      // (mirrors createIssuedOrder's supplier check).
      const customerId = body.customer_id ? Number(body.customer_id) : null;
      if (customerId) {
        const customer = await tx.customers.findUnique({
          where: { id: customerId },
          select: { id: true },
        });
        if (!customer) return { error: "customer_not_found" as const };
      }

      // Deferred numbering: a draft carries NO quotation_number (the column is
      // nullable-unique so many drafts coexist). The official number is consumed
      // only when the draft is finalized (assignOfferNumber on draft->active).
      // A caller-supplied number, or an offer created already-finalized, numbers now.
      const quotationNumber =
        explicitNumber ??
        (status === "draft" ? null : await generateOfferNumber(tx));

      // Re-check uniqueness INSIDE the transaction to close the TOCTOU window
      // between a pre-transaction check and the insert (mirrors createOrder).
      // generateOfferNumber already verifies its own generated numbers, so this
      // guards the caller-supplied path.
      if (explicitNumber !== null) {
        const taken = await isOfferNumberTaken(explicitNumber, tx);
        if (taken) return { error: "number_taken" as const };
      }

      const quotation = await tx.quotations.create({
        data: {
          quotation_number: quotationNumber,
          project_code: body.project_code ? String(body.project_code) : null,
          customer_id: customerId,
          created_at: body.created_at
            ? new Date(String(body.created_at))
            : undefined,
          valid_until: body.valid_until
            ? new Date(String(body.valid_until))
            : null,
          currency: body.currency ? String(body.currency) : "CZK",
          language: body.language ? String(body.language) : "cs",
          status,
          scope_title: body.scope_title ? String(body.scope_title) : null,
          scope_description: body.scope_description
            ? String(body.scope_description)
            : null,
        },
      });

      if (Array.isArray(body.items)) {
        await tx.quotation_items.createMany({
          data: (body.items as QuotationItemInput[]).map((item, i) => ({
            quotation_id: quotation.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,
            is_included_in_total: item.is_included_in_total !== false,
            position: item.position ?? i,
          })),
        });
      }

      if (Array.isArray(body.sections)) {
        await tx.scope_sections.createMany({
          data: (body.sections as ScopeSectionInput[]).map((s, i) => ({
            quotation_id: quotation.id,
            title: s.title ?? null,
            title_cz: s.title_cz ?? null,
            content: s.content ?? null,
            position: s.position ?? i,
          })),
        });
      }

      return quotation;
    });
  } catch (err) {
    // P2002 on the quotation_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 createIssuedOrder).
    if (
      err instanceof Prisma.PrismaClientKnownRequestError &&
      err.code === "P2002"
    ) {
      return { error: "number_taken" as const };
    }
    throw err;
  }
}

export async function updateOffer(id: number, body: Record<string, any>) {
  const existing = await prisma.quotations.findUnique({ where: { id } });
  if (!existing) return { error: "not_found" as const };

  const currentStatus = existing.status;

  // Status changes must follow the transition table (issued-orders parity).
  if (body.status !== undefined && String(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/active.
  // In ordered/invalidated a field edit is REJECTED explicitly (it used to be
  // possible to rewrite everything in any status except invalidated).
  // Status-only transition payloads (e.g. ordered -> invalidated) still work.
  const editable = isEditableStatus(currentStatus);
  if (
    !editable &&
    NON_STATUS_UPDATE_FIELDS.some((f) => body[f] !== undefined)
  ) {
    return { error: "not_editable" as const, currentStatus };
  }

  // Explicit null CLEARS the customer; a set id must exist (dangling FK would
  // surface as a P2003 500). The old `Number(null)` coerced null to 0.
  let customerId: number | null | undefined = undefined;
  if (body.customer_id !== undefined) {
    customerId = body.customer_id ? Number(body.customer_id) : null;
    if (customerId) {
      const customer = await prisma.customers.findUnique({
        where: { id: customerId },
        select: { id: true },
      });
      if (!customer) return { error: "customer_not_found" as const };
    }
  }

  // Finalize transition draft -> active: assign the official number ATOMICALLY
  // with the status change. assignOfferNumber is idempotent.
  const finalizing =
    currentStatus === "draft" &&
    body.status !== undefined &&
    String(body.status) === "active";

  const data = {
    customer_id: customerId,
    created_at:
      body.created_at !== undefined
        ? body.created_at
          ? new Date(String(body.created_at))
          : null
        : undefined,
    valid_until:
      body.valid_until !== undefined
        ? body.valid_until
          ? new Date(String(body.valid_until))
          : null
        : undefined,
    currency: body.currency !== undefined ? String(body.currency) : undefined,
    language: body.language !== undefined ? String(body.language) : undefined,
    status: body.status !== undefined ? String(body.status) : undefined,
    project_code:
      body.project_code !== undefined
        ? body.project_code
          ? String(body.project_code)
          : null
        : undefined,
    scope_title:
      body.scope_title !== undefined
        ? body.scope_title
          ? String(body.scope_title)
          : null
        : undefined,
    scope_description:
      body.scope_description !== undefined
        ? body.scope_description
          ? String(body.scope_description)
          : null
        : undefined,
    modified_at: new Date(),
  };

  let assignedNumber: string | null = null;
  if (Array.isArray(body.items) || Array.isArray(body.sections) || finalizing) {
    await prisma.$transaction(async (tx) => {
      await tx.quotations.update({ where: { id }, data });
      if (finalizing) assignedNumber = await assignOfferNumber(id, tx);
      if (Array.isArray(body.items)) {
        await tx.quotation_items.deleteMany({ where: { quotation_id: id } });
        await tx.quotation_items.createMany({
          data: (body.items as QuotationItemInput[]).map((item, i) => ({
            quotation_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,
            is_included_in_total: item.is_included_in_total !== false,
            position: item.position ?? i,
          })),
        });
      }
      if (Array.isArray(body.sections)) {
        await tx.scope_sections.deleteMany({ where: { quotation_id: id } });
        await tx.scope_sections.createMany({
          data: (body.sections as ScopeSectionInput[]).map((s, i) => ({
            quotation_id: id,
            title: s.title ?? null,
            title_cz: s.title_cz ?? null,
            content: s.content ?? null,
            position: s.position ?? i,
          })),
        });
      }
    });
  } else {
    await prisma.quotations.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 (Array.isArray(body.items)) newValues.items = body.items;
  if (Array.isArray(body.sections)) newValues.sections = body.sections;

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

export async function deleteOffer(id: number) {
  const existing = await prisma.quotations.findUnique({ where: { id } });
  if (!existing) return { error: "not_found" as const };

  try {
    await prisma.quotations.delete({ where: { id } });
  } catch (err) {
    // P2003 = the offer is referenced by an order/project via a Restrict FK —
    // surface a clean conflict token instead of a 500.
    if (
      err instanceof Prisma.PrismaClientKnownRequestError &&
      err.code === "P2003"
    ) {
      return { error: "linked" as const };
    }
    throw err;
  }

  const year = existing.created_at
    ? new Date(existing.created_at).getFullYear()
    : new Date().getFullYear();
  await releaseOfferNumber(year, existing.quotation_number ?? undefined);

  // Remove the offer PDF from the NAS so the DB delete doesn't orphan it.
  // The service is the SINGLE owner of this cleanup (the route used to repeat
  // it and always logged a spurious second-delete failure).
  // Best-effort and idempotent: deleteOfferPdf returns false (and logs) if the
  // file is already gone, so a NAS outage / missing file never throws here.
  if (existing.quotation_number && nasOffersManager.isConfigured()) {
    try {
      const relPath = nasOffersManager.buildRelativePath(
        existing.quotation_number,
        year,
      );
      nasOffersManager.deleteOfferPdf(relPath);
    } catch (e) {
      console.error(
        "[offers.service] NAS offer PDF delete failed for",
        existing.quotation_number,
        e,
      );
    }
  }

  return { data: existing };
}

export async function duplicateOffer(id: number) {
  const original = await prisma.quotations.findUnique({
    where: { id },
    include: {
      quotation_items: { orderBy: { position: "asc" } },
      // id tiebreak: position can repeat, keep the order deterministic.
      scope_sections: { orderBy: [{ position: "asc" }, { id: "asc" }] },
    },
  });
  if (!original) return null;

  return prisma.$transaction(async (tx) => {
    const nextOfferNumber = await generateOfferNumber(tx);

    const copy = await tx.quotations.create({
      data: {
        quotation_number: nextOfferNumber,
        project_code: original.project_code,
        customer_id: original.customer_id,
        valid_until: null,
        currency: original.currency,
        language: original.language,
        status: "active",
        scope_title: original.scope_title,
        scope_description: original.scope_description,
      },
    });

    if (original.quotation_items.length > 0) {
      await tx.quotation_items.createMany({
        data: original.quotation_items.map((item) => ({
          quotation_id: copy.id,
          description: item.description,
          item_description: item.item_description,
          quantity: item.quantity,
          unit: item.unit,
          unit_price: item.unit_price,
          is_included_in_total: item.is_included_in_total,
          position: item.position,
        })),
      });
    }

    if (original.scope_sections.length > 0) {
      await tx.scope_sections.createMany({
        data: original.scope_sections.map((s) => ({
          quotation_id: copy.id,
          title: s.title,
          title_cz: s.title_cz,
          content: s.content,
          position: s.position,
        })),
      });
    }

    return { copy, original };
  });
}

export async function invalidateOffer(id: number) {
  const existing = await prisma.quotations.findUnique({ where: { id } });
  if (!existing) return { error: "not_found" as const };

  // The dedicated endpoint must respect the same transition table as PUT:
  // only active/ordered offers can be invalidated (a draft is deleted instead,
  // invalidated is terminal).
  const allowed = VALID_TRANSITIONS[existing.status] || [];
  if (!allowed.includes("invalidated")) {
    return {
      error: "invalid_transition" as const,
      currentStatus: existing.status,
      newStatus: "invalidated",
    };
  }

  await prisma.quotations.update({
    where: { id },
    data: { status: "invalidated", modified_at: new Date() },
  });
  return { data: existing };
}
