/**
 * Learn — the two tracks, and the host allowlist that turns a pasted link into
 * an iframe source.
 *
 * **Two tracks, and they are not the same kind of thing.** `supply` and
 * `demand` are CONCEPT material: how this market actually works, taught to the
 * GTM team that runs that side. `platform` is PIG itself — onboarding, feature
 * walkthroughs, demos. The distinction is load-bearing rather than cosmetic,
 * because the access code unlocks exactly one of them.
 *
 * **Only the platform track may be visible to a code-holder.** Someone holding
 * the share code has no account and no principal; they may see how the product
 * works, because that is a sales asset. They may not see how we source and
 * price capacity. This predicate is enforced three times on purpose — here, in
 * the API write path, and in a database CHECK constraint — because a concept
 * video becoming anon-visible through a mistake in a form is the failure that
 * matters, and a UI-only rule does not survive an API caller.
 *
 * **The allowlist is the whole XSS surface of the feature.** A learn resource
 * is a URL somebody pasted, and it ends up as an `iframe src`. So a pasted URL
 * is never stored as a source and never rendered as one: it is resolved
 * through the table below into a *provider* and an *external id*, and every
 * embed URL is rebuilt from a hardcoded template and a pattern-checked id.
 * Anything the table does not match is rejected at the write path, so a row
 * that cannot be rendered safely cannot exist.
 *
 * Adding a provider is one row here plus one host in the proxy's `frame-src`
 * (see `LEARN_FRAME_SRC_HOSTS`). Do not add a row whose URL shape has not been
 * checked against the running service — the id extraction is what decides
 * whether a hostile path becomes a trusted embed.
 *
 * **One provider is not an iframe at all.** `pig` is video PIG serves itself,
 * from `/media/learn/…` on this origin, rendered in a native `<video>`. It
 * goes through the identical funnel — a pasted string is resolved to a
 * provider and an id, and every source is rebuilt from a template — but its
 * resolved embed carries `kind: 'video'`, so a caller must branch rather than
 * assume an iframe. That is why `LearnEmbed` is a discriminated union: the
 * alternative, an `isVideo` boolean beside a bare `src`, lets a caller render
 * an iframe from a video row by forgetting one `if`.
 */

// ---------------------------------------------------------------------------
// Tracks and visibility
// ---------------------------------------------------------------------------

export const LEARN_TRACKS = ['supply', 'demand', 'platform'] as const;
export type LearnTrack = (typeof LEARN_TRACKS)[number];

/** The tracks that teach the market rather than the product. Members only. */
export const LEARN_CONCEPT_TRACKS = ['supply', 'demand'] as const satisfies readonly LearnTrack[];
export type LearnConceptTrack = (typeof LEARN_CONCEPT_TRACKS)[number];

/** The one track a code-holder may reach. Named once; referenced everywhere. */
export const LEARN_CODE_TRACK = 'platform' as const satisfies LearnTrack;

export const LEARN_TRACK_LABELS: Record<LearnTrack, string> = {
  supply: 'Supply',
  demand: 'Demand',
  platform: 'Platform',
};

export const LEARN_TRACK_DESCRIPTIONS: Record<LearnTrack, string> = {
  supply: 'How capacity is sourced, qualified, priced and contracted.',
  demand: 'How compute is sold, renewed and expanded.',
  platform: 'Onboarding, feature walkthroughs and product demos of PIG itself.',
};

export const LEARN_VISIBILITIES = ['members', 'code'] as const;
export type LearnVisibility = (typeof LEARN_VISIBILITIES)[number];

export function isLearnTrack(value: string): value is LearnTrack {
  return (LEARN_TRACKS as readonly string[]).includes(value);
}

export function isLearnVisibility(value: string): value is LearnVisibility {
  return (LEARN_VISIBILITIES as readonly string[]).includes(value);
}

