import { logError } from "@/lib/logger";
import { error as errorResponse, success } from "@/lib/response";
import { SMTP_FROM, transporter } from "@/lib/mailer";
import { CONSOLE_SSO_TTL_MINUTES, consoleSsoLink } from "@/lib/console-sso";
import {
  ExportStorage,
  buildExportObjectPath,
} from "@/lib/storage/export-storage";
import { EXPORT_FILE_TTL_HOURS } from "@/internal/queue/export-file-purge.worker";
import { randomUUID } from "node:crypto";
import {
  sendExportApprovalRequestMail,
  sendExportApprovedMail,
  sendExportRejectedMail,
  sendExportReviewedMail,
} from "@/lib/templates/export-requests";
import {
  ExportRequestsRepository,
  type ConsoleUserRecipient,
  type ExportRequestRow,
  type ExportRequestStatus,
} from "@/internal/repository/admin_console/export_requests";
import type { Context } from "hono";
import type Mail from "nodemailer/lib/mailer";

/**
 * Export approval requests.
 *
 * Direct download is unchanged. This is the second path: a user submits the
 * workbook they would have downloaded, a super admin reviews it, and on approval
 * it is emailed to the requester.
 *
 * The file travels as base64 in JSON rather than multipart. The console reaches
 * core through its own `/api/proxy` route, which forwards a JSON body cleanly;
 * multipart through that hop would need separate handling for no real benefit at
 * these sizes.
 *
 * On arrival it is written to GCS under the exports folder and the row keeps only the
 * object path — see lib/storage/export-storage. Requests created before that change
 * hold their bytes on the row instead; `resolveWorkbook` is the one place that
 * difference is handled.
 *
 * Four mails come out of this flow, all rendered from lib/templates/export-requests:
 *
 *  - a request arrives  → each super admin, pointing at the queue, where the
 *                         workbook can be downloaded for review
 *  - approved           → the requester, with the workbook and who approved it when
 *  - rejected           → the requester, with the reason
 *  - either decision    → each super admin, as the audit record of who decided what
 *
 * Only the approval mail carries the file. Attaching it to the others would put the
 * data in inboxes ahead of the approval that is supposed to gate it.
 *
 * Each mail is addressed to one person and carries a one-time sign-in link minted
 * for them, so it must be sent per recipient rather than to a combined `to:` — a
 * shared link would sign every reader in as whoever it was minted for.
 */

/** Sections the console can request an export for — mirrors ExportSection. */
const ALLOWED_SECTIONS = new Set([
  "campaigns",
  "promoCodes",
  "memberReferrals",
  "reports",
  "bookings",
]);

/*
 * Payload ceiling, in bytes of decoded file.
 *
 * Exports are bounded by the console's MAX_EXPORT_ROWS (10,000), which lands well
 * under this. The limit is here so a malformed or hostile client can't push an
 * arbitrarily large blob into a row that the review queue then reads.
 */
const MAX_FILE_BYTES = 12 * 1024 * 1024;

const XLSX_MIME =
  "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";

/** Where every button in these mails points. */
const QUEUE_PATH = "/admin/export-requests";

function repo(c: Context) {
  return new ExportRequestsRepository(c.get("datastore"));
}

function isSuperAdmin(c: Context): boolean {
  return c.get("isAdminConsoleSuperAdmin") === true;
}

function requesterEmail(c: Context): string {
  return String(c.get("adminEmail") ?? "");
}

function xlsxName(filename: string): string {
  return filename.endsWith(".xlsx") ? filename : `${filename}.xlsx`;
}

function workbookAttachment(
  filename: string,
  bytes: Buffer,
): Mail.Options["attachments"] {
  return [{ filename: xlsxName(filename), content: bytes, contentType: XLSX_MIME }];
}

/**
 * Fetches the workbook for a request, wherever it lives.
 *
 * New requests keep the file in GCS and the row carries `file_path`. Requests created
 * before that store it inline in `file_bytes`, and those rows are still fulfillable —
 * this is the single place that difference is handled, so nothing downstream has to
 * know which era a request comes from.
 *
 * Null means the file is genuinely unavailable: the object has been removed, or the
 * row somehow records neither. Callers report that rather than sending an empty
 * spreadsheet.
 */
