<script setup lang="ts">
import {
  BLOB_FALLBACK_MAX_BYTES,
  BLOB_WARNING_BYTES,
  CHUNK_SIZE,
  PREVIEW_MAX_BYTES,
  type PublicDescriptor,
} from "~/../shared/types/drop";
import {
  accessFromHash,
  decryptChunk,
  decryptManifest,
  formatBytes,
  fromBase64Url,
  keyFromHash,
} from "~/utils/crypto";
import { leaseStorageKey, retryTransient } from "~/utils/download";

const savePicker = () =>
  (
    window as Window & {
      showSaveFilePicker?: (options?: {
        suggestedName?: string;
      }) => Promise<FileSystemFileHandle>;
    }
  ).showSaveFilePicker;
const route = useRoute();
const state = ref<"loading" | "ready" | "error">("loading");
const errorMessage = ref("");
const manifest = ref<Awaited<ReturnType<typeof decryptManifest>> | null>(null);
const descriptor = ref<PublicDescriptor | null>(null);
const downloading = ref(false);
const previewing = ref(false);
const progress = ref(0);
const hasPicker = ref(false);
const decryptedBlob = ref<Blob | null>(null);
const previewUrl = ref<string | null>(null);
const fallbackUrl = ref<string | null>(null);
const fallbackBlob = ref<Blob | null>(null);
const roomId = computed(() => String(route.params.id || "").toUpperCase());
const canBlob = computed(
  () => (manifest.value?.size || 0) <= BLOB_FALLBACK_MAX_BYTES,
);
const previewable = computed(() =>
  Boolean(
    manifest.value &&
    manifest.value.size <= PREVIEW_MAX_BYTES &&
    (/^image\/(avif|gif|jpeg|png|webp)$/u.test(manifest.value.type) ||
      /^video\/(mp4|webm|ogg)$/u.test(manifest.value.type)),
  ),
);
useSeoMeta({
  title: "Private download",
  robots: "noindex, nofollow, noarchive",
});
function tokenKey() {
  return leaseStorageKey(roomId.value);
}
function replacePreviewUrl(blob: Blob) {
  if (previewUrl.value) URL.revokeObjectURL(previewUrl.value);
  previewUrl.value = URL.createObjectURL(blob);
}
function saveBlob(blob: Blob, name: string) {
  const picker = savePicker();
  if (picker)
    return picker({ suggestedName: name }).then(async (handle) => {
      const writer = await handle.createWritable();
      try {
        await writer.write(blob);
        await writer.close();
      } catch (error) {
        await writer.abort().catch(() => undefined);
        throw error;
      }
    });
  if (!fallbackUrl.value || fallbackBlob.value !== blob) {
    if (fallbackUrl.value) URL.revokeObjectURL(fallbackUrl.value);
    fallbackUrl.value = URL.createObjectURL(blob);
    fallbackBlob.value = blob;
  }
  const anchor = document.createElement("a");
  anchor.href = fallbackUrl.value;
  anchor.download = name;
  anchor.rel = "noreferrer";
  document.body.appendChild(anchor);
  anchor.click();
  anchor.remove();
}
async function load() {
  try {
    const key = keyFromHash(window.location.hash);
    const access = accessFromHash(window.location.hash);
    if (!key || !access)
      throw new Error("This private link is missing its capabilities.");
    const response = await fetch(
      `/api/drops/${encodeURIComponent(roomId.value)}`,
      { headers: { "x-quickdrop-access": access }, cache: "no-store" },
    );
    if (!response.ok)
      throw new Error(
        response.status === 410
          ? "This drop has expired or has no reads remaining."
          : "This drop could not be found.",
      );
    const data = (await response.json()) as PublicDescriptor;
    const encryptedManifest = fromBase64Url(data.manifest);
    const decoded = await decryptManifest(
      encryptedManifest,
      key,
      roomId.value,
      data.noncePrefix,
      data.chunkSize,
      data.chunkCount,
    );
    const expected = data.chunkCipherBytes.reduce(
      (sum, bytes) => sum + bytes - 16,
      0,
    );
    if (expected !== decoded.size)
      throw new Error("The drop failed its aggregate integrity check.");
    descriptor.value = data;
    manifest.value = decoded;
    state.value = "ready";
  } catch (error) {
    errorMessage.value =
      error instanceof Error ? error.message : "This drop could not be opened.";
    state.value = "error";
  }
}
async function claim(access: string): Promise<string> {
  const previous = sessionStorage.getItem(tokenKey()) || undefined;
  const response = await retryTransient(() =>
    fetch(`/api/drops/${encodeURIComponent(roomId.value)}/leases`, {
      method: "POST",
      headers: {
        "x-quickdrop-access": access,
        ...(previous ? { authorization: `Bearer ${previous}` } : {}),
      },
    }),
  );
  if (!response.ok)
    throw new Error(
      response.status === 410
        ? "No download read is available."
        : "The download could not be started.",
    );
  const lease = (await response.json()) as { token: string };
  sessionStorage.setItem(tokenKey(), lease.token);
  return lease.token;
}
async function complete(token: string) {
  const response = await retryTransient(() =>
    fetch(`/api/drops/${encodeURIComponent(roomId.value)}/complete`, {
      method: "POST",
      headers: { authorization: `Bearer ${token}` },
    }),
  );
  if (!response.ok)
    throw new Error(
      "The file was decrypted successfully, but the read could not be recorded. Keep this tab open and retry completion.",
    );
  sessionStorage.removeItem(tokenKey());
}
async function transferChunks(
  token: string,
  key: string,
  consume: (plain: Uint8Array) => Promise<void> | void,
): Promise<void> {
  if (!descriptor.value || !manifest.value)
    throw new Error("The drop is not ready.");
  const currentDescriptor = descriptor.value;
  const expectedSize = manifest.value.size;
  let total = 0;
  for (let index = 0; index < currentDescriptor.chunkCount; index++) {
    const response = await retryTransient(() =>
      fetch(`/api/drops/${encodeURIComponent(roomId.value)}/chunks/${index}`, {
        headers: { authorization: `Bearer ${token}` },
        cache: "no-store",
      }),
    );
    if (!response.ok)
      throw new Error(
        "A chunk could not be downloaded. Your read is preserved; retry the download.",
      );
    const cipher = new Uint8Array(await response.arrayBuffer());
    const expectedCipherBytes = currentDescriptor.chunkCipherBytes[index];
    if (
      !Number.isSafeInteger(expectedCipherBytes) ||
      cipher.byteLength !== expectedCipherBytes
    )
      throw new Error("The downloaded chunk has an invalid length.");
    const plain = await decryptChunk(
      cipher,
      key,
      roomId.value,
      currentDescriptor.noncePrefix,
      index,
      cipher.byteLength - 16,
      CHUNK_SIZE,
      currentDescriptor.chunkCount,
    );
    total += plain.byteLength;
    await consume(plain);
    progress.value = ((index + 1) / currentDescriptor.chunkCount) * 100;
  }
  if (total !== expectedSize)
    throw new Error(
      "The downloaded file failed its size check; your read is preserved.",
    );
}
async function decryptAll(token: string, key: string): Promise<Blob> {
  if (!manifest.value) throw new Error("The drop is not ready.");
  const type = manifest.value.type;
  const parts: Uint8Array[] = [];
  await transferChunks(token, key, (plain) => {
    parts.push(plain);
  });
  return new Blob(parts as BlobPart[], { type });
}
async function preview() {
  if (
    !previewable.value ||
    !manifest.value ||
    downloading.value ||
    previewing.value
  )
    return;
  previewing.value = true;
  downloading.value = true;
  errorMessage.value = "";
  progress.value = 0;
  try {
    const key = keyFromHash(window.location.hash)!;
    const access = accessFromHash(window.location.hash)!;
    const token = await claim(access);
    const blob = await decryptAll(token, key);
    decryptedBlob.value = blob;
    replacePreviewUrl(blob);
    try {
      await complete(token);
    } catch (error) {
      errorMessage.value =
        error instanceof Error
          ? error.message
          : "The file was decrypted successfully, but completion could not be recorded.";
    }
  } catch (error) {
    errorMessage.value =
      error instanceof Error
        ? error.message
        : "Preview failed integrity checks.";
  } finally {
    previewing.value = false;
    downloading.value = false;
  }
}
async function download() {
  if (!manifest.value || !descriptor.value || downloading.value) return;
  const metadata = manifest.value;
  if (decryptedBlob.value) {
    downloading.value = true;
    try {
      await saveBlob(decryptedBlob.value, metadata.name);
    } catch (error) {
      errorMessage.value =
        error instanceof Error ? error.message : "The file could not be saved.";
    } finally {
      downloading.value = false;
    }
    return;
  }

  const picker = savePicker();
  if (!picker && !canBlob.value) {
    errorMessage.value =
      "This file is larger than the safe browser fallback. Use a desktop browser with File System Access support.";
    return;
  }
  if (
    !picker &&
    metadata.size > BLOB_WARNING_BYTES &&
    !window.confirm(
      "This browser must assemble the file in memory. Continue and consume one read?",
    )
  )
    return;

  downloading.value = true;
  errorMessage.value = "";
  progress.value = 0;
  let writer: FileSystemWritableFileStream | null = null;
  try {
    const key = keyFromHash(window.location.hash)!;
    const access = accessFromHash(window.location.hash)!;
    // The picker must open before the read is claimed so cancelling it costs no read.
    const handle = picker
      ? await picker({ suggestedName: metadata.name })
      : null;
    const token = await claim(access);
    if (handle) {
      writer = await handle.createWritable();
      await transferChunks(token, key, (plain) =>
        writer!.write(plain as unknown as ArrayBuffer),
      );
      await writer.close();
      writer = null;
    } else {
      const blob = await decryptAll(token, key);
      decryptedBlob.value = blob;
      await saveBlob(blob, metadata.name);
    }
    await complete(token);
  } catch (error) {
    if (writer) await writer.abort().catch(() => undefined);
    errorMessage.value =
      error instanceof Error ? error.message : "Download failed.";
  } finally {
    downloading.value = false;
  }
}
onMounted(() => {
  hasPicker.value = Boolean(savePicker());
  load();
});
onBeforeUnmount(() => {
  if (previewUrl.value) URL.revokeObjectURL(previewUrl.value);
  if (fallbackUrl.value) URL.revokeObjectURL(fallbackUrl.value);
});
</script>
<template>
  <div class="download-page">
    <template v-if="state === 'ready' && manifest"
      ><p class="eyebrow text-center">Private file transfer</p>
      <h1 class="download-heading">Ready to download.</h1>
      <p class="download-intro">
        Opening a download or preview consumes one read. The file is decrypted
        one chunk at a time on this device.
      </p>
      <section class="panel download-card">
        <div class="selected-card download-file">
          <div class="selected-meta">
            <p class="selected-name">{{ manifest.name }}</p>
            <p class="selected-detail">
              {{ formatBytes(manifest.size) }} · {{ manifest.type }}
            </p>
          </div>
        </div>
        <div class="flex gap-2">
          <button
            v-if="previewable"
            class="button button-secondary button-large"
            :disabled="downloading"
            @click="preview"
          >
            {{
              previewing
                ? `Previewing… ${Math.round(progress)}%`
                : "Preview (consumes one read)"
            }}</button
          ><button
            class="button button-large"
            :disabled="downloading"
            @click="download"
          >
            {{
              downloading
                ? `Downloading… ${Math.round(progress)}%`
                : decryptedBlob
                  ? "Save file"
                  : "Download file"
            }}
          </button>
        </div>
        <div v-if="previewUrl" class="preview">
          <img
            v-if="manifest.type.startsWith('image/')"
            :src="previewUrl"
            :alt="manifest.name"
          /><video v-else :src="previewUrl" controls preload="metadata" />
        </div>
        <p v-if="!hasPicker && canBlob" class="privacy-note">
          This browser uses a bounded Blob fallback{{
            manifest.size > BLOB_WARNING_BYTES
              ? " and will warn before claiming a read"
              : ""
          }}.
        </p>
        <p v-if="!canBlob" class="privacy-note">
          Files over 256 MiB require File System Access support; no read will be
          claimed here.
        </p>
        <p v-if="errorMessage" class="error-message" role="alert">
          {{ errorMessage }}
        </p>
      </section></template
    ><template v-else
      ><h1 class="download-heading">Opening your drop.</h1>
      <section class="panel download-card state-box">
        <p v-if="state === 'loading'">Reading the encrypted descriptor…</p>
        <template v-else
          ><h2>This drop couldn't be opened.</h2>
          <p>{{ errorMessage }}</p>
          <NuxtLink class="button button-secondary button-small" to="/"
            >Send a new file</NuxtLink
          ></template
        >
      </section></template
    >
  </div>
</template>