/**
 * May this track carry this visibility?
 *
 * Phrased as a predicate over the pair rather than "is this track public", so
 * that the check reads the same in the write path and in the CHECK constraint
 * and neither can drift into asking a subtly different question.
 */
export function learnVisibilityPermitted(track: LearnTrack, visibility: LearnVisibility): boolean {
  return visibility !== 'code' || track === LEARN_CODE_TRACK;
}

// ---------------------------------------------------------------------------
// The provider allowlist
// ---------------------------------------------------------------------------

export const LEARN_PROVIDERS = ['cap', 'loom', 'youtube_nocookie', 'pig'] as const;
export type LearnProvider = (typeof LEARN_PROVIDERS)[number];

/**
 * What a caller must render, and the discriminator that decides which.
 *
 * `kind` is the contract between this package and the web app. A union rather
 * than an optional flag because the two branches need different elements and
 * different sandboxing: an iframe is a third-party document on someone else's
 * origin, a video is bytes we serve. Conflating them is how a same-origin
 * `<iframe src>` ends up rendering a file the browser will happily execute
 * scripts from.
 */
export type LearnEmbed =
  | { kind: 'iframe'; src: string }
  /**
   * `poster` is part of the shape because a `<video>` with no poster is a
   * black rectangle until it buffers. Nothing sets it yet — a poster is a
   * second content-addressed file, and inventing its path here would produce
   * a 404 on every card.
   */
  | { kind: 'video'; src: string; poster?: string };

export type LearnEmbedKind = LearnEmbed['kind'];

// ---------------------------------------------------------------------------
// Self-hosted media
// ---------------------------------------------------------------------------

/**
 * The one prefix a self-hosted source may live under. Fixed, not configurable:
 * a configurable prefix is a prefix that can be set to `/` by an operator who
 * has not thought about it, and the whole safety argument here is that the
 * path is a constant with one pattern-checked segment after it.
 */
export const LEARN_MEDIA_PATH_PREFIX = '/media/learn/';

/** Extension → Content-Type. One table, so the allowlist and the served type cannot drift. */
export const LEARN_MEDIA_CONTENT_TYPES: Record<string, string> = {
  mp4: 'video/mp4',
  webm: 'video/webm',
  m4v: 'video/x-m4v',
  // Poster frames. Served from the same directory and the same route as the
  // video they were cut from — see `learnPosterFilename`.
  jpg: 'image/jpeg',
  webp: 'image/webp',
};

/**
 * The filename charset, anchored, with the dot rules written into the pattern
 * rather than checked afterwards.
 *
 * Every dot must be followed by at least one ordinary character, so `..`
 * cannot appear at all — traversal is impossible by construction rather than
 * by a `includes('..')` guard someone can later delete. No slash, no scheme,
 * no percent-encoding, no space. The lookahead caps the length, because an
 * unbounded filename is an unbounded path to `stat`.
 */
export const LEARN_MEDIA_FILENAME_PATTERN =
  /^(?=.{1,120}$)[A-Za-z0-9][A-Za-z0-9_-]*(?:\.[A-Za-z0-9_-]+)*\.(?:mp4|webm|m4v|jpg|webp)$/;

export function isLearnMediaFilename(value: string): boolean {
  return LEARN_MEDIA_FILENAME_PATTERN.test(value);
}

/** The public path for a filename that has already passed the pattern. */
export function learnMediaPath(filename: string): string {
  return `${LEARN_MEDIA_PATH_PREFIX}${filename}`;
}

/**
 * The poster that belongs to a video file.
 *
 * Derived by swapping the extension, which keeps the VIDEO's content hash in
 * the poster's name: `overview.4d4581ae.mp4` -> `overview.4d4581ae.jpg`.
 * Re-rendering a video changes both names together, so a poster can never go
 * stale against the clip it claims to show — which a separately hashed or
 * hand-named thumbnail would eventually do.
 *
 * Returns null for a filename that is not a video, so a poster cannot acquire
 * a poster of its own.
 */