async function resolveWorkbook(row: {
  id: string;
  file_path: string | null;
  file_bytes: Buffer | null;
  file_purged_at: string | null;
}): Promise<Buffer | null> {
  // Retention already removed it. Checked before the bucket so an expected absence
  // doesn't look like a storage fault in the logs.
  if (row.file_purged_at) return null;
  if (row.file_path) {
    const bytes = await ExportStorage().getWorkbook(row.file_path);
    if (!bytes) {
      logError(
        `Export request ${row.id} points at ${row.file_path}, but no such object exists.`,
      );
    }
    return bytes;
  }
  // Legacy row: the bytes are the only copy.
  return row.file_bytes ?? null;
}

/** The shape the mail templates describe, lifted off a stored request. */
function mailSummary(row: ExportRequestRow) {
  return {
    label: row.label,
    requestedBy: row.requested_by,
    requestedAt: row.created_at,
    rowCount: row.row_count,
    fileSize: row.file_size,
    filename: xlsxName(row.filename),
    filtersSummary: row.filters_summary,
  };
}

/**
 * Resolves the requester's console account — their name, for the mails, and their
 * id, for their sign-in link.
 *
 * The account is always read, never assumed from the request row: the row stores an
 * id and an address but no name, so short-circuiting on `requested_by_id` is what
 * made approvers read "nithya.rajan@karmagroup.com (nithya.rajan@karmagroup.com)".
 * Looked up by id when there is one, by address otherwise, since
 * `requested_by_id` is nullable.
 *
 * Null is a normal answer — the mail then carries a plain link and names the
 * requester by their address.
 */
async function requesterAccount(
  c: Context,
  row: ExportRequestRow,
): Promise<ConsoleUserRecipient | null> {
  try {
    const found = row.requested_by_id
      ? await repo(c).consoleUserById(row.requested_by_id)
      : await repo(c).consoleUserByEmail(row.requested_by);
    if (found) return found;
    // An id that no longer resolves (account hard-deleted) still gets a link, since
    // the token is keyed on the id and redemption does its own checks.
    return row.requested_by_id
      ? { id: row.requested_by_id, email: row.requested_by, name: row.requested_by }
      : null;
  } catch (err) {
    logError("Could not resolve the requester's console account:", err);
    return null;
  }
}

/**
 * How the requester is named in a mail: "Nithya Rajan (nithya.rajan@…)".
 *
 * Collapses to the bare address when no name is on record, rather than repeating
 * the address twice — `toRecipient` falls back to the address for `name`.
 */
function describeRequester(
  account: ConsoleUserRecipient | null,
  email: string,
): string {
  const name = account?.name?.trim();
  return name && name.toLowerCase() !== email.toLowerCase()
    ? `${name} (${email})`
    : email;
}

