import React from "react";
import { renderToBuffer } from "@react-pdf/renderer";
import type { AdminApiContext } from "@shopify/shopify-app-react-router/server";

import db from "../../db.server";
import { composeInvoice } from "./composeInvoice";
import { buildGiroCodeDataUrl } from "./girocode";
import { loadOrderForInvoice } from "./loadOrderForInvoice.server";
import { getLogoDataUrl } from "./logoCache.server";
import { attachLineItemImages } from "./productImageCache.server";
import { allocateInvoiceNumber } from "./numbering.server";
import { InvoiceDocument } from "./pdf/InvoiceDocument";
import type { InvoiceViewModel } from "./types";

export interface GenerateInvoiceArgs {
  shopDomain: string;
  admin: AdminApiContext;
  /** Either a numeric Shopify order id or a full GID. */
  orderId: string;
  /** When true, bypass the "sent invoice is locked" rule and regenerate in place. */
  forceRegenerate?: boolean;
}

export interface GeneratedInvoice {
  invoiceId: string;
  invoiceNumber: string;
  pdfUrl: string;
  pdfFileGid: string;
  version: number;
  reused: boolean;
}

/**
 * Top-level orchestrator. Loads order + settings, composes the view model,
 * renders the PDF, uploads to Shopify Files and persists an Invoice row.
 *
 * Idempotency rules:
 * - If a non-cancelled Invoice for the order exists and is unsent, it is
 *   regenerated in place (new file, same number, version++).
 * - If the latest is `sent` and not cancelled, generation is refused (caller
 *   must use the cancel-and-reissue flow). Future phase.
 */
export async function generateInvoice(
  args: GenerateInvoiceArgs,
): Promise<GeneratedInvoice> {
  const { shopDomain, admin } = args;
  const orderGid = toOrderGid(args.orderId);

  const settings = await db.shopSettings.upsert({
    where: { shopDomain },
    update: {},
    create: { shopDomain },
  });

  const order = await loadOrderForInvoice(admin, orderGid);

  // Find latest existing invoice (excluding storno) for this order.
  const latest = await db.invoice.findFirst({
    where: { shopDomain, orderId: orderGid, kind: "invoice", cancelledAt: null },
    orderBy: [{ version: "desc" }, { createdAt: "desc" }],
  });

  if (latest && latest.sentAt && !args.forceRegenerate) {
    throw new Error(
      `Invoice ${latest.invoiceNumber} has already been sent. Use cancel-and-reissue to correct it.`,
    );
  }

  const invoiceNumber = latest
    ? latest.invoiceNumber
    : await allocateInvoiceNumber(settings, order.orderNumber);

  // Compose view model and render PDF.
  const viewModel = composeInvoice({ order, settings, invoiceNumber });

  // Logo (cached).
  const logoDataUrl = await getLogoDataUrl(shopDomain, settings.logoUrl);
  if (logoDataUrl) viewModel.issuer.logoDataUrl = logoDataUrl;

  // Product images for each line (best-effort, parallel, in-process cache).
  await attachLineItemImages(viewModel.lines);

  // GiroCode (only for unpaid + IBAN configured + enabled).
  if (
    settings.giroCodeEnabled &&
    settings.iban &&
    !viewModel.paid &&
    viewModel.totals.gross > 0
  ) {
    viewModel.giroCodePngDataUrl = await buildGiroCodeDataUrl({
      beneficiaryName:
        [settings.companyName, settings.legalForm].filter(Boolean).join(" ") ||
        "Beneficiary",
      iban: settings.iban,
      bic: settings.bic,
      amount: viewModel.totals.gross,
      currency: viewModel.currency,
      remittance: invoiceNumber,
    });
  }

  const pdfBuffer = await renderInvoicePdf(viewModel);

  const filename = `Rechnung-${sanitiseForFilename(invoiceNumber)}.pdf`;

  const upload = await uploadPdfToShopifyFiles(admin, {
    bytes: pdfBuffer,
    filename,
    alt: `Invoice ${invoiceNumber}`,
  });

  const version = latest ? latest.version + 1 : 1;
  const totalsJson = JSON.stringify(viewModel.totals);
  const customerJson = JSON.stringify({
    recipient: viewModel.recipient,
    isB2B: viewModel.isB2B,
    recipientVatId: viewModel.recipientVatId,
    customerEmail: order.customer?.email,
  });

  // Persist (upsert by latest row when regenerating in place).
  const invoice = latest
    ? await db.invoice.update({
        where: { id: latest.id },
        data: {
          version,
          pdfFileGid: upload.fileGid,
          pdfUrl: upload.url,
          totalsJson,
          customerJson,
          issuedAt: new Date(),
          status: "issued",
          lastError: "",
        },
      })
    : await db.invoice.create({
        data: {
          shopDomain,
          orderId: orderGid,
          orderName: order.name,
          orderNumber: order.orderNumber,
          invoiceNumber,
          language: viewModel.language,
          kind: "invoice",
          version: 1,
          pdfFileGid: upload.fileGid,
          pdfUrl: upload.url,
          totalsJson,
          customerJson,
          status: "issued",
        },
      });

  // Link the latest PDF on the order via metafields (best-effort; do not
  // fail the whole operation if scopes are missing).
  try {
    await writeOrderMetafields(admin, orderGid, {
      pdfUrl: upload.url,
      number: invoiceNumber,
      version: invoice.version,
    });
  } catch (err) {
    console.warn("Order metafield write failed:", err);
  }

  return {
    invoiceId: invoice.id,
    invoiceNumber,
    pdfUrl: upload.url,
    pdfFileGid: upload.fileGid,
    version: invoice.version,
    reused: !!latest,
  };
}

