import "server-only";
import {
  createHash,
  createHmac,
  randomUUID,
  timingSafeEqual,
} from "node:crypto";
import { z } from "zod";
import {
  documentMetadataSchema,
  MAX_DOCUMENT_BYTES,
  staffDocumentKinds,
} from "./application-schema";
const descriptorSchema = documentMetadataSchema
  .omit({ name: true })
  .extend({
    kind: z.enum(staffDocumentKinds),
    sha256: z.string().regex(/^[a-f0-9]{64}$/),
  })
  .strict();
const capabilitySchema = z.string().regex(/^[a-f0-9]{64}$/);
const timestamp = z.number().int().nonnegative().max(8_640_000_000_000_000);
export const UPLOAD_URL_LIFETIME_MS = 10 * 60 * 1000;
export const UPLOAD_TICKET_LIFETIME_MS = 7 * 24 * 60 * 60 * 1000;
const ticketSchema = z
  .object({
    version: z.literal(1),
    id: z.uuid(),
    ownerDigest: z.string().regex(/^[a-f0-9]{64}$/),
    document: descriptorSchema,
    issuedAt: timestamp,
    expiresAt: timestamp,
  })
  .strict();
export type UploadDescriptor = z.infer<typeof descriptorSchema>;
export type UploadTicket = z.infer<typeof ticketSchema>;
export type UploadTarget = { url: string; headers: Record<string, string> };
export type StoredDocument = {
  size: number;
  contentType: string;
  generation: string;
  body: AsyncIterable<Uint8Array>;
};
export interface UploadStorage {
  signUpload(
    path: string,
    expiresAt: number,
    headers: Record<string, string>,
  ): Promise<string>;
  read(path: string, signal: AbortSignal): Promise<StoredDocument>;
}
export class UploadRejected extends Error {
  constructor(readonly content = false) {
    super("The document could not be verified. Please select it again.");
  }
}

function signingKey(secret: string): string {
  if (secret.length < 32)
    throw new Error("Invalid upload signing configuration");
  return secret;
}
const digest = (capability: string) =>
  createHash("sha256").update(capability).digest("hex");
const mac = (encoded: string, secret: string) =>
  createHmac("sha256", signingKey(secret))
    .update(`maxcredit-upload-v1.${encoded}`)
    .digest();
export const uploadObjectPath = (ticket: UploadTicket) =>
  `applications/uploads/${z.uuid().parse(ticket.id)}`;

export function issueUploadTicket(
  input: unknown,
  ownerCapability: string,
  secret: string,
  now: number,
): { ticket: string; data: UploadTicket } {
  const descriptor = descriptorSchema.safeParse(input);
  if (
    !descriptor.success ||
    !capabilitySchema.safeParse(ownerCapability).success ||
    !timestamp.safeParse(now).success
  )
    throw new UploadRejected();
  const parsed = ticketSchema.safeParse({
    version: 1,
    id: randomUUID(),
    ownerDigest: digest(ownerCapability),
    document: descriptor.data,
    issuedAt: now,
    expiresAt: now + UPLOAD_TICKET_LIFETIME_MS,
  });
  if (!parsed.success) throw new UploadRejected();
  const data = parsed.data;
  const encoded = Buffer.from(JSON.stringify(data)).toString("base64url");
  return {
    data,
    ticket: `${encoded}.${mac(encoded, secret).toString("base64url")}`,
  };
}

export function readUploadTicket(
  value: unknown,
  ownerCapability: string,
  secret: string,
  now: number,
): UploadTicket {
  signingKey(secret);
  if (
    typeof value !== "string" ||
    value.length > 4096 ||
    !capabilitySchema.safeParse(ownerCapability).success ||
    !timestamp.safeParse(now).success
  )
    throw new UploadRejected();
  const parts = /^([A-Za-z0-9_-]+)\.([A-Za-z0-9_-]{43})$/.exec(value);
  if (!parts) throw new UploadRejected();
  const supplied = Buffer.from(parts[2], "base64url"),
    expected = mac(parts[1], secret);
  if (
    supplied.length !== expected.length ||
    !timingSafeEqual(supplied, expected) ||
    supplied.toString("base64url") !== parts[2]
  )
    throw new UploadRejected();
  let raw: unknown;
  try {
    const bytes = Buffer.from(parts[1], "base64url");
    if (bytes.toString("base64url") !== parts[1]) throw new UploadRejected();
    raw = JSON.parse(
      new TextDecoder("utf-8", { fatal: true }).decode(bytes),
    ) as unknown;
  } catch {
    throw new UploadRejected();
  }
  const result = ticketSchema.safeParse(raw);
  if (!result.success) throw new UploadRejected();
  const ticket = result.data;
  if (
    ticket.ownerDigest !== digest(ownerCapability) ||
    ticket.issuedAt > now ||
    ticket.expiresAt <= now ||
    ticket.expiresAt - ticket.issuedAt !== UPLOAD_TICKET_LIFETIME_MS
  )
    throw new UploadRejected();
  return ticket;
}