export const createExportRequestHandler = async (c: Context) => {
  try {
    const body = (await c.req.json().catch(() => null)) as {
      section?: string;
      label?: string;
      filename?: string;
      filtersSummary?: string;
      rowCount?: number;
      fileBase64?: string;
    } | null;
    if (!body) return errorResponse(c, "Expected a JSON body", 400);

    const section = String(body.section ?? "");
    if (!ALLOWED_SECTIONS.has(section)) {
      return errorResponse(c, "Unknown export section", 400);
    }
    if (!body.fileBase64) {
      return errorResponse(c, "The generated file is required", 400);
    }

    const email = requesterEmail(c);
    if (!email) {
      // Without an address there is nowhere to deliver an approval, so refuse
      // rather than accept a request that can never be fulfilled.
      return errorResponse(
        c,
        "Your account has no email address, so an export cannot be delivered.",
        400,
      );
    }

    let file: Buffer;
    try {
      file = Buffer.from(body.fileBase64, "base64");
    } catch {
      return errorResponse(c, "The file could not be decoded", 400);
    }
    if (file.length === 0) return errorResponse(c, "The file is empty", 400);
    if (file.length > MAX_FILE_BYTES) {
      return errorResponse(
        c,
        `Export is too large (${file.length} bytes, limit ${MAX_FILE_BYTES}).`,
        413,
      );
    }

    const filename = String(body.filename ?? "export").replace(
      /[^\w.\-]+/g,
      "_",
    );

    /*
     * The workbook goes to the bucket before the row is written.
     *
     * That ordering is what keeps the two consistent in the direction that matters: a
     * failed upload means no request, and the user is told to retry. The reverse
     * order would leave a queued request whose file does not exist, which an approver
     * cannot review and cannot dismiss as noise.
     *
     * The object is keyed by a locally-minted UUID rather than the request's primary
     * key, which the database only allocates at insert time — too late to name an
     * object we want written first. Nothing else derives from it, so it never needs
     * to match the row's id.
     */
    const objectPath = buildExportObjectPath(
      randomUUID(),
      xlsxName(filename),
      new Date(),
    );
    await ExportStorage().putWorkbook(objectPath, file);

    let row;
    try {
      row = await repo(c).create({
        requestedBy: email,
        requestedById: String(c.get("consoleUserId") ?? "") || null,
        section,
        label: String(body.label ?? section),
        filename,
        filtersSummary: body.filtersSummary ? String(body.filtersSummary) : null,
        rowCount: Number(body.rowCount ?? 0),
        filePath: objectPath,
        fileSize: file.length,
      });
    } catch (insertErr) {
      // No row means nothing will ever read this object; don't leave it behind.
      await ExportStorage().deleteWorkbook(objectPath);
      throw insertErr;
    }

    /*
     * Tell the approvers a request is waiting.
     *
     * Best-effort and deliberately after the row is committed: the request must
     * stand even if the notification fails, otherwise a mail outage would silently
     * lose people's export requests. A failure is logged, and the queue itself is
     * still the source of truth.
     */
    void (async () => {
      try {
        const admins = await repo(c).superAdmins();
        if (admins.length === 0) return;

        /*
         * No attachment on this mail.
         *
         * "Which excel is being downloaded" is a fair question for an approver, but
         * emailing the workbook to answer it would release the data before the
         * approval that gates it — to every super admin, in an inbox, with nothing
         * recorded about who then had a copy. The queue serves the same file for
         * download instead, behind the session that already authorises review.
         */
        const requester = await requesterAccount(c, row);
        const summary = {
          ...mailSummary(row),
          requestedBy: describeRequester(requester, email),
        };

        for (const admin of admins) {
          try {
            const reviewLink = await consoleSsoLink(c, {
              userId: admin.id,
              next: QUEUE_PATH,
            });
            const mail = await sendExportApprovalRequestMail(
              {
                ...summary,
                reviewerEmail: admin.email,
                reviewLink,
                ssoMinutes: CONSOLE_SSO_TTL_MINUTES,
              },
              { from: SMTP_FROM, to: admin.email },
            );
            await transporter.sendMail(mail);
          } catch (perAdminErr) {
            // One unreachable approver must not stop the rest being told.
            logError(
              `Failed notifying super admin ${admin.email} of an export request:`,
              perAdminErr,
            );
          }
        }
      } catch (notifyErr) {
        logError("Export request saved but notifying super admins failed:", notifyErr);
      }
    })();

    return success(c, row, "Export request submitted for approval", 201);
  } catch (err: any) {
    logError("Error in createExportRequestHandler:", err);
    return errorResponse(c, err?.message || "Failed to submit request", 500);
  }
};

/**
 * The review queue for a super admin, or the caller's own requests otherwise.
 *
 * A normal user seeing their own history is useful and harmless; seeing everyone's
 * is not, so the scope is decided here rather than trusted from the query.
 */
export const listExportRequestsHandler = async (c: Context) => {
  try {
    const q = c.req.query();
    const status = q.status as ExportRequestStatus | undefined;
    const limit = Math.min(100, Math.max(1, parseInt(q.limit ?? "25", 10) || 25));
    const offset = Math.max(0, parseInt(q.offset ?? "0", 10) || 0);

    const result = await repo(c).list({
      status:
        status === "pending" || status === "approved" || status === "rejected"
          ? status
          : undefined,
      requestedBy: isSuperAdmin(c) ? undefined : requesterEmail(c),
      limit,
      offset,
    });

    return success(
      c,
      { ...result, canReview: isSuperAdmin(c) },
      "Export requests fetched",
      200,
    );
  } catch (err: any) {
    logError("Error in listExportRequestsHandler:", err);
    return errorResponse(c, err?.message || "Failed to list requests", 500);
  }
};

/**
 * Serves the stored workbook for one request.
 *
 * This is how an approver answers "what is actually in this file" now that the mail
 * no longer attaches it: the same bytes, behind the session that already authorises
 * review, and only ever one request at a time.
 *
 * Who may fetch it:
 *
 *  - a super admin, for any request — that is the review itself
 *  - the requester, for their own request, but only once it has been approved.
 *    Before that they must not be able to pull the data straight back out of the
 *    queue, which would make the whole approval step decorative.
 */