export function learnPosterFilename(videoFilename: string): string | null {
  const dot = videoFilename.lastIndexOf('.');
  if (dot <= 0) return null;
  const extension = videoFilename.slice(dot + 1).toLowerCase();
  if (!LEARN_MEDIA_CONTENT_TYPES[extension]?.startsWith('video/')) return null;
  return `${videoFilename.slice(0, dot)}.jpg`;
}

/** The Content-Type for a validated filename, or null if it has no known one. */
export function learnMediaContentType(filename: string): string | null {
  const extension = filename.slice(filename.lastIndexOf('.') + 1).toLowerCase();
  return LEARN_MEDIA_CONTENT_TYPES[extension] ?? null;
}

export interface LearnProviderDefinition {
  provider: LearnProvider;
  label: string;
  /** Which element the resolved embed becomes. See `LearnEmbed`. */
  kind: LearnEmbedKind;
  /**
   * Disabled providers are inert: a pasted link matching one is rejected, so a
   * row can never be created and nothing can ever be framed from it. They are
   * listed so that turning one on is a flag and a CSP host rather than a
   * design exercise under time pressure.
   */
  enabled: boolean;
  /**
   * Exact hostnames. Never a suffix match — `evil-loom.com` ends in loom.com.
   * Empty for a self-hosted provider, which is matched on the path prefix and
   * must therefore never be reachable from the host lookup.
   */
  hosts: readonly string[];
  /** Path prefixes whose NEXT segment is the id, and nothing after it. */
  idSegmentPrefixes: readonly string[];
  /** The id charset, anchored. Everything downstream trusts this. */
  idPattern: RegExp;
  embed(externalId: string): string;
  watch(externalId: string): string;
  /**
   * What the proxy's `frame-src` needs before this provider can render, or
   * null for a provider that is never framed. A native `<video>` on this
   * origin needs no `frame-src` entry at all — it is covered by
   * `default-src 'self'` — which is a real part of why self-hosting was
   * chosen over another embed host.
   */
  frameSrc: string | null;
}

/**
 * Cap (cap.so) self-hosted at video.karti.ai.
 *
 * Verified against the running instance rather than assumed: the Next.js app
 * carries `app/s/[videoId]` and `app/embed/[videoId]`, both of which answer 404
 * for an unknown id — which is how we know the routes exist at all, since
 * every unrouted path there answers 307 instead. Ids observed in that
 * instance's database are lowercase alphanumeric, 15 characters; the pattern
 * is deliberately a little wider than that and no wider.
 *
 * HyperFrames (app.heygen.com) is the next one wanted. It is absent rather
 * than disabled because its share/embed path shape has not been checked
 * against the live service, and guessing that is precisely how an id
 * extraction ends up accepting a path it should not.
 */
