/**
 * Shared HTML-escaping and URL-safety utilities.
 *
 * `escapeHtml` / `escapeAttr` are the single source of truth for escaping
 * user-supplied strings before they are interpolated into exported/rendered
 * HTML. `safeUrl` blocks dangerous URL schemes (javascript:, vbscript:,
 * data:text/html) including whitespace/case/entity-obfuscated variants.
 */

/** Escapes &, <, >, " for safe use in text content and double-quoted attributes. */
export function escapeHtml(s: string): string {
  if (typeof s !== 'string') {
    if (s === null || s === undefined) return '';
    s = String(s);
  }
  // Ampersand MUST be escaped first, otherwise the entities produced by the
  // other replacements would themselves be re-escaped (double-escaping).
  return s
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;');
}

/** Escapes everything escapeHtml does, plus ' and ` for use in any attribute context. */
export function escapeAttr(s: string): string {
  if (typeof s !== 'string') {
    if (s === null || s === undefined) return '';
    s = String(s);
  }
  return escapeHtml(s)
    .replace(/'/g, '&#39;')
    .replace(/`/g, '&#96;');
}

// Dangerous scheme prefixes, checked against a normalized copy with all
// colons removed (see safeUrl) so that colon-splicing obfuscation like
// "java:script:alert(1)" can't slip past a literal "javascript:" check.
// M-5: `data:image/svg+xml` is blocked alongside `data:text/html` -- an SVG
// document can carry an inline <script>/onload= just like an HTML document,
// so it executes script when loaded as a document/navigation target (e.g. an
// <a href> or window.open), even though it's nominally an "image" MIME type.
// Other `data:image/*` types (png/jpeg/gif/webp, ...) stay allowed below --
// only this specific scriptable subtype is blocked, regardless of a
// trailing `;base64` or other parameters.
const DANGEROUS_SCHEME_PREFIXES = ['javascript', 'vbscript', 'datatext/html', 'dataimage/svg+xml'];

/** Decodes &#NN; and &#xNN; numeric HTML entities (used to obfuscate scheme names). */
function decodeNumericEntities(s: string): string {
  return s.replace(/&#x([0-9a-f]+);?/gi, (_m, hex) => String.fromCharCode(parseInt(hex, 16)))
    .replace(/&#0*([0-9]+);?/g, (_m, dec) => String.fromCharCode(parseInt(dec, 10)));
}

/**
 * Returns the trimmed input if it is a safe URL, otherwise returns ''.
 * Blocks javascript:, vbscript:, and data:text/html schemes, including
 * whitespace / control-char / case / HTML-entity obfuscated variants.
 * Allowed URLs are returned UNCHANGED (trimmed only) -- query strings etc.
 * are never mangled.
 */
export function safeUrl(s: string): string {
  if (typeof s !== 'string') return '';

  const trimmed = s.trim();
  if (trimmed === '') return '';

  const collapsed = normalizeForSchemeCheck(trimmed);

  if (DANGEROUS_SCHEME_PREFIXES.some((prefix) => collapsed.startsWith(prefix))) {
    return '';
  }

  return trimmed;
}

/**
 * Image-context variant of `safeUrl` for IMAGE sinks only (`<img src>`, CSS
 * `url(...)` backgrounds). `data:image/svg+xml` is safe here -- loaded as an
 * image, an SVG is rasterized and never executes an inline <script>/onload=
 * the way it would as a navigation/iframe document -- so, unlike `safeUrl`,
 * this ALLOWS every `data:image/*` subtype (svg+xml, png, jpeg, gif, webp,
 * ..., with or without `;base64`).
 *
 * Still blocks `javascript:` / `vbscript:` (same obfuscation-resistant
 * normalization as `safeUrl`), and -- because this is an image sink, not a
 * general-purpose URL sink -- treats `data:` as an ALLOWLIST rather than a
 * blocklist: any `data:` URI whose type is NOT `image/*` (`data:text/html`,
 * `data:application/javascript`, `data:text/javascript`, etc.) is blocked
 * too, since none of those are legitimate image sources.
 *
 * Do NOT use this for href/iframe/form-action/navigation sinks -- keep
 * those on `safeUrl`, which still blocks `data:image/svg+xml`.
 */
export function safeImageUrl(s: unknown): string {
  if (typeof s !== 'string') return '';

  const trimmed = s.trim();
  if (trimmed === '') return '';

  const collapsed = normalizeForSchemeCheck(trimmed);

  if (collapsed.startsWith('javascript') || collapsed.startsWith('vbscript')) {
    return '';
  }
  if (collapsed.startsWith('data') && !collapsed.startsWith('dataimage/')) {
    // A data: URI whose MIME type isn't image/* -- e.g. data:text/html,
    // data:application/javascript, data:text/javascript. No legitimate
    // image source needs these; block unconditionally.
    return '';
  }

  return trimmed;
}

// Normalizes a trimmed URL string for scheme-prefix matching only: decodes
// numeric HTML entities (catches e.g. &#106; -> 'j'), strips whitespace /
// control chars, lowercases, then strips every colon. Stripping colons
// defeats obfuscation that splices extra colons into the scheme name itself
// (e.g. decoding &#58; mid-word produces "java:script:alert(1)", which would
// otherwise dodge a literal "^javascript:" check) while still reliably
// catching the real "javascript:"/"vbscript:"/"data:..." prefixes once their
// own colon is removed. Used for prefix `.startsWith()` checks only -- the
// original (non-collapsed) string is always what gets returned/emitted.
function normalizeForSchemeCheck(trimmed: string): string {
  return decodeNumericEntities(trimmed)
    .replace(/[\x00-\x20]+/g, '')
    .toLowerCase()
    .replace(/:/g, '');
}

/**
 * Neutralizes CSS-context breakout for a single design-token value (color,
 * length, gradient, etc.) so it is safe to interpolate RAW into either CSS
 * context found in exported HTML:
 *   - ATTRIBUTE context: `style="color:${v}"` -- a `"` or `'` closes the
 *     attribute early; a `;` injects an extra declaration.
 *   - ELEMENT context: `<style>.x{color:${v}}</style>` -- `<`, `>`, `{`, `}`
 *     let a value close the rule / the `<style>` element itself and open a
 *     `<script>`, which is the worst case (arbitrary script execution).
 * Strips the characters that can terminate a value, declaration, rule, or
 * the `<style>` element itself, and neutralizes `url(` so a background
 * shorthand can't smuggle a `javascript:` URL. None of hex colors,
 * `rgb()`/`rgba()`, `var(...)`, gradients, `calc(...)`, or `px`/`%`/`em`/
 * `rem` sizes contain any stripped character, so legitimate values pass
 * through unchanged. If a sink legitimately needs a background image URL,
 * route that value through `safeUrl` instead of `cssValue`.
 */
export function cssValue(v: unknown): string {
  if (typeof v !== 'string') return '';
  return v
    .replace(/[<>{};"'\\]/g, '')
    .replace(/url\s*\(/gi, '');
}

/**
 * Derives a deterministic, HTML-id-safe slug from a field's `name`/`label`
 * for wiring `<label for>` to a matching `<input id>` in exported markup.
 * Pure function of the input string -- no Math.random/Date.now -- so the
 * same field always gets the same id across export runs.
 */
export function slugId(s: string | undefined, fallback = 'field'): string {
  const base = (s || '').toString().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
  return base || fallback;
}

/**
 * Deterministic (non-cryptographic) string hash -- djb2. Same input always
 * produces the same output across export runs (pure function of the
 * string, no Math.random/Date.now). Used as a fallback seed source when a
 * Craft node id isn't available -- see `scopeId` below.
 */
export function stableHash(seed: string): string {
  let h = 5381;
  for (let i = 0; i < seed.length; i++) {
    h = ((h << 5) + h + seed.charCodeAt(i)) | 0;
  }
  return (h >>> 0).toString(36);
}

/**
 * Derives a deterministic AND unique per-node scope id for a component's
 * exported element ids / aria-controls / aria-labelledby / for / inline
 * `<script>` function names.
 *
 * Craft.js assigns every node in a serialized page tree a unique id that
 * is stable across repeated exports of the same saved page. Scoping ids on
 * that node id -- rather than `Math.random()` (unique but changes on every
 * export, breaking caching/diffing) or a hash of prop/content data
 * (deterministic but COLLIDES whenever two instances share default or
 * identical content) -- yields ids that are simultaneously deterministic
 * (same node -> same id, every export) and collision-free (different
 * nodes -> different ids, even with identical content).
 *
 * `nodeId` is optional only for defensive/legacy call sites -- existing
 * `toHtml(props, childrenHtml)` 2-arg callers that haven't been updated to
 * pass the node id. In that case we fall back to `stableHash(fallbackSeed)`,
 * which is still deterministic (never Math.random) but, like the old
 * per-component hash implementations, can collide across instances with
 * identical content. Callers should always pass the real node id when
 * available.
 */
export function scopeId(nodeId: string | undefined, fallbackSeed: string, prefix: string): string {
  // M-4: hash the raw node id (via the same djb2 `stableHash` used for the
  // fallback path below) rather than lowercasing + stripping punctuation
  // into a slug. A slug collapses distinct ids that differ only by
  // case/punctuation (e.g. "AbC" and "abc", or "a-b" and "ab") onto the same
  // scope; hashing the untouched string keeps them distinct while staying
  // deterministic and producing a valid CSS ident (prefix + '_' + [a-z0-9]+).
  const seed = (nodeId || '').toString();
  return `${prefix}_${stableHash(seed || fallbackSeed)}`;
}

// Shared enum allowlists for `toHtml` attribute sinks fed by props that are
// declared as a TS union (e.g. `method?: 'GET' | 'POST'`) but are NOT
// type-checked at runtime -- they arrive raw via the AI `update_props` path
// (only `node_id` is validated there) or a deserialized saved-state blob.
// Allowlisting -- rather than escaping -- is correct here because the only
// legitimate values are the fixed set below; anything else is either an
// honest mistake or an attribute-breakout attempt, and both collapse safely
// to the same known-good default.

const ALLOWED_FORM_METHODS = ['GET', 'POST'] as const;

/** Allowlists a `<form method>` value, case-insensitively, falling back to 'POST'. */
export function sanitizeFormMethod(m: unknown): 'GET' | 'POST' {
  const upper = (typeof m === 'string' ? m : '').trim().toUpperCase();
  return (ALLOWED_FORM_METHODS as readonly string[]).includes(upper) ? (upper as 'GET' | 'POST') : 'POST';
}

const ALLOWED_INPUT_TYPES = [
  'text', 'email', 'tel', 'number', 'password', 'url', 'search',
  'date', 'time', 'datetime-local', 'month', 'week',
  'checkbox', 'radio', 'hidden', 'color', 'range', 'file',
] as const;

/** Allowlists an `<input type>` value, falling back to 'text'. */
export function sanitizeInputType(t: unknown): string {
  const value = typeof t === 'string' ? t : '';
  return (ALLOWED_INPUT_TYPES as readonly string[]).includes(value) ? value : 'text';
}