/** Convert legacy numeric ids to a full Shopify GID. */
export function toOrderGid(input: string): string {
  if (input.startsWith("gid://")) return input;
  return `gid://shopify/Order/${input}`;
}

function sanitiseForFilename(s: string): string {
  return s.replace(/[^A-Za-z0-9._-]/g, "_");
}

export { sanitiseForFilename };

/** Renders the PDF as a Node Buffer. */
export async function renderInvoicePdf(view: InvoiceViewModel): Promise<Buffer> {
  // @react-pdf returns a Node Buffer in node environments.
  return renderToBuffer(<InvoiceDocument invoice={view} />) as Promise<Buffer>;
}

/* ------------------------------------------------------------------ */
/* Shopify Files upload                                                */
/* ------------------------------------------------------------------ */

interface UploadResult {
  fileGid: string;
  url: string;
}

const STAGED_UPLOAD_MUTATION = `#graphql
  mutation stagedUploads($input: [StagedUploadInput!]!) {
    stagedUploadsCreate(input: $input) {
      stagedTargets {
        url
        resourceUrl
        parameters { name value }
      }
      userErrors { field message }
    }
  }
`;

const FILE_CREATE_MUTATION = `#graphql
  mutation fileCreate($files: [FileCreateInput!]!) {
    fileCreate(files: $files) {
      files {
        id
        alt
        createdAt
        ... on GenericFile {
          url
        }
      }
      userErrors { field message }
    }
  }
`;

const FILE_QUERY = `#graphql
  query File($id: ID!) {
    node(id: $id) {
      ... on GenericFile {
        id
        url
      }
    }
  }
`;

interface StagedTarget {
  url: string;
  resourceUrl: string;
  parameters: { name: string; value: string }[];
}

interface UploadInput {
  bytes: Buffer;
  filename: string;
  alt: string;
}