export const downloadExportRequestFileHandler = async (c: Context) => {
  try {
    const id = c.req.param("id");
    if (!id) return errorResponse(c, "Request id is required", 400);

    const full = await repo(c).getWithFile(id);
    if (!full) return errorResponse(c, "Request not found", 404);

    if (!isSuperAdmin(c)) {
      const email = requesterEmail(c);
      const isOwn =
        Boolean(email) &&
        full.requested_by.toLowerCase() === email.toLowerCase();
      if (!isOwn) {
        return errorResponse(c, "You cannot download this export.", 403);
      }
      if (full.status !== "approved") {
        return errorResponse(
          c,
          "This export hasn't been approved yet, so it can't be downloaded.",
          403,
        );
      }
    }

    const bytes = await resolveWorkbook(full);
    if (!bytes) {
      // Expiry is the ordinary case and worth naming, so nobody reports it as a bug.
      return errorResponse(
        c,
        full.file_purged_at
          ? `This file was deleted ${EXPORT_FILE_TTL_HOURS} hours after it was emailed. Ask the requester for their copy, or request the export again.`
          : "The file for this request is no longer available.",
        410,
      );
    }

    const filename = xlsxName(full.filename);
    return c.body(new Uint8Array(bytes), 200, {
      "Content-Type": XLSX_MIME,
      "Content-Disposition": `attachment; filename="${filename}"`,
      "Content-Length": String(bytes.length),
      // Never cached: the queue is reachable from shared machines and the payload
      // is member data.
      "Cache-Control": "no-store",
    });
  } catch (err: any) {
    logError("Error in downloadExportRequestFileHandler:", err);
    return errorResponse(c, err?.message || "Failed to download the export", 500);
  }
};

/**
 * Copies the decision to every super admin.
 *
 * This is the audit trail in the inbox: who reviewed it, when, and whether the file
 * actually went out. Detached and best-effort — it is a record of something that has
 * already happened, so failing to send it must not affect the decision or the
 * response the reviewer sees.
 */
function notifySuperAdminsOfReview(
  c: Context,
  args: {
    row: ExportRequestRow;
    /** The requester, named — the row only carries their address. */
    requestedBy: string;
    decision: "approved" | "rejected";
    reviewedBy: string;
    note: string | null;
    deliveryStatus: string;
  },
): void {
  void (async () => {
    try {
      const admins = await repo(c).superAdmins();
      if (admins.length === 0) return;

      for (const admin of admins) {
        try {
          const queueLink = await consoleSsoLink(c, {
            userId: admin.id,
            next: QUEUE_PATH,
          });
          const mail = await sendExportReviewedMail(
            {
              ...mailSummary(args.row),
              decision: args.decision,
              reviewedBy: args.reviewedBy,
              reviewedAt: args.row.reviewed_at ?? new Date().toISOString(),
              note: args.note,
              deliveryStatus: args.deliveryStatus,
              queueLink,
              reviewerEmail: admin.email,
              ssoMinutes: CONSOLE_SSO_TTL_MINUTES,
            },
            { from: SMTP_FROM, to: admin.email },
          );
          await transporter.sendMail(mail);
        } catch (perAdminErr) {
          logError(
            `Failed sending the review record to ${admin.email}:`,
            perAdminErr,
          );
        }
      }
    } catch (err) {
      logError("Could not send the export review record to super admins:", err);
    }
  })();
}

