/**
 * Filesystem-backend download proxy.
 *
 * The `FilesystemBackend.presignDownload(...)` returns a CRM-internal URL of
 * the form `/api/storage/<hmac-signed-token>`. This route verifies the HMAC,
 * checks expiry, enforces single-use via a short Redis cache, then streams
 * the file out with explicit `Content-Type` + `Content-Disposition`.
 *
 * §14.9a mitigations exercised here:
 * - HMAC verification (timingSafeEqual via filesystem.verifyProxyToken)
 * - expiry check (token includes `e` epoch seconds)
 * - single-use replay protection via short Redis SET-NX
 * - Node runtime only (no edge); explicit headers so Next.js doesn't try to
 *   process the bytes (no image optimization, no streaming transforms)
 */

import { createReadStream } from 'node:fs';
import * as fs from 'node:fs/promises';
import { Readable } from 'node:stream';

import { NextRequest, NextResponse } from 'next/server';

import { ALLOWED_MIME_TYPES, MAX_FILE_SIZE } from '@/lib/constants/file-validation';
import {
  AppError,
  errorResponse,
  ForbiddenError,
  NotFoundError,
  ValidationError,
} from '@/lib/errors';
import { logger } from '@/lib/logger';
import { redis } from '@/lib/redis';
import { FilesystemBackend, getStorageBackend } from '@/lib/storage';
import { verifyProxyToken } from '@/lib/storage/filesystem';
import { isPdfMagic } from '@/lib/services/berth-pdf-parser';

export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';

// Replay-protection TTL must outlive the token itself, otherwise the
// dedup key expires and the same token can be redeemed twice. We pin it
// to the token's own expiry (clamped to a 25-day ceiling so a forged
// far-future token can't pollute Redis indefinitely). Send-out emails
// mint 24-hour tokens so the typical TTL is 24h + a small buffer.
const REPLAY_TTL_FLOOR_SECONDS = 60; // never below 60s (post-expiry tail).
const REPLAY_TTL_CEILING_SECONDS = 25 * 24 * 60 * 60; // 25 days.