export async function uploadPdfToShopifyFiles(
  admin: AdminApiContext,
  input: UploadInput,
): Promise<UploadResult> {
  const stagedRes = await admin.graphql(STAGED_UPLOAD_MUTATION, {
    variables: {
      input: [
        {
          filename: input.filename,
          mimeType: "application/pdf",
          httpMethod: "POST",
          resource: "FILE",
          fileSize: input.bytes.length.toString(),
        },
      ],
    },
  });
  const stagedJson = (await stagedRes.json()) as {
    data?: {
      stagedUploadsCreate?: {
        stagedTargets?: StagedTarget[];
        userErrors?: { field: string[]; message: string }[];
      };
    };
  };
  const stagedErr = stagedJson.data?.stagedUploadsCreate?.userErrors ?? [];
  if (stagedErr.length > 0) {
    throw new Error(`stagedUploadsCreate failed: ${JSON.stringify(stagedErr)}`);
  }
  const target = stagedJson.data?.stagedUploadsCreate?.stagedTargets?.[0];
  if (!target) throw new Error("stagedUploadsCreate returned no target");

  // POST the bytes to the staged URL (multipart form with the parameters).
  const form = new FormData();
  for (const p of target.parameters) form.append(p.name, p.value);
  form.append(
    "file",
    new Blob([new Uint8Array(input.bytes)], { type: "application/pdf" }),
    input.filename,
  );
  const putRes = await fetch(target.url, { method: "POST", body: form });
  if (!putRes.ok) {
    const text = await putRes.text().catch(() => "");
    throw new Error(`Staged upload POST failed (${putRes.status}): ${text}`);
  }

  // Register the file with Shopify.
  const fileRes = await admin.graphql(FILE_CREATE_MUTATION, {
    variables: {
      files: [
        {
          alt: input.alt,
          contentType: "FILE",
          originalSource: target.resourceUrl,
        },
      ],
    },
  });
  const fileJson = (await fileRes.json()) as {
    data?: {
      fileCreate?: {
        files?: { id: string; url?: string | null }[];
        userErrors?: { field: string[]; message: string }[];
      };
    };
  };
  const fileErr = fileJson.data?.fileCreate?.userErrors ?? [];
  if (fileErr.length > 0) {
    throw new Error(`fileCreate failed: ${JSON.stringify(fileErr)}`);
  }
  const file = fileJson.data?.fileCreate?.files?.[0];
  if (!file) throw new Error("fileCreate returned no file");

  // The CDN url is populated asynchronously after Shopify ingests the file.
  // Poll a few times for it to appear.
  let url = file.url ?? "";
  if (!url) {
    for (let i = 0; i < 8 && !url; i++) {
      await sleep(500);
      const q = await admin.graphql(FILE_QUERY, { variables: { id: file.id } });
      const qj = (await q.json()) as {
        data?: { node?: { url?: string | null } | null };
      };
      url = qj.data?.node?.url ?? "";
    }
  }

  return { fileGid: file.id, url };
}

function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

/* ------------------------------------------------------------------ */
/* Order metafield linkage                                             */
/* ------------------------------------------------------------------ */

const METAFIELDS_SET_MUTATION = `#graphql
  mutation metafieldsSet($metafields: [MetafieldsSetInput!]!) {
    metafieldsSet(metafields: $metafields) {
      metafields { id namespace key }
      userErrors { field message }
    }
  }
`;

export async function writeOrderMetafields(
  admin: AdminApiContext,
  orderGid: string,
  data: { pdfUrl: string; number: string; version: number },
): Promise<void> {
  const res = await admin.graphql(METAFIELDS_SET_MUTATION, {
    variables: {
      metafields: [
        {
          ownerId: orderGid,
          namespace: "linumiq_invoice",
          key: "pdf_url",
          type: "url",
          value: data.pdfUrl,
        },
        {
          ownerId: orderGid,
          namespace: "linumiq_invoice",
          key: "number",
          type: "single_line_text_field",
          value: data.number,
        },
        {
          ownerId: orderGid,
          namespace: "linumiq_invoice",
          key: "version",
          type: "number_integer",
          value: data.version.toString(),
        },
      ],
    },
  });
  const json = (await res.json()) as {
    data?: {
      metafieldsSet?: {
        userErrors?: { field: string[]; message: string }[];
      };
    };
  };
  const errs = json.data?.metafieldsSet?.userErrors ?? [];
  if (errs.length > 0) {
    throw new Error(`metafieldsSet failed: ${JSON.stringify(errs)}`);
  }
}