export const reviewExportRequestHandler = async (c: Context) => {
  try {
    if (!isSuperAdmin(c)) {
      return errorResponse(
        c,
        "Only a super admin can approve or reject an export.",
        403,
      );
    }
    const id = c.req.param("id");
    if (!id) return errorResponse(c, "Request id is required", 400);

    const body = (await c.req.json().catch(() => null)) as {
      action?: string;
      note?: string;
    } | null;
    const approve = body?.action === "approve";
    if (!approve && body?.action !== "reject") {
      return errorResponse(c, "action must be approve or reject", 400);
    }

    const reviewer = requesterEmail(c) || "super admin";
    const note = body?.note ? String(body.note) : null;

    /*
     * Status is flipped before the mail is sent, and the update only matches a
     * row that is still `pending`. That ordering is what stops two approvers from
     * both sending the file — the second update returns nothing.
     */
    const reviewed = await repo(c).review({
      id,
      status: approve ? "approved" : "rejected",
      reviewedBy: reviewer,
      note,
    });
    if (!reviewed) {
      return errorResponse(
        c,
        "That request no longer needs review — it may already have been actioned.",
        409,
      );
    }

    const requester = await requesterAccount(c, reviewed);
    const requesterLink = await consoleSsoLink(c, {
      userId: requester?.id,
      next: QUEUE_PATH,
    });
    const reviewedAt = reviewed.reviewed_at ?? new Date().toISOString();
    // Named the same way in every mail about this request, including the copies
    // that go to people who don't know the address by sight.
    const requestedBy = describeRequester(requester, reviewed.requested_by);

    if (!approve) {
      /*
       * Tell the requester their export was refused, and why.
       *
       * Awaited but never fatal: a rejection that is only visible if you happen to
       * revisit the queue is how people end up asking twice. The failure is logged
       * rather than written to `delivery_error`, which the queue renders as "the
       * file didn't reach them" and means something else.
       */
      try {
        const mail = await sendExportRejectedMail(
          {
            ...mailSummary(reviewed),
            requestedBy,
            reviewedBy: reviewer,
            reviewedAt,
            note,
            queueLink: requesterLink,
            ssoForEmail: requester ? reviewed.requested_by : null,
            ssoMinutes: CONSOLE_SSO_TTL_MINUTES,
          },
          { from: SMTP_FROM, to: reviewed.requested_by },
        );
        await transporter.sendMail(mail);
      } catch (mailErr) {
        logError("Export rejected but notifying the requester failed:", mailErr);
      }

      notifySuperAdminsOfReview(c, {
        row: reviewed,
        requestedBy,
        decision: "rejected",
        reviewedBy: reviewer,
        note,
        deliveryStatus: "No file was sent.",
      });

      return success(c, reviewed, "Export request rejected", 200);
    }

    // Approved: deliver the exact bytes that were reviewed.
    const full = await repo(c).getWithFile(id);
    if (!full) return errorResponse(c, "Request not found", 404);

    /*
     * Fetched from the bucket before the mail is composed.
     *
     * A missing object is recorded through `markDelivery` like any other delivery
     * failure, so the queue shows the approval standing with nothing delivered —
     * which is exactly the situation — rather than the approver seeing a success and
     * the requester receiving nothing.
     */
    const workbook = await resolveWorkbook(full);
    if (!workbook) {
      const reason = "The stored file could not be found, so nothing was sent.";
      await repo(c).markDelivery(id, reason);
      notifySuperAdminsOfReview(c, {
        row: reviewed,
        requestedBy,
        decision: "approved",
        reviewedBy: reviewer,
        note,
        deliveryStatus: reason,
      });
      return success(
        c,
        { ...reviewed, delivery_error: reason },
        `Approved, but ${reason.toLowerCase()}`,
        200,
      );
    }

    try {
      const mail = await sendExportApprovedMail(
        {
          ...mailSummary(full),
          requestedBy,
          reviewedBy: reviewer,
          reviewedAt,
          note,
          queueLink: requesterLink,
          ssoForEmail: requester ? full.requested_by : null,
          ssoMinutes: CONSOLE_SSO_TTL_MINUTES,
          retentionHours: EXPORT_FILE_TTL_HOURS,
        },
        { from: SMTP_FROM, to: full.requested_by },
        workbookAttachment(full.filename, workbook),
      );
      await transporter.sendMail(mail);
      await repo(c).markDelivery(id, null);
    } catch (mailErr: any) {
      /*
       * The approval stands even if delivery fails.
       *
       * Rolling it back would let a retry re-approve and potentially double-send;
       * recording the error instead keeps the decision and makes the failure
       * visible in the queue so it can be resent.
       */
      logError("Export approved but email failed:", mailErr);
      await repo(c).markDelivery(id, String(mailErr?.message ?? mailErr));
      notifySuperAdminsOfReview(c, {
        row: reviewed,
        requestedBy,
        decision: "approved",
        reviewedBy: reviewer,
        note,
        deliveryStatus: `Sending the file to ${full.requested_by} failed: ${String(
          mailErr?.message ?? mailErr,
        )}`,
      });
      return success(
        c,
        { ...reviewed, delivery_error: String(mailErr?.message ?? mailErr) },
        "Approved, but sending the email failed. It can be resent.",
        200,
      );
    }

    notifySuperAdminsOfReview(c, {
      row: reviewed,
      requestedBy,
      decision: "approved",
      reviewedBy: reviewer,
      note,
      deliveryStatus: `Emailed to ${full.requested_by}.`,
    });

    return success(c, reviewed, "Export approved and emailed", 200);
  } catch (err: any) {
    logError("Error in reviewExportRequestHandler:", err);
    return errorResponse(c, err?.message || "Failed to review request", 500);
  }
};
