/**
 * Shared sub-composition inlining logic.
 *
 * Both the core bundler (preview) and the producer compiler (render) need to
 * inline sub-composition HTML referenced via `data-composition-src`. This
 * module is the single source of truth for that transformation, eliminating
 * divergence that previously caused bugs (e.g. producer not setting
 * `data-composition-file`).
 */

import {
  rewriteAssetPaths,
  rewriteCssAssetUrls,
  rewriteInlineStyleAssetUrls,
} from "./rewriteSubCompPaths";
import { queryByAttr } from "../utils/cssSelector";
import {
  scopeCssToComposition,
  wrapInlineScriptWithErrorBoundary,
  wrapScopedCompositionScript,
} from "./compositionScoping";
import { checkSubCompositionUsability } from "@hyperframes/parsers/sub-composition-validity";

// ---------------------------------------------------------------------------
// Public interface
// ---------------------------------------------------------------------------

export interface InlineSubCompositionsOptions {
  /**
   * Resolve the HTML content for a sub-composition given its `data-composition-src` value.
   * Return `null` when the file cannot be found.
   */
  resolveHtml: (srcPath: string) => string | null;

  /**
   * Parse an HTML string into a Document. The returned object must expose
   * standard DOM APIs (querySelector, querySelectorAll, body, head, etc.).
   * Both linkedom's `parseHTML(...).document` and the core bundler's
   * `parseHTMLContent(...)` satisfy this contract.
   */
  parseHtml: (html: string) => Document;

  /**
   * Identity map produced by `assignBundledRuntimeCompositionIds`.
   * When provided, authoredCompositionId and runtimeCompositionId are read
   * from this map instead of from the host element's attributes directly.
   * The bundler uses this; the producer can omit it.
   */
  hostIdentityMap?: Map<
    Element,
    { authoredCompositionId: string | null; runtimeCompositionId: string | null }
  >;

  /**
   * When true, rewrite `url(...)` references in inline `style` attributes
   * on sub-composition elements. The bundler enables this; the producer
   * can skip it.
   */
  rewriteInlineStyles?: boolean;

  /**
   * Prepare the inner root element before injecting it into the host.
   * The bundler's `prepareFlattenedInnerRoot` clones the element, strips
   * timing attributes, and adds `data-hf-inner-root`. When omitted, the
   * inner root's outerHTML is injected as-is.
   */
  flattenInnerRoot?: (innerRoot: Element) => Element;

  /**
   * When true, CSS selectors targeting the authored root use a compound
   * selector (`[scope][root]`) instead of a descendant (`[scope] [root]`).
   * Enable this in the producer path where the inner root merges onto
   * the host element via innerHTML — both attributes end up on the same
   * element and a descendant selector won't match.
   */
  compoundAuthoredRoot?: boolean;

  /**
   * Read declared variable defaults from a sub-composition's `<html>` element.
   * The bundler passes `readDeclaredDefaults`; the producer can omit this.
   */
  readVariableDefaults?: (docElement: Element) => Record<string, unknown>;

  /**
   * Parse host-level variable overrides from `data-variable-values`.
   * The bundler passes `parseHostVariableValues`; the producer can omit this.
   */
  parseHostVariables?: (host: Element) => Record<string, unknown>;

  /**
   * Build a CSS attribute selector for scoping, e.g.
   * `[data-composition-id="my-comp"]`. Defaults to a simple implementation
   * when not provided. The bundler passes `cssAttributeSelector` which
   * handles escaping.
   */
  buildScopeSelector?: (compId: string) => string;

  /**
   * Error label prefix used in wrapped composition scripts.
   * Defaults to `"[HyperFrames] composition script error:"`.
   */
  scriptErrorLabel?: string;

  /**
   * Log a warning when a composition file cannot be resolved. `reason` is a
   * short, human-readable explanation (e.g. "the file is empty (0 bytes or
   * whitespace-only)") from `checkSubCompositionUsability` — present for
   * every skip except when `resolveHtml` returns `null` (file not found,
   * which callers detect themselves before calling `resolveHtml`).
   * Defaults to `console.warn`.
   */
  onMissingComposition?: (srcPath: string, reason?: string) => void;
}