export async function createUploadTarget(
  ticket: UploadTicket,
  storage: UploadStorage,
  now: number,
  timeoutMs = 10_000,
  notAfter = Number.POSITIVE_INFINITY,
): Promise<UploadTarget> {
  const parsed = ticketSchema.safeParse(ticket);
  if (
    !parsed.success ||
    !timestamp.safeParse(now).success ||
    !Number.isFinite(timeoutMs) ||
    timeoutMs <= 0 ||
    parsed.data.issuedAt > now ||
    parsed.data.expiresAt <= now ||
    parsed.data.expiresAt - parsed.data.issuedAt !==
      UPLOAD_TICKET_LIFETIME_MS ||
    !(notAfter > now)
  )
    throw new UploadRejected();
  const headers = {
    "Content-Type": ticket.document.type,
    "x-goog-content-length-range": `${ticket.document.size},${ticket.document.size}`,
    "x-goog-if-generation-match": "0",
  };
  let timer: ReturnType<typeof setTimeout> | undefined;
  try {
    const url = await Promise.race([
      storage.signUpload(
        uploadObjectPath(ticket),
        Math.min(now + UPLOAD_URL_LIFETIME_MS, ticket.expiresAt, notAfter),
        headers,
      ),
      new Promise<never>((_, reject) => {
        timer = setTimeout(() => reject(new UploadRejected()), timeoutMs);
      }),
    ]);
    const parsedUrl = z.url().safeParse(url);
    if (!parsedUrl.success) throw new UploadRejected();
    const target = new URL(parsedUrl.data);
    if (
      target.protocol !== "https:" ||
      target.username ||
      target.password ||
      target.hash ||
      target.port ||
      !(
        target.hostname === "storage.googleapis.com" ||
        target.hostname.endsWith(".storage.googleapis.com")
      )
    )
      throw new UploadRejected();
    return { url, headers };
  } catch {
    throw new UploadRejected();
  } finally {
    clearTimeout(timer);
  }
}

function matchesSignature(
  header: Uint8Array,
  type: UploadDescriptor["type"],
): boolean {
  const bytes = Buffer.from(header);
  if (type === "application/pdf")
    return bytes.subarray(0, 5).equals(Buffer.from("%PDF-"));
  if (type === "image/png")
    return bytes
      .subarray(0, 8)
      .equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]));
  return (
    bytes.length >= 3 &&
    bytes[0] === 255 &&
    bytes[1] === 216 &&
    bytes[2] === 255
  );
}

export async function verifyUploadedDocument(
  ticket: UploadTicket,
  storage: UploadStorage,
  timeoutMs = 15_000,
): Promise<{ path: string; generation: string }> {
  const parsed = ticketSchema.safeParse(ticket);
  if (!parsed.success || !Number.isFinite(timeoutMs) || timeoutMs <= 0)
    throw new UploadRejected();
  const controller = new AbortController();
  let timer: ReturnType<typeof setTimeout> | undefined;
  const operation = async () => {
    const path = uploadObjectPath(parsed.data);
    const object = await storage.read(path, controller.signal);
    if (controller.signal.aborted) throw new UploadRejected();
    if (!/^[1-9][0-9]{0,19}$/.test(object.generation))
      throw new UploadRejected();
    if (
      object.size !== ticket.document.size ||
      object.size > MAX_DOCUMENT_BYTES ||
      object.contentType !== ticket.document.type
    )
      throw new UploadRejected(true);
    const hash = createHash("sha256");
    let size = 0;
    let header = Buffer.alloc(0);
    for await (const chunk of object.body) {
      if (controller.signal.aborted || !(chunk instanceof Uint8Array))
        throw new UploadRejected();
      size += chunk.byteLength;
      if (size > ticket.document.size || size > MAX_DOCUMENT_BYTES)
        throw new UploadRejected(true);
      if (header.length < 8)
        header = Buffer.concat([header, chunk.subarray(0, 8 - header.length)]);
      hash.update(chunk);
    }
    if (
      size !== ticket.document.size ||
      hash.digest("hex") !== ticket.document.sha256 ||
      !matchesSignature(header, ticket.document.type)
    )
      throw new UploadRejected(true);
    return { path, generation: object.generation };
  };
  try {
    return await Promise.race([
      operation(),
      new Promise<never>((_, reject) => {
        timer = setTimeout(() => {
          controller.abort();
          reject(new UploadRejected());
        }, timeoutMs);
      }),
    ]);
  } catch (error) {
    throw error instanceof UploadRejected ? error : new UploadRejected();
  } finally {
    clearTimeout(timer);
    controller.abort();
  }
}