export const LEARN_PROVIDER_TABLE: readonly LearnProviderDefinition[] = [
  {
    provider: 'cap',
    label: 'Cap',
    kind: 'iframe',
    enabled: true,
    hosts: ['video.karti.ai'],
    idSegmentPrefixes: ['s', 'embed'],
    idPattern: /^[a-z0-9]{8,32}$/,
    embed: (id) => `https://video.karti.ai/embed/${id}`,
    watch: (id) => `https://video.karti.ai/s/${id}`,
    frameSrc: 'https://video.karti.ai',
  },
  {
    provider: 'loom',
    label: 'Loom',
    kind: 'iframe',
    enabled: false,
    hosts: ['www.loom.com', 'loom.com'],
    idSegmentPrefixes: ['share', 'embed'],
    idPattern: /^[a-f0-9]{16,64}$/,
    embed: (id) => `https://www.loom.com/embed/${id}`,
    watch: (id) => `https://www.loom.com/share/${id}`,
    frameSrc: 'https://www.loom.com',
  },
  {
    provider: 'youtube_nocookie',
    label: 'YouTube',
    kind: 'iframe',
    enabled: false,
    // The nocookie host only, never youtube.com: the point of listing YouTube
    // at all is the privacy-preserving embed, and accepting the ordinary host
    // would quietly reintroduce the tracking this avoids.
    hosts: ['www.youtube-nocookie.com', 'youtube-nocookie.com'],
    idSegmentPrefixes: ['embed'],
    idPattern: /^[A-Za-z0-9_-]{11}$/,
    embed: (id) => `https://www.youtube-nocookie.com/embed/${id}`,
    watch: (id) => `https://www.youtube-nocookie.com/embed/${id}`,
    frameSrc: 'https://www.youtube-nocookie.com',
  },
  /**
   * PIG itself. The file lives on disk beside the application and is served
   * from `/media/learn/…` by this same process.
   *
   * `hosts` is empty on purpose. This provider is matched on the path prefix,
   * never on a hostname, so an absolute URL naming any host — including our
   * own public URL — resolves as `unknown_host` and cannot become a video
   * source. That keeps exactly one accept shape to reason about.
   *
   * `idSegmentPrefixes` is empty for the same reason: the generic two-segment
   * extractor is not used here, because the id is a filename with dots and an
   * extension rather than an opaque token.
   */
  {
    provider: 'pig',
    label: 'PIG (self-hosted)',
    kind: 'video',
    enabled: true,
    hosts: [],
    idSegmentPrefixes: [],
    idPattern: LEARN_MEDIA_FILENAME_PATTERN,
    embed: (id) => learnMediaPath(id),
    // The file is its own share link. There is no player page to send someone
    // to, and inventing one would be a route that does not exist.
    watch: (id) => learnMediaPath(id),
    frameSrc: null,
  },
];

/**
 * Hosts the proxy must allow in `frame-src` for the enabled providers.
 *
 * A provider with no `frameSrc` contributes nothing: a self-hosted `<video>`
 * is not framed, so widening `frame-src` for it would grant framing rights
 * nothing asked for.
 */
export const LEARN_FRAME_SRC_HOSTS: readonly string[] = LEARN_PROVIDER_TABLE.filter(
  (definition): definition is LearnProviderDefinition & { frameSrc: string } =>
    definition.enabled && definition.frameSrc !== null,
).map((definition) => definition.frameSrc);

export function learnProviderDefinition(
  provider: LearnProvider,
): LearnProviderDefinition | undefined {
  return LEARN_PROVIDER_TABLE.find((definition) => definition.provider === provider);
}

export const LEARN_EMBED_REJECTIONS = [
  'malformed_url',
  'insecure_scheme',
  'unknown_host',
  'provider_disabled',
  'unrecognised_path',
  'malformed_id',
] as const;
export type LearnEmbedRejection = (typeof LEARN_EMBED_REJECTIONS)[number];

export type LearnEmbedResolution =
  | {
      ok: true;
      provider: LearnProvider;
      externalId: string;
      /** Canonical share link. Safe to show a human; never an iframe source. */
      watchUrl: string;
      /**
       * The source string, kept for every caller written before self-hosting
       * existed. New code should read `embed` instead: this field cannot say
       * whether the source belongs in an iframe or a `<video>`, and rendering
       * it in the wrong one is the mistake the union exists to prevent.
       */
      embedUrl: string;
      /** What to render, and which element to render it in. */
      embed: LearnEmbed;
    }
  | { ok: false; reason: LearnEmbedRejection };

export const LEARN_EMBED_REJECTION_MESSAGES: Record<LearnEmbedRejection, string> = {
  malformed_url: 'That is not a URL.',
  insecure_scheme: 'Only https links can be embedded.',
  // Hostless providers are omitted: naming `/media/learn/…` as an "allowed
  // host" would invite someone to paste an absolute URL to it, which this
  // resolver refuses.
  unknown_host: `Links from that host are not allowed. Allowed: ${LEARN_PROVIDER_TABLE.filter((d) => d.enabled && d.hosts.length > 0).map((d) => d.hosts[0]).join(', ')}.`,
  provider_disabled: 'That provider is recognised but not enabled yet.',
  unrecognised_path: 'That looks like the right host but not a share link.',
  malformed_id: 'The video id in that link is not a shape we recognise.',
};