export interface InlineSubCompositionsResult {
  styles: string[];
  scripts: string[];
  externalScriptSrcs: string[];
  scriptItems: Array<{ kind: "inline"; content: string } | { kind: "external"; src: string }>;
  externalLinks: { href: string; rel: string; crossorigin?: string }[];
  variablesByComp: Record<string, Record<string, unknown>>;
}

// ---------------------------------------------------------------------------
// Default helpers
// ---------------------------------------------------------------------------

function defaultBuildScopeSelector(compId: string): string {
  const escaped = compId.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
  return `[data-composition-id="${escaped}"]`;
}

// ---------------------------------------------------------------------------
// Core implementation
// ---------------------------------------------------------------------------

/**
 * Inline sub-compositions into a document. For each host element in `hosts`:
 *
 * 1. Resolve the sub-composition HTML via `options.resolveHtml`
 * 2. Parse it, find `<template>` or `<body>` content
 * 3. Find the inner `[data-composition-id]` root
 * 4. Extract `<style>` elements, scope CSS, collect them
 * 5. Extract `<script>` elements, wrap inline scripts, collect them
 * 6. Collect external script `src` URLs for deduplication
 * 7. Rewrite asset paths (and optionally inline-style asset URLs)
 * 8. Copy dimension attrs from inner root to host if missing
 * 9. Set `data-composition-file` on host
 * 10. Remove `data-composition-src` from host
 * 11. Inject the content into the host element
 */
