import type { StorageDriver } from "./driver.ts";
import { accepts, contentTypeOf, extForType, extOf } from "./mime.ts";
import { UploadError, type UploadedFile } from "./upload.ts";

export interface ResumableUploadSession {
  id: string;
  key: string;
  name: string;
  type: string;
  size: number;
  chunkSize: number;
  totalChunks: number;
  createdAt: number;
  expiresAt: number;
  chunks: Record<number, Uint8Array>;
  digests: Record<number, string>;
}

export interface ResumableSessionStore {
  get(id: string): Promise<ResumableUploadSession | null>;
  put(session: ResumableUploadSession): Promise<void>;
  delete(id: string): Promise<void>;
  list(): Promise<ResumableUploadSession[]>;
}

export function memoryResumableSessionStore(): ResumableSessionStore {
  const sessions = new Map<string, ResumableUploadSession>();
  return {
    async get(id) {
      return sessions.get(id) ?? null;
    },
    async put(session) {
      sessions.set(session.id, session);
    },
    async delete(id) {
      sessions.delete(id);
    },
    async list() {
      return [...sessions.values()];
    },
  };
}

export interface ResumableUploadManagerOptions {
  driver: StorageDriver;
  sessions?: ResumableSessionStore;
  maxBytes?: number;
  chunkSize?: number;
  maxSessions?: number;
  ttlMs?: number;
  accept?: string[];
  prefix?: string;
  publicUrl?: (key: string) => string | null;
  now?: () => number;
}

export interface CreateResumableUpload {
  name: string;
  type?: string;
  size: number;
  chunkSize?: number;
}

export interface ResumableChunkResult {
  receivedChunks: number;
  totalChunks: number;
  complete: boolean;
  file?: UploadedFile;
}

export interface ResumableUploadManager {
  create(input: CreateResumableUpload): Promise<ResumableUploadSession>;
  uploadChunk(
    id: string,
    index: number,
    data: Uint8Array,
    sha256?: string,
  ): Promise<ResumableChunkResult>;
  status(
    id: string,
  ): Promise<{ received: number[]; totalChunks: number; expiresAt: number } | null>;
  cancel(id: string): Promise<boolean>;
  prune(): Promise<number>;
}

function safePrefix(prefix: string): string {
  const value = prefix.replace(/^\/+|\/+$/g, "");
  if (
    value &&
    (!/^[A-Za-z0-9._/-]+$/.test(value) ||
      value.split("/").some((part) => !part || part === "." || part === ".."))
  ) {
    throw new UploadError("unsafe upload prefix", 400);
  }
  return value;
}

async function digest(data: Uint8Array): Promise<string> {
  return Array.from(
    new Uint8Array(await crypto.subtle.digest("SHA-256", data as BufferSource)),
    (byte) => byte.toString(16).padStart(2, "0"),
  ).join("");
}