/** Build the render instruction for a provider and an already-validated id. */
function embedFor(definition: LearnProviderDefinition, externalId: string): LearnEmbed {
  const src = definition.embed(externalId);
  if (definition.kind !== 'video') return { kind: 'iframe', src };

  /*
   * The poster is asserted, not verified — this module is pure and has no
   * filesystem. A poster that was never generated 404s, which a <video poster>
   * renders exactly as it renders no poster at all, and which the card falls
   * back from. Claiming a missing image is therefore free; omitting one that
   * exists would cost every card its thumbnail.
   */
  const posterFile = learnPosterFilename(externalId);
  return posterFile
    ? { kind: 'video', src, poster: learnMediaPath(posterFile) }
    : { kind: 'video', src };
}

/**
 * Extract the id from a path, or null.
 *
 * The segment must be the LAST one. `/s/<id>/../../anything` and
 * `/s/<id>/edit` are both rejected rather than silently truncated to `<id>`,
 * because "close enough to a share link" is not a category this function is
 * allowed to have.
 */
function externalIdFromPath(definition: LearnProviderDefinition, pathname: string): string | null {
  const segments = pathname.split('/').filter(Boolean);
  if (segments.length !== 2) return null;
  const [prefix, candidate] = segments;
  if (!prefix || !candidate) return null;
  if (!definition.idSegmentPrefixes.includes(prefix)) return null;
  return candidate;
}

/**
 * Turn a pasted URL into a provider and an id, or say why not.
 *
 * Everything a caller may render is rebuilt from the template in the table.
 * The input string itself is never returned as a URL, so a resolution result
 * cannot carry an attacker's bytes into an attribute.
 */
export function resolveLearnEmbed(raw: string): LearnEmbedResolution {
  const trimmed = raw.trim();

  /*
   * The self-hosted branch, taken before anything is parsed as a URL.
   *
   * A leading slash is the ONLY way to reach it, and `//evil.example/x` — a
   * protocol-relative URL, which also begins with a slash — falls into it and
   * is rejected by the prefix check rather than treated as a host. There is no
   * second accept shape: no bare filename, no absolute URL naming our own
   * origin, because each extra shape is another place the pattern has to hold.
   */
  if (trimmed.startsWith('/')) return resolveSelfHostedMedia(trimmed);

  let parsed: URL;
  try {
    parsed = new URL(trimmed);
  } catch {
    return { ok: false, reason: 'malformed_url' };
  }

  // `javascript:` and `data:` are the obvious ones; `http:` matters too,
  // because framing it from an https page is blocked anyway and storing it
  // produces a resource that silently never plays.
  if (parsed.protocol !== 'https:') return { ok: false, reason: 'insecure_scheme' };

  // `https://video.karti.ai@evil.example/` parses with hostname `evil.example`
  // and reads to a human as the trusted host. Never legitimate here.
  if (parsed.username || parsed.password) return { ok: false, reason: 'malformed_url' };
  // A trusted hostname on an unexpected port is a different service.
  if (parsed.port) return { ok: false, reason: 'malformed_url' };

  const host = parsed.hostname.toLowerCase();
  const definition = LEARN_PROVIDER_TABLE.find((candidate) => candidate.hosts.includes(host));
  if (!definition) return { ok: false, reason: 'unknown_host' };
  if (!definition.enabled) return { ok: false, reason: 'provider_disabled' };

  const externalId = externalIdFromPath(definition, parsed.pathname);
  if (externalId === null) return { ok: false, reason: 'unrecognised_path' };
  if (!definition.idPattern.test(externalId)) return { ok: false, reason: 'malformed_id' };

  return {
    ok: true,
    provider: definition.provider,
    externalId,
    watchUrl: definition.watch(externalId),
    embedUrl: definition.embed(externalId),
    embed: embedFor(definition, externalId),
  };
}

