import { logError } from "@/lib/logger";
import type { TDashboardStorage } from "@/lib/storage/dashboard-storage";
import { Readable } from "node:stream";
import { safeRelativePath } from "./dashboard-bundle.validator";

/**
 * THE serving core. Both the authenticated console path and the public
 * share-link path call `serveDashboardAsset` — there is exactly one
 * implementation of the perimeter, so a header can never be loosened on one
 * path and silently missed on the other.
 *
 * Callers differ only in HOW they establish the right to serve (console
 * session + canUserViewDashboard, vs. a share token). Once resolved, they hand
 * this function the same shape and get back a byte-identical response.
 */

/**
 * Content-Security-Policy for served bundle assets.
 *
 * This is the exfiltration perimeter. A dashboard bundle is arbitrary
 * third-party JS running inside our console's origin, so the policy starts at
 * `default-src 'none'` and re-opens only what a self-contained static bundle
 * genuinely needs.
 *
 * Two directives are load-bearing and must not be relaxed:
 *   connect-src 'none'     — no fetch/XHR/WebSocket/sendBeacon. A bundle cannot
 *                            phone home with anything it can see.
 *   frame-ancestors 'self' — only our own console may frame it. A PUBLIC viewer
 *                            is strictly less trusted than an authed one, so
 *                            this is never widened for share links either.
 *
 * script-src/style-src carry 'unsafe-inline' because these bundles inline their
 * JS and CSS by contract. There is deliberately NO 'unsafe-eval'.
 */
export const DASHBOARD_CSP = [
  "default-src 'none'",
  "script-src 'unsafe-inline' 'self'",
  "style-src 'unsafe-inline' 'self'",
  "img-src 'self' data:",
  "font-src 'self' data:",
  "connect-src 'none'",
  "form-action 'none'",
  "base-uri 'none'",
  "frame-ancestors 'self'",
].join("; ");

/** The complete non-content header set applied to every served asset. */
export const applyServeHeaders = (headers: Headers) => {
  headers.set("Content-Security-Policy", DASHBOARD_CSP);
  headers.set("X-Content-Type-Options", "nosniff");
  headers.set("X-Frame-Options", "SAMEORIGIN");
  headers.set("Cache-Control", "private, no-store");
  headers.set("Referrer-Policy", "no-referrer");
};

export type ServeVersion = {
  id: string;
  storage_prefix: string;
  entry_point: string;
};

/**
 * Who is asking. Exactly one of these is non-null; it decides only how the view
 * is attributed in dashboard_view_log, never what gets served or with which
 * headers.
 */
export type ServeViewer = {
  consoleUserId: string | null;
  publicLinkId: string | null;
};

export type ServeOutcome =
  | { ok: true; response: Response; servedEntryPoint: boolean }
  | { ok: false; status: 400 | 404; message: string };

export type ServeAssetParams = {
  storage: TDashboardStorage;
  dashboardId: string;
  version: ServeVersion;
  /**
   * Decoded, bundle-relative path. "" means "the entry point" (a bare /serve).
   * MUST already be percent-decoded by the caller so the safety check below
   * sees `../` rather than `%2e%2e%2f`.
   */
  requestedPath: string;
  viewer: ServeViewer;
  ipAddress: string | null;
  /** Records the view. Called for the entry point only; failures are swallowed. */
  recordView: (entry: {
    dashboardId: string;
    versionId: string;
    consoleUserId: string | null;
    publicLinkId: string | null;
    ipAddress: string | null;
  }) => Promise<void>;
  /**
   * Extra bookkeeping on an entry-point hit — the public path uses this to
   * bump dashboard_public_links.view_count. Failures are swallowed.
   */
  onEntryPointServed?: () => Promise<void>;
};

export const serveDashboardAsset = async ({
  storage,
  dashboardId,
  version,
  requestedPath,
  viewer,
  ipAddress,
  recordView,
  onEntryPointServed,
}: ServeAssetParams): Promise<ServeOutcome> => {
  // A bare /serve means "the entry point".
  const isBareServe = requestedPath === "";
  const wanted = isBareServe ? version.entry_point : requestedPath;

  // Defense in depth: the exact same path check the Bundle Contract validator
  // applies at upload time, re-applied at read time. The wildcard can never
  // escape the version prefix.
  const safePath = safeRelativePath(wanted);
  if (safePath === null) {
    return { ok: false, status: 400, message: "Invalid asset path." };
  }

  const fullPath = `${version.storage_prefix}/${safePath}`;
  const object = await storage.getObject(fullPath);
  if (!object) {
    return { ok: false, status: 404, message: "Asset not found." };
  }

  // Log the ENTRY POINT only. A dashboard load pulls dozens of sub-assets
  // through this same handler; logging each would turn one view into fifty rows
  // and make the view log useless.
  const servedEntryPoint = isBareServe || safePath === version.entry_point;
  if (servedEntryPoint) {
    try {
      await recordView({
        dashboardId,
        versionId: version.id,
        consoleUserId: viewer.consoleUserId,
        publicLinkId: viewer.publicLinkId,
        ipAddress,
      });
    } catch (err) {
      // Never let bookkeeping break serving.
      logError(err, "Failed recording dashboard view");
    }
    if (onEntryPointServed) {
      try {
        await onEntryPointServed();
      } catch (err) {
        logError(err, "Failed post-serve bookkeeping for dashboard view");
      }
    }
  }

  const headers = new Headers();
  headers.set("Content-Type", object.contentType);
  if (object.size > 0) headers.set("Content-Length", String(object.size));
  applyServeHeaders(headers);

  return {
    ok: true,
    servedEntryPoint,
    response: new Response(
      Readable.toWeb(object.stream) as unknown as ReadableStream,
      { status: 200, headers },
    ),
  };
};

/**
 * Shared URL parsing for both serve paths: everything after the `/serve`
 * marker is the bundle-relative request path. Percent-decoded here, BEFORE the
 * safety check, so `%2e%2e%2f` is caught as `../`.
 *
 * @returns the decoded relative path ("" for a bare /serve), or null on
 *          malformed percent-encoding.
 */
export const relativePathFromServeUrl = (
  requestPath: string,
  marker: string,
): string | null => {
  const at = requestPath.indexOf(marker);
  if (at === -1) return "";
  let rest = requestPath.slice(at + marker.length);
  if (rest.startsWith("/")) rest = rest.slice(1);
  if (rest === "") return "";
  try {
    return decodeURIComponent(rest);
  } catch {
    return null;
  }
};
