/**
 * File upload proxy
 *
 * `POST /api/:org/file-configs/:id/upload?filename=<encoded>` accepts the
 * file bytes as the raw request body (NOT multipart) and streams them
 * through `@aws-sdk/lib-storage` `Upload` to the configured bucket using
 * the server-side decrypted credentials.
 *
 * Why raw body and not multipart? Hono's `c.req.formData()` materializes
 * the entire multipart body before yielding the file, which defeats the
 * point of streaming for the 100 MB cap. Accepting the file directly as
 * the POST body keeps memory bounded.
 *
 * Why `Upload` (lib-storage) and not a single `PutObjectCommand`? Upload
 * automatically switches to multipart for large files, uploads parts in
 * parallel, and survives transient part failures — none of which a
 * single-shot PutObject offers when the SDK can't replay a stream.
 */

import { Upload } from "@aws-sdk/lib-storage";
import { Hono } from "hono";
import { HTTPException } from "hono/http-exception";
import type { StudioContext } from "@/core/studio-context";
import {
  buildPublicUrl,
  buildS3Client,
  FileConfigForbiddenError,
  resolveFileConfig,
} from "@/file-storage/file-config-s3";
import {
  MAX_UPLOAD_BYTES,
  UploadRejected,
  assertAllowed,
  buildObjectKey,
} from "@/file-storage/upload-policy";

type Variables = { studioContext: StudioContext };

export const createFileUploadRoutes = () => {
  const app = new Hono<{ Variables: Variables }>();

  app.post("/file-configs/:id/upload", async (c) => {
    const ctx = c.get("studioContext");
    const userId = ctx.auth?.user?.id;
    if (!userId) {
      throw new HTTPException(401, { message: "Unauthorized" });
    }
    const orgId = ctx.organization?.id;
    if (!orgId) {
      throw new HTTPException(400, { message: "Organization required" });
    }
    // Gate behind a write-tier file-config permission. We pick
    // FILE_CONFIG_UPDATE specifically so that if the `file-configs:manage`
    // group ever splits into read/write capabilities, this upload route
    // lands on the write side. Using a read-only tool here (e.g.
    // FILE_OBJECTS_LIST) would silently expose write access to read-only
    // principals after such a split.
    await ctx.access.check("FILE_CONFIG_UPDATE");

    const configId = c.req.param("id");
    if (!configId) {
      throw new HTTPException(400, { message: "Missing config id" });
    }

    const filename = c.req.query("filename");
    if (!filename) {
      throw new HTTPException(400, {
        message: "Missing `filename` query parameter",
      });
    }

    const contentType =
      c.req.header("content-type") || "application/octet-stream";
    const contentLengthHeader = c.req.header("content-length");
    const contentLength = contentLengthHeader
      ? Number(contentLengthHeader)
      : NaN;
    if (!Number.isFinite(contentLength) || contentLength <= 0) {
      throw new HTTPException(411, {
        message: "Content-Length required and must be a positive number",
      });
    }

    try {
      assertAllowed(contentType, contentLength);
    } catch (err) {
      if (err instanceof UploadRejected) {
        throw new HTTPException(contentLength > MAX_UPLOAD_BYTES ? 413 : 400, {
          message: err.message,
        });
      }
      throw err;
    }

    const body = c.req.raw.body;
    if (!body) {
      throw new HTTPException(400, { message: "Empty request body" });
    }

    // Counting transform: the Content-Length header check above is a
    // client claim. `Upload` from lib-storage doesn't validate the
    // stream against ContentLength, so a malicious client could lie in
    // the header and stream past the cap. Pipe through a transform that
    // tallies bytes and aborts mid-stream if we cross MAX_UPLOAD_BYTES,
    // which propagates up through `upload.done()` as a thrown error.
    let bytesRead = 0;
    let oversizeAborted = false;
    const guarded = body.pipeThrough(
      new TransformStream<Uint8Array, Uint8Array>({
        transform(chunk, controller) {
          bytesRead += chunk.byteLength;
          if (bytesRead > MAX_UPLOAD_BYTES) {
            oversizeAborted = true;
            controller.error(
              new UploadRejected(
                `File too large: stream exceeded ${MAX_UPLOAD_BYTES} bytes.`,
              ),
            );
            return;
          }
          controller.enqueue(chunk);
        },
      }),
    );

    let fileCfg;
    try {
      fileCfg = await resolveFileConfig(
        ctx.storage.orgFileConfigs,
        ctx.storage.orgSites,
        configId,
        orgId,
      );
    } catch (err) {
      if (err instanceof FileConfigForbiddenError) {
        throw new HTTPException(403, { message: err.message });
      }
      throw err;
    }

    const key = buildObjectKey({
      prefix: fileCfg.info.prefix,
      filename,
    });

    const client = buildS3Client(fileCfg);

    try {
      const upload = new Upload({
        client,
        params: {
          Bucket: fileCfg.info.bucket,
          Key: key,
          Body: guarded,
          ContentType: contentType,
          // Intentionally omit ContentLength: when the client lies in
          // the header, lib-storage doesn't enforce the stream against
          // it; relying on the counting transform above is the only
          // reliable check.
        },
        // 8 MB per part keeps part count reasonable for 100 MB uploads
        // (~13 parts) while staying well above the 5 MB S3 minimum.
        partSize: 8 * 1024 * 1024,
        queueSize: 4,
      });
      await upload.done();
    } catch (err) {
      if (oversizeAborted) {
        throw new HTTPException(413, {
          message: `File too large: stream exceeded ${MAX_UPLOAD_BYTES} bytes.`,
        });
      }
      throw new HTTPException(502, {
        message: `Upload to bucket failed: ${
          err instanceof Error ? err.message : String(err)
        }`,
      });
    }

    return c.json({
      key,
      publicUrl: buildPublicUrl(fileCfg.info, key),
      contentType,
      size: bytesRead,
    });
  });

  return app;
};