export function inlineSubCompositions(
  document: Document,
  hosts: Element[],
  options: InlineSubCompositionsOptions,
): InlineSubCompositionsResult {
  const {
    resolveHtml,
    parseHtml,
    hostIdentityMap,
    rewriteInlineStyles = false,
    flattenInnerRoot,
    compoundAuthoredRoot,
    readVariableDefaults,
    parseHostVariables,
    buildScopeSelector = defaultBuildScopeSelector,
    scriptErrorLabel = "[HyperFrames] composition script error:",
    onMissingComposition,
  } = options;

  const styles: string[] = [];
  const scripts: string[] = [];
  const externalScriptSrcs: string[] = [];
  const scriptItems: InlineSubCompositionsResult["scriptItems"] = [];
  const externalLinks: { href: string; rel: string; crossorigin?: string }[] = [];
  const seenLinkHrefs = new Set<string>();
  const variablesByComp: Record<string, Record<string, unknown>> = {};

  for (const hostEl of hosts) {
    const src = hostEl.getAttribute("data-composition-src");
    if (!src) continue;

    const compHtml = resolveHtml(src);
    // Shared with lint + render pre-flight (@hyperframes/parsers'
    // subCompositionValidity.ts) so all three callers agree on what counts
    // as a usable sub-composition file. This path stays intentionally
    // tolerant (skip, don't throw) — preview and studio must keep bundling
    // around a scene that's still being authored. Lint and the render
    // pre-flight check use the same helper to fail loudly instead.
    const validity = checkSubCompositionUsability(compHtml, parseHtml);
    if (!validity.ok) {
      onMissingComposition?.(src, validity.detail);
      continue;
    }
    if (compHtml == null) {
      // Unreachable in practice — checkSubCompositionUsability's "empty"
      // reason already covers null/undefined — but this lets TypeScript
      // narrow compHtml to `string` below without an `as T` assertion.
      onMissingComposition?.(src);
      continue;
    }

    const compDoc = parseHtml(compHtml);

    // Determine composition IDs
    let compId: string | null;
    let runtimeCompId: string;
    if (hostIdentityMap) {
      const identity = hostIdentityMap.get(hostEl);
      compId = identity?.authoredCompositionId || null;
      runtimeCompId = identity?.runtimeCompositionId || compId || "";
    } else {
      compId = hostEl.getAttribute("data-composition-id") || null;
      runtimeCompId = compId || "";
    }

    // Find content: prefer <template>, fall back to <body>
    const contentRoot = compDoc.querySelector("template");
    const contentHtml = contentRoot ? contentRoot.innerHTML || "" : compDoc.body?.innerHTML || "";
    if (!contentHtml.trim()) {
      onMissingComposition?.(src);
      continue;
    }
    const contentDoc = parseHtml(contentHtml);
    if (!contentDoc.documentElement) {
      onMissingComposition?.(src);
      continue;
    }

    // Find the inner composition root
    const innerRoot = compId
      ? queryByAttr(contentDoc, "data-composition-id", compId)
      : contentDoc.querySelector("[data-composition-id]");
    const inferredCompId = innerRoot?.getAttribute("data-composition-id")?.trim() || "";
    const authoredRootId = innerRoot?.getAttribute("id")?.trim() || null;
    const scopeCompId = compId || inferredCompId;
    const runtimeScope = runtimeCompId ? buildScopeSelector(runtimeCompId) : "";

    // Variable merging (bundler feature)
    if (readVariableDefaults && parseHostVariables && runtimeCompId) {
      const mergedVariables = {
        ...readVariableDefaults(compDoc.documentElement),
        ...parseHostVariables(hostEl),
      };
      if (Object.keys(mergedVariables).length > 0) {
        variablesByComp[runtimeCompId] = mergedVariables;
      }
    }

    // When a sub-composition is a full HTML document (no <template>), styles
    // and scripts in <head> are not part of contentDoc (which only has body
    // content). Extract them so backgrounds, positioning, fonts, and library
    // scripts (e.g. GSAP CDN) are not silently dropped.
    if (!contentRoot && compDoc.head) {
      for (const s of [...compDoc.head.querySelectorAll("style")]) {
        const css = rewriteCssAssetUrls(s.textContent || "", src);
        styles.push(
          scopeCompId
            ? scopeCssToComposition(css, scopeCompId, runtimeScope || undefined, authoredRootId, {
                compoundAuthoredRoot: compoundAuthoredRoot === true,
              })
            : css,
        );
      }
      for (const s of [...compDoc.head.querySelectorAll("script")]) {
        const externalSrc = (s.getAttribute("src") || "").trim();
        if (externalSrc) {
          if (!externalScriptSrcs.includes(externalSrc)) {
            externalScriptSrcs.push(externalSrc);
          }
          scriptItems.push({ kind: "external", src: externalSrc });
        }
      }
      for (const link of [
        ...compDoc.head.querySelectorAll('link[rel="stylesheet"], link[rel="preconnect"]'),
      ]) {
        const href = (link.getAttribute("href") || "").trim();
        if (href && !seenLinkHrefs.has(href)) {
          seenLinkHrefs.add(href);
          const rel = (link.getAttribute("rel") || "").trim();
          const crossorigin = link.hasAttribute("crossorigin")
            ? link.getAttribute("crossorigin") || ""
            : undefined;
          externalLinks.push({ href, rel, crossorigin });
        }
      }
    }

    // Extract styles from content
    for (const s of [...contentDoc.querySelectorAll("style")]) {
      const css = rewriteCssAssetUrls(s.textContent || "", src);
      styles.push(
        scopeCompId
          ? scopeCssToComposition(css, scopeCompId, runtimeScope || undefined, authoredRootId, {
              compoundAuthoredRoot: compoundAuthoredRoot === true,
            })
          : css,
      );
      s.remove();
    }

    // Extract scripts from content
    for (const s of [...contentDoc.querySelectorAll("script")]) {
      const externalSrc = (s.getAttribute("src") || "").trim();
      if (externalSrc) {
        if (!externalScriptSrcs.includes(externalSrc)) {
          externalScriptSrcs.push(externalSrc);
        }
        scriptItems.push({ kind: "external", src: externalSrc });
      } else {
        const wrappedScript = scopeCompId
          ? wrapScopedCompositionScript(
              s.textContent || "",
              scopeCompId,
              scriptErrorLabel,
              runtimeScope || undefined,
              runtimeCompId || scopeCompId,
              authoredRootId,
            )
          : wrapInlineScriptWithErrorBoundary(s.textContent || "", scriptErrorLabel);
        scripts.push(wrappedScript);
        scriptItems.push({ kind: "inline", content: wrappedScript });
      }
      s.remove();
    }

    // Rewrite relative asset paths before inlining so ../foo.svg from
    // compositions/ resolves correctly when the content moves to root.
    const assetEls = innerRoot
      ? innerRoot.querySelectorAll("[src], [href]")
      : contentDoc.querySelectorAll("[src], [href]");
    rewriteAssetPaths(
      assetEls,
      src,
      (el: Element, attr: string) => el.getAttribute(attr),
      (el: Element, attr: string, val: string) => {
        el.setAttribute(attr, val);
      },
    );

    if (rewriteInlineStyles) {
      const styledEls = innerRoot
        ? innerRoot.querySelectorAll("[style]")
        : contentDoc.querySelectorAll("[style]");
      rewriteInlineStyleAssetUrls(
        styledEls,
        src,
        (el: Element) => el.getAttribute("style"),
        (el: Element, val: string) => {
          el.setAttribute("style", val);
        },
      );
    }

    if (innerRoot?.hasAttribute("data-timeline-locked")) {
      hostEl.setAttribute("data-timeline-locked", "");
    }

    // Copy dimension attributes from inner root to host if missing
    if (innerRoot) {
      const innerW = innerRoot.getAttribute("data-width");
      const innerH = innerRoot.getAttribute("data-height");
      if (innerW && !hostEl.getAttribute("data-width")) hostEl.setAttribute("data-width", innerW);
      if (innerH && !hostEl.getAttribute("data-height")) {
        hostEl.setAttribute("data-height", innerH);
      }
    }

    // Inject content into the host element
    if (innerRoot) {
      innerRoot.setAttribute("data-composition-file", src);
      for (const child of [...innerRoot.querySelectorAll("style, script")]) child.remove();
      if (flattenInnerRoot) {
        const prepared = flattenInnerRoot(innerRoot);
        if (!compId && inferredCompId) {
          // Anonymous host: flattenInnerRoot strips data-composition-id,
          // assuming the host already carries the composition's identity.
          // When the host has none, nothing in the render DOM matches the
          // composition's own root-styling CSS or self-referencing scripts
          // (e.g. document.querySelector('[data-composition-id="X"]')).
          // Restore it on the wrapper so both keep resolving, same as
          // before flattening preserved it via outerHTML.
          prepared.setAttribute("data-composition-id", inferredCompId);
        }
        hostEl.innerHTML = prepared.outerHTML || "";
      } else {
        hostEl.innerHTML = compId ? innerRoot.innerHTML || "" : innerRoot.outerHTML || "";
        // When the producer path strips the inner root (innerHTML), the
        // authored id attribute is lost. Propagate it to the host so that
        // rewritten #ID selectors ([data-hf-authored-id="X"]) still resolve.
        if (compId && authoredRootId) {
          hostEl.setAttribute("data-hf-authored-id", authoredRootId);
        }
      }
    } else {
      for (const child of [...contentDoc.querySelectorAll("style, script")]) child.remove();
      // linkedom fragment parsing: when content is `<div data-composition-id="X">...</div>`,
      // the div becomes documentElement and body is empty. Fall back to documentElement.outerHTML
      // to preserve the composition wrapper.
      const bodyHtml = contentDoc.body?.innerHTML || "";
      hostEl.innerHTML = bodyHtml || contentDoc.documentElement?.outerHTML || "";
    }

    hostEl.setAttribute("data-composition-file", src);
    hostEl.removeAttribute("data-composition-src");
  }

  return { styles, scripts, externalScriptSrcs, scriptItems, externalLinks, variablesByComp };
}