/**
 * Resolve `/media/learn/<filename>` and nothing else.
 *
 * The rejection reasons are reused rather than extended so that the write path
 * keeps one vocabulary: a path that is not under the prefix, or that has a
 * further segment, is `unrecognised_path`; a filename that fails the pattern
 * is `malformed_id`. A query string or a fragment is a rejection too — the
 * stored id must be the exact bytes of a file on disk, and `?v=2` silently
 * dropped is a stored id that no longer names anything.
 */
function resolveSelfHostedMedia(path: string): LearnEmbedResolution {
  const definition = learnProviderDefinition('pig');
  if (!definition) return { ok: false, reason: 'unknown_host' };
  if (!definition.enabled) return { ok: false, reason: 'provider_disabled' };
  if (!path.startsWith(LEARN_MEDIA_PATH_PREFIX)) return { ok: false, reason: 'unrecognised_path' };

  const filename = path.slice(LEARN_MEDIA_PATH_PREFIX.length);
  if (filename.includes('/')) return { ok: false, reason: 'unrecognised_path' };
  if (!isLearnMediaFilename(filename)) return { ok: false, reason: 'malformed_id' };

  return {
    ok: true,
    provider: 'pig',
    externalId: filename,
    watchUrl: definition.watch(filename),
    embedUrl: definition.embed(filename),
    embed: embedFor(definition, filename),
  };
}

/**
 * Rebuild an embed source from stored columns.
 *
 * Re-validates the id rather than trusting the database. A row written before
 * a pattern was tightened, or by a future code path that skipped the resolver,
 * must not be framed on the strength of having been persisted once.
 */
export function learnEmbedUrl(provider: LearnProvider, externalId: string): string | null {
  return learnEmbed(provider, externalId)?.src ?? null;
}

/**
 * The same rebuild, carrying the discriminator.
 *
 * This is the one a renderer should call. `learnEmbedUrl` remains because
 * callers predate self-hosting, and both go through this function so a
 * tightened pattern cannot apply to one and not the other.
 */
export function learnEmbed(provider: LearnProvider, externalId: string): LearnEmbed | null {
  const definition = learnProviderDefinition(provider);
  if (!definition || !definition.enabled) return null;
  if (!definition.idPattern.test(externalId)) return null;
  return embedFor(definition, externalId);
}

/** The human-facing share link, on the same terms. */
export function learnWatchUrl(provider: LearnProvider, externalId: string): string | null {
  const definition = learnProviderDefinition(provider);
  if (!definition || !definition.enabled) return null;
  if (!definition.idPattern.test(externalId)) return null;
  return definition.watch(externalId);
}

// ---------------------------------------------------------------------------
// Presentation helpers
// ---------------------------------------------------------------------------

/**
 * `4:32`, or `1:04:12` past the hour.
 *
 * Here rather than in the web app because the duration is also rendered by the
 * public code-holder view, and two formatters would eventually disagree about
 * whether a 61-minute video is `61:00` or `1:01:00`.
 */
export function formatLearnDuration(seconds: number | null | undefined): string | null {
  if (seconds == null || !Number.isFinite(seconds) || seconds < 0) return null;
  const whole = Math.round(seconds);
  const hours = Math.floor(whole / 3600);
  const minutes = Math.floor((whole % 3600) / 60);
  const secs = whole % 60;
  const pad = (value: number) => String(value).padStart(2, '0');
  return hours > 0 ? `${hours}:${pad(minutes)}:${pad(secs)}` : `${minutes}:${pad(secs)}`;
}