export function createResumableUploadManager(
  options: ResumableUploadManagerOptions,
): ResumableUploadManager {
  const sessions = options.sessions ?? memoryResumableSessionStore();
  const maxBytes = options.maxBytes ?? 100 * 1024 * 1024;
  const defaultChunkSize = options.chunkSize ?? 1024 * 1024;
  const maxSessions = options.maxSessions ?? 1000;
  const ttlMs = options.ttlMs ?? 24 * 60 * 60_000;
  if (!Number.isInteger(maxBytes) || maxBytes < 1)
    throw new RangeError("resumable maxBytes must be positive");
  if (!Number.isInteger(defaultChunkSize) || defaultChunkSize < 1)
    throw new RangeError("resumable chunkSize must be positive");
  if (!Number.isInteger(maxSessions) || maxSessions < 1)
    throw new RangeError("resumable maxSessions must be positive");
  if (!Number.isFinite(ttlMs) || ttlMs <= 0)
    throw new RangeError("resumable ttlMs must be positive");
  const prefix = safePrefix(options.prefix ?? "resumable");
  const now = options.now ?? Date.now;
  const finalizing = new Map<string, Promise<UploadedFile>>();

  const manager: ResumableUploadManager = {
    async create(input) {
      await manager.prune();
      if (!Number.isInteger(input.size) || input.size < 1 || input.size > maxBytes)
        throw new UploadError(`invalid upload size (max ${maxBytes} bytes)`, 413);
      const type = input.type || contentTypeOf(input.name);
      if (!accepts(options.accept, { type, name: input.name }))
        throw new UploadError(`file type not allowed: ${type}`, 415);
      if ((await sessions.list()).length >= maxSessions)
        throw new UploadError("too many active resumable uploads", 429);
      const chunkSize = input.chunkSize ?? defaultChunkSize;
      if (!Number.isInteger(chunkSize) || chunkSize < 1 || chunkSize > maxBytes)
        throw new UploadError("invalid resumable chunk size", 400);
      const id = crypto.randomUUID();
      const extension = extOf(input.name) || extForType(type);
      const filename = extension ? `${id}.${extension}` : id;
      const timestamp = now();
      const session: ResumableUploadSession = {
        id,
        key: prefix ? `${prefix}/${filename}` : filename,
        name: input.name.split(/[\\/]/).pop()?.slice(0, 255) || "file",
        type,
        size: input.size,
        chunkSize,
        totalChunks: Math.ceil(input.size / chunkSize),
        createdAt: timestamp,
        expiresAt: timestamp + ttlMs,
        chunks: {},
        digests: {},
      };
      await sessions.put(session);
      return session;
    },

    async uploadChunk(id, index, data, expectedDigest) {
      const session = await sessions.get(id);
      if (!session || session.expiresAt <= now())
        throw new UploadError("upload session not found or expired", 404);
      if (!Number.isInteger(index) || index < 0 || index >= session.totalChunks)
        throw new UploadError("invalid chunk index", 400);
      const expectedSize =
        index === session.totalChunks - 1
          ? session.size - session.chunkSize * (session.totalChunks - 1)
          : session.chunkSize;
      if (data.byteLength !== expectedSize)
        throw new UploadError(`invalid chunk size (expected ${expectedSize})`, 400);
      const actualDigest = await digest(data);
      if (expectedDigest && expectedDigest.toLowerCase() !== actualDigest)
        throw new UploadError("chunk checksum mismatch", 422);
      if (session.digests[index] && session.digests[index] !== actualDigest)
        throw new UploadError("chunk already uploaded with different content", 409);
      session.chunks[index] ??= data.slice();
      session.digests[index] = actualDigest;
      await sessions.put(session);
      const receivedChunks = Object.keys(session.chunks).length;
      if (receivedChunks !== session.totalChunks) {
        return { receivedChunks, totalChunks: session.totalChunks, complete: false };
      }
      let completion = finalizing.get(id);
      if (!completion) {
        completion = (async () => {
          const output = new Uint8Array(session.size);
          let offset = 0;
          for (let chunk = 0; chunk < session.totalChunks; chunk++) {
            const value = session.chunks[chunk];
            if (!value) throw new UploadError("upload is missing a chunk", 409);
            output.set(value, offset);
            offset += value.byteLength;
          }
          await options.driver.put(session.key, output, {
            contentType: session.type,
            filename: session.name,
          });
          await sessions.delete(id);
          return {
            key: session.key,
            url: options.publicUrl?.(session.key) ?? null,
            name: session.name,
            type: session.type,
            size: session.size,
          };
        })().finally(() => finalizing.delete(id));
        finalizing.set(id, completion);
      }
      return {
        receivedChunks,
        totalChunks: session.totalChunks,
        complete: true,
        file: await completion,
      };
    },

    async status(id) {
      const session = await sessions.get(id);
      return session
        ? {
            received: Object.keys(session.chunks)
              .map(Number)
              .sort((a, b) => a - b),
            totalChunks: session.totalChunks,
            expiresAt: session.expiresAt,
          }
        : null;
    },
    async cancel(id) {
      if (!(await sessions.get(id))) return false;
      await sessions.delete(id);
      return true;
    },
    async prune() {
      let removed = 0;
      for (const session of await sessions.list()) {
        if (session.expiresAt <= now()) {
          await sessions.delete(session.id);
          removed++;
        }
      }
      return removed;
    },
  };
  return manager;
}
