import type { Context } from 'hono';
import { Hono } from 'hono';

import {
  LocalStorageService,
  StorageError,
  type StoredObject,
  storageService,
} from './service.js';

export interface StorageAuthorizationRequest {
  bucketId: string;
  objectPath: string;
}

export type StorageAuthorizer = (
  context: Context,
  request: StorageAuthorizationRequest,
) => boolean | Response | Promise<boolean | Response>;

export interface StorageRouteOptions {
  authorize?: StorageAuthorizer;
}

interface ByteRange {
  start: number;
  end: number;
}

type AccessMode = 'public' | 'authenticated' | 'signed';

function routeError(error: unknown, context: Context): Response {
  const storageError =
    error instanceof StorageError
      ? error
      : new StorageError('Unexpected local storage failure.', 'storage_error', 500);

  return context.json(
    {
      statusCode: storageError.statusCode,
      error: storageError.code,
      message: storageError.message,
    },
    storageError.statusCode,
  );
}

function routeParameter(value: string | undefined, label: string): string {
  if (!value) {
    throw new StorageError(`${label} is required.`, 'invalid_path', 400);
  }
  // Hono decodes named parameters once. Decoding again would turn a literal
  // "%2e%2e" filename into traversal syntax.
  return value;
}

function parseRange(value: string | undefined, size: number): ByteRange | null {
  if (!value) {
    return null;
  }
  const match = /^bytes=(\d*)-(\d*)$/.exec(value.trim());
  if (!match || (!match[1] && !match[2]) || size === 0) {
    throw new StorageError('Requested byte range is not satisfiable.', 'invalid_range', 416);
  }

  let start: number;
  let end: number;
  if (!match[1]) {
    const suffixLength = Number(match[2]);
    if (!Number.isSafeInteger(suffixLength) || suffixLength <= 0) {
      throw new StorageError('Requested byte range is not satisfiable.', 'invalid_range', 416);
    }
    start = Math.max(0, size - suffixLength);
    end = size - 1;
  } else {
    start = Number(match[1]);
    end = match[2] ? Number(match[2]) : size - 1;
  }

  if (
    !Number.isSafeInteger(start) ||
    !Number.isSafeInteger(end) ||
    start < 0 ||
    start >= size ||
    end < start
  ) {
    throw new StorageError('Requested byte range is not satisfiable.', 'invalid_range', 416);
  }
  return { start, end: Math.min(end, size - 1) };
}

function quotedEtag(value: string): string {
  return value.startsWith('"') && value.endsWith('"') ? value : `"${value.replace(/["\r\n]/g, '')}"`;
}

function contentDisposition(download: boolean | string, objectPath: string): string | null {
  if (!download) {
    return null;
  }
  const candidate = typeof download === 'string' ? download : objectPath.split('/').at(-1) || 'download';
  const filename = candidate.replace(/["\\\r\n]/g, '_').slice(0, 255) || 'download';
  return `attachment; filename="${filename}"; filename*=UTF-8''${encodeURIComponent(filename)}`;
}

function cacheHeader(object: StoredObject): string {
  const seconds = object.cacheControl && /^\d{1,10}$/.test(object.cacheControl)
    ? object.cacheControl
    : '0';
  return `${object.isPublic ? 'public' : 'private'}, max-age=${seconds}`;
}

function responseForObject(
  context: Context,
  object: StoredObject,
  download: boolean | string,
): Response {
  const etag = object.etag ? quotedEtag(object.etag) : null;
  if (etag && context.req.header('If-None-Match') === etag) {
    return new Response(null, { status: 304, headers: { ETag: etag } });
  }

  let range: ByteRange | null;
  try {
    range = parseRange(context.req.header('Range'), object.size);
  } catch (error) {
    if (error instanceof StorageError && error.statusCode === 416) {
      return context.json(
        { statusCode: 416, error: error.code, message: error.message },
        416,
        { 'Content-Range': `bytes */${object.size}` },
      );
    }
    throw error;
  }

  const start = range?.start ?? 0;
  const end = range?.end ?? Math.max(0, object.size - 1);
  const body = range ? object.bytes.subarray(start, end + 1) : object.bytes;
  const headers = new Headers({
    'Accept-Ranges': 'bytes',
    'Cache-Control': cacheHeader(object),
    'Content-Length': String(body.byteLength),
    'Content-Type': object.contentType,
    'Last-Modified': new Date(object.updatedAt).toUTCString(),
    'X-Content-Type-Options': 'nosniff',
  });
  if (etag) {
    headers.set('ETag', etag);
  }
  if (range) {
    headers.set('Content-Range', `bytes ${start}-${end}/${object.size}`);
  }
  const disposition = contentDisposition(download, object.name);
  if (disposition) {
    headers.set('Content-Disposition', disposition);
  }

  return new Response(context.req.method === 'HEAD' ? null : body, {
    status: range ? 206 : 200,
    headers,
  });
}

export function createStorageRoutes(
  service: LocalStorageService = storageService,
  options: StorageRouteOptions = {},
): Hono {
  const router = new Hono();

  const serve = async (context: Context, mode: AccessMode): Promise<Response> => {
    try {
      const bucketId = routeParameter(context.req.param('bucket'), 'Bucket name');
      const objectPath = routeParameter(context.req.param('object'), 'Object path');
      let download: boolean | string = false;

      if (mode === 'public') {
        if (!(await service.bucketIsPublic(bucketId))) {
          throw new StorageError('Object not found.', 'object_not_found', 404);
        }
      } else if (mode === 'authenticated') {
        if (!options.authorize) {
          throw new StorageError('Authentication is required.', 'authentication_required', 401);
        }
        const authorization = await options.authorize(context, { bucketId, objectPath });
        if (authorization instanceof Response) {
          return authorization;
        }
        if (!authorization) {
          throw new StorageError('Access to this object is forbidden.', 'forbidden', 403);
        }
      } else {
        const token = context.req.query('token');
        const access = service.verifySignedAccess(token ?? '', bucketId, objectPath);
        download = access.download;
      }

      const object = await service.readObject(bucketId, objectPath);
      return responseForObject(context, object, download);
    } catch (error) {
      return routeError(error, context);
    }
  };

  router.get('/object/public/:bucket/:object{.+}', (context) => serve(context, 'public'));
  router.on('HEAD', '/object/public/:bucket/:object{.+}', (context) => serve(context, 'public'));
  router.get('/object/authenticated/:bucket/:object{.+}', (context) => serve(context, 'authenticated'));
  router.on('HEAD', '/object/authenticated/:bucket/:object{.+}', (context) => serve(context, 'authenticated'));
  router.get('/object/sign/:bucket/:object{.+}', (context) => serve(context, 'signed'));
  router.on('HEAD', '/object/sign/:bucket/:object{.+}', (context) => serve(context, 'signed'));

  return router;
}

export const storageRoutes = createStorageRoutes();
export default storageRoutes;