export async function GET(
  _req: NextRequest,
  ctx: { params: Promise<{ token: string }> },
): Promise<NextResponse> {
  const { token } = await ctx.params;

  const backend = await getStorageBackend();
  if (!(backend instanceof FilesystemBackend)) {
    return errorResponse(new NotFoundError('storage proxy'));
  }

  const result = verifyProxyToken(token, backend.getHmacSecret(), 'get');
  if (!result.ok) {
    logger.warn({ reason: result.reason }, 'Storage proxy token rejected');
    return errorResponse(new ForbiddenError('Invalid or expired token'));
  }
  const { payload } = result;

  let absolutePath: string;
  try {
    absolutePath = backend.resolveKeyForProxy(payload.k);
  } catch (err) {
    logger.warn({ err, key: payload.k }, 'Storage proxy key resolution failed');
    return errorResponse(new ValidationError('Invalid key'));
  }

  // Confirm the file is servable BEFORE burning the single-use replay key
  // (audit M18). The old order consumed the SET-NX key first, so a transient
  // `fs.stat` failure / NFS hiccup / ENOENT permanently bricked the emailed
  // URL ("Token already used" for its full life). Now a stat failure leaves
  // the token unused and a genuine retry succeeds.
  let size: number;
  try {
    const stat = await fs.stat(absolutePath);
    if (!stat.isFile()) {
      return errorResponse(new NotFoundError('file'));
    }
    size = stat.size;
  } catch (err) {
    const code = (err as NodeJS.ErrnoException).code;
    if (code === 'ENOENT') {
      return errorResponse(new NotFoundError('file'));
    }
    return errorResponse(err);
  }

  // Single-use enforcement. SET NX with a TTL pinned to the token's own
  // expiry so the dedup window never closes before the token does. Using
  // the body half of the token as the dedup key (signature included
  // would also work but body is enough - a reused token has the same body).
  // Claimed only now - after the file is confirmed servable - so an earlier
  // transient error doesn't permanently consume the token (audit M18).
  const replayKey = `storage:proxy:seen:${token.split('.')[0]}`;
  const remainingSeconds = Math.max(
    REPLAY_TTL_FLOOR_SECONDS,
    Math.min(REPLAY_TTL_CEILING_SECONDS, payload.e - Math.floor(Date.now() / 1000) + 60),
  );
  const setOk = await redis.set(replayKey, '1', 'EX', remainingSeconds, 'NX');
  if (setOk !== 'OK') {
    logger.warn({ key: payload.k }, 'Storage proxy token replay rejected');
    return errorResponse(new ForbiddenError('Token already used'));
  }

  // Convert the Node Readable into a Web ReadableStream for NextResponse.
  // If the stream fails after this point we DEL the replay key so the
  // customer's retry isn't bricked (audit M18) - the dedup intent is "one
  // successful download", not "one attempt".
  const nodeStream = createReadStream(absolutePath);
  nodeStream.on('error', (err) => {
    logger.warn({ err, key: payload.k }, 'Storage proxy stream failed; releasing replay key');
    void redis.del(replayKey).catch(() => undefined);
  });
  const webStream = Readable.toWeb(nodeStream) as unknown as ReadableStream<Uint8Array>;

  const headers = new Headers();
  // L17(a): constrain the served Content-Type to a known-safe allow-list.
  // `payload.c` is issuer-signed (not attacker-forgeable) but a future buggy
  // issuer could mint an active type (e.g. text/html) that a browser would
  // render inline. Anything off the allow-list is served as a download with
  // a generic octet-stream type; `nosniff` is set unconditionally below.
  const tokenContentType = payload.c && ALLOWED_MIME_TYPES.has(payload.c) ? payload.c : null;
  headers.set('Content-Type', tokenContentType ?? 'application/octet-stream');
  if (!tokenContentType) {
    // Force download for any non-allow-listed type so an unexpected
    // content-type can never be rendered inline by the browser.
    headers.set(
      'Content-Disposition',
      `attachment; filename="${(payload.f ?? 'download').replace(/"/g, '')}"`,
    );
  }
  headers.set('Content-Length', String(size));
  if (payload.f) {
    // RFC 5987 - quote the filename and provide a UTF-8 fallback.
    const safe = payload.f.replace(/"/g, '');
    headers.set(
      'Content-Disposition',
      `attachment; filename="${safe}"; filename*=UTF-8''${encodeURIComponent(payload.f)}`,
    );
  }
  headers.set('Cache-Control', 'private, no-store');
  headers.set('X-Content-Type-Options', 'nosniff');

  return new NextResponse(webStream, { status: 200, headers });
}

/**
 * Filesystem-backend upload proxy. The presigned URL minted by
 * `FilesystemBackend.presignUpload` points here. Without this handler the
 * browser-driven berth-PDF / brochure uploads would 405 in filesystem
 * deployments - the entire pluggable-storage abstraction relied on the
 * GET-only counterpart for downloads.
 *
 * Same token-verify + single-use replay protection as GET, plus:
 *   - Hard size cap (rejects oversized bodies before any disk I/O).
 *   - Magic-byte check when the issuer declared content-type=application/pdf
 *     (matches the §14.6 §6c/§7c invariant: every upload path verifies
 *     bytes server-side, not just at the client).
 */
export async function PUT(
  req: NextRequest,
  ctx: { params: Promise<{ token: string }> },
): Promise<NextResponse> {
  const { token } = await ctx.params;

  const backend = await getStorageBackend();
  if (!(backend instanceof FilesystemBackend)) {
    return errorResponse(new NotFoundError('storage proxy'));
  }

  const result = verifyProxyToken(token, backend.getHmacSecret(), 'put');
  if (!result.ok) {
    logger.warn({ reason: result.reason }, 'Storage proxy upload token rejected');
    return errorResponse(new ForbiddenError('Invalid or expired token'));
  }
  const { payload } = result;

  // Separate replay namespace from GET so a token can validly serve one
  // upload AND one download (the issuer only mints the second), but a
  // PUT cannot be replayed against itself.
  const replayKey = `storage:proxy:put:${token.split('.')[0]}`;
  const remainingSeconds = Math.max(
    REPLAY_TTL_FLOOR_SECONDS,
    Math.min(REPLAY_TTL_CEILING_SECONDS, payload.e - Math.floor(Date.now() / 1000) + 60),
  );
  const setOk = await redis.set(replayKey, '1', 'EX', remainingSeconds, 'NX');
  if (setOk !== 'OK') {
    logger.warn({ key: payload.k }, 'Storage proxy upload token replay rejected');
    return errorResponse(new ForbiddenError('Token already used'));
  }

  // Effective byte cap. The token may carry a per-port `b` cap (from
  // `system_settings.berth_pdf_max_upload_mb`); enforce the tighter of that
  // and the global `MAX_FILE_SIZE` ceiling. Without this (audit M17) the
  // proxy enforced only the global 50 MB and a rep could write 50 MB to a
  // berth advertised as 15 MB-capped.
  const effectiveCap =
    typeof payload.b === 'number' && Number.isFinite(payload.b) && payload.b > 0
      ? Math.min(MAX_FILE_SIZE, payload.b)
      : MAX_FILE_SIZE;

  // Pre-flight size check via Content-Length so a malicious caller can't
  // exhaust disk by streaming hundreds of MB before we look at the body.
  const contentLengthHeader = req.headers.get('content-length');
  const contentLength = contentLengthHeader ? Number(contentLengthHeader) : NaN;
  if (Number.isFinite(contentLength) && contentLength > effectiveCap) {
    return errorResponse(
      new AppError(
        413,
        `File exceeds ${effectiveCap} byte cap (Content-Length: ${contentLength})`,
        'PAYLOAD_TOO_LARGE',
      ),
    );
  }

  if (!req.body) {
    return errorResponse(new ValidationError('Empty body'));
  }

  // Read the body into a buffer with a hard cap. Filesystem deployments are
  // small-tenant (single-node only - see FilesystemBackend boot guard) so
  // the ceiling fits comfortably in heap; no streaming needed.
  let buffer: Buffer;
  try {
    const chunks: Buffer[] = [];
    let total = 0;
    const reader = req.body.getReader();
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      total += value.byteLength;
      if (total > effectiveCap) {
        try {
          await reader.cancel();
        } catch {
          /* ignore */
        }
        return errorResponse(
          new AppError(413, `File exceeds ${effectiveCap} byte cap`, 'PAYLOAD_TOO_LARGE'),
        );
      }
      chunks.push(Buffer.from(value));
    }
    buffer = Buffer.concat(chunks);
  } catch (err) {
    logger.warn({ err, key: payload.k }, 'Storage proxy upload read failed');
    return errorResponse(new ValidationError('Upload read failed'));
  }

  // Magic-byte gate: when the token was minted with `c=application/pdf`
  // (the only consumer today - berth PDFs + brochures), refuse anything
  // that isn't actually a PDF. Mirrors the post-upload check in
  // berth-pdf.service.ts so the two paths behave identically.
  if (payload.c === 'application/pdf' && !isPdfMagic(buffer)) {
    return errorResponse(
      new ValidationError('Uploaded file failed PDF magic-byte check (does not start with %PDF-).'),
    );
  }

  try {
    await backend.put(payload.k, buffer, {
      contentType: payload.c ?? 'application/octet-stream',
    });
  } catch (err) {
    logger.error({ err, key: payload.k }, 'Storage proxy upload write failed');
    return errorResponse(err);
  }

  return NextResponse.json({ ok: true, key: payload.k, sizeBytes: buffer.length }, { status: 200 });
}
