import { useEffect, useMemo, useRef, useState, useCallback } from "react";
import type { GsapAnimation, GsapKeyframesData, ParsedGsap } from "@hyperframes/core/gsap-parser";
import { isStudioHoldSet } from "@hyperframes/core/gsap-parser";
import { usePlayerStore } from "../player/store/playerStore";
import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeBridge";
import {
  clearKeyframeCacheForElement,
  clearKeyframeCacheForFile,
} from "./gsapKeyframeCacheHelpers";
import { toAbsoluteTime } from "./gsapShared";
import { deduplicateKeyframes, synthesizeFlatTweenKeyframes } from "./gsapTweenSynth";

function extractIdFromSelector(selector: string): string | null {
  const match = selector.match(/^#([\w-]+)/);
  return match ? match[1] : null;
}

/**
 * Resolve a tween's target selector to the ids of the element(s) it animates.
 * A bare `#id` resolves directly; anything else (a class like `.dot`, a group
 * `.a, .b`, or a descendant selector) is matched against the live preview DOM so
 * class/selector tweens (e.g. `gsap.from(".dot", {stagger})`) attribute to every
 * element they animate — not just one parsed from the string. Falls back to a
 * leading `#id` when there's no DOM (so the cache still populates pre-iframe).
 */
// fallow-ignore-next-line complexity
export function resolveSelectorElementIds(
  selector: string,
  doc: Document | null | undefined,
): string[] {
  const bareId = selector.match(/^#([\w-]+)$/);
  if (bareId) return [bareId[1]];
  if (!doc) {
    const lead = extractIdFromSelector(selector);
    return lead ? [lead] : [];
  }
  const ids = new Set<string>();
  for (const part of selector.split(",")) {
    const sel = part.trim();
    if (!sel) continue;
    try {
      for (const el of Array.from(doc.querySelectorAll(sel))) {
        if (el.id) ids.add(el.id);
      }
    } catch {
      const lead = extractIdFromSelector(sel);
      if (lead) ids.add(lead);
    }
  }
  return Array.from(ids);
}

/** The selected element's identity for matching tweens to it. */
export interface GsapElementTarget {
  id?: string | null;
  selector?: string | null;
}

/**
 * A tween belongs to the selected element when its target selector addresses
 * that element — by id (`#id`), by the exact CSS selector the element was
 * selected through (`.kicker`), or as one member of a group selector
 * (`.clock-face, .clock-hand`, emitted for array/`toArray` targets). Real
 * compositions target tweens by class via `querySelector`, so id-only matching
 * misses them.
 *
 * When the live DOM `element` is supplied, each comma-part of a tween's selector
 * is also tested with `element.matches(part)` — true CSS semantics — so a
 * class/descendant tween shared across elements (e.g. `gsap.from(".dot", {stagger})`)
 * is attributed to *every* matching element, not just the one whose exact
 * selector string happens to equal the tween's.
 */
export function getAnimationsForElement(
  animations: GsapAnimation[],
  target: GsapElementTarget,
  element?: Element | null,
): GsapAnimation[] {
  const matchers = new Set<string>();
  if (target.id) matchers.add(`#${target.id}`);
  if (target.selector) matchers.add(target.selector);
  if (matchers.size === 0 && !element) return [];
  return animations.filter((a) =>
    a.targetSelector.split(",").some((part) => {
      const trimmed = part.trim();
      if (!trimmed) return false;
      if (matchers.has(trimmed)) return true;
      const lastSimple = trimmed.split(/\s+/).pop();
      if (lastSimple && matchers.has(lastSimple)) return true;
      if (element) {
        try {
          if (element.matches(trimmed)) return true;
        } catch {
          /* tween selector isn't a valid CSS selector for matches() — skip */
        }
      }
      return false;
    }),
  );
}

export async function fetchParsedAnimations(
  projectId: string,
  sourceFile: string,
): Promise<ParsedGsap | null> {
  try {
    const res = await fetch(
      `/api/projects/${encodeURIComponent(projectId)}/gsap-animations/${encodeURIComponent(sourceFile)}`,
      // Always re-read the freshly-parsed source; no per-call timestamp (which
      // would defeat caching forever and is a deterministic-render no-no).
      { cache: "no-store" },
    );
    if (!res.ok) return null;
    const parsed = (await res.json()) as ParsedGsap;
    // Studio-emitted pre-keyframe hold `set`s are an internal runtime detail (they
    // hold an element's first keyframe before its tween). They must not surface as
    // user animations — otherwise they pollute the keyframe cache / timeline diamonds.
    return { ...parsed, animations: parsed.animations.filter((a) => !isStudioHoldSet(a)) };
  } catch {
    return null;
  }
}

/**
 * Clip-relative timing basis for an element. Sub-composition internals (e.g. pills
 * inside a scene) aren't timeline clips themselves — they're derived at expand time
 * — so they're absent from `elements`. Without a basis, elDuration defaulted to 1
 * and clip-relative keyframe percentages blew past 100% (rendering off the clip).
 * Fall back to the sub-comp HOST's bounds, resolved via domClipChildren (the host's
 * data-composition-src is stripped in the rendered DOM, so we can't query it).
 */
export function resolveClipTimingBasis(
  elementId: string,
  sourceFile: string,
  elements: ReadonlyArray<{
    domId?: string;
    key?: string;
    id: string;
    start: number;
    duration: number;
  }>,
  domClipChildren: ReadonlyArray<{ id: string; hostId: string }>,
): { elStart: number; elDuration: number } {
  const direct = elements.find(
    (el) => el.domId === elementId || (el.key ?? el.id) === `${sourceFile}#${elementId}`,
  );
  if (direct) return { elStart: direct.start, elDuration: direct.duration };
  const hostId = domClipChildren.find((c) => c.id === elementId)?.hostId;
  const host = hostId
    ? elements.find((el) => el.domId === hostId || (el.key ?? el.id) === `index.html#${hostId}`)
    : undefined;
  return { elStart: host?.start ?? 0, elDuration: host?.duration ?? 1 };
}

export function useGsapAnimationsForElement(
  projectId: string | null,
  sourceFile: string,
  target: GsapElementTarget | null,
  version: number,
  iframeRef?: React.RefObject<HTMLIFrameElement | null>,
): {
  animations: GsapAnimation[];
  multipleTimelines: boolean;
  unsupportedTimelinePattern: boolean;
} {
  const [allAnimations, setAllAnimations] = useState<GsapAnimation[]>([]);
  const [multipleTimelines, setMultipleTimelines] = useState(false);
  const [unsupportedTimelinePattern, setUnsupportedTimelinePattern] = useState(false);
  const lastFetchKeyRef = useRef("");
  const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  // Re-run the per-element cache populate when sub-comp DOM children appear, so a
  // sub-comp element gets its host-relative keyframe percentages (not elDuration=1).
  const domClipChildrenKey = usePlayerStore((s) =>
    s.domClipChildren.map((c) => `${c.id}<${c.hostId}`).join("|"),
  );

  useEffect(() => {
    const targetKey = target?.id ?? target?.selector ?? "";
    const fetchKey = `${projectId}:${sourceFile}:${version}:${targetKey}`;
    if (fetchKey === lastFetchKeyRef.current) return;
    lastFetchKeyRef.current = fetchKey;

    if (retryTimerRef.current) {
      clearTimeout(retryTimerRef.current);
      retryTimerRef.current = null;
    }

    if (!projectId) {
      setAllAnimations([]);
      setMultipleTimelines(false);
      setUnsupportedTimelinePattern(false);
      return;
    }

    let cancelled = false;
    fetchParsedAnimations(projectId, sourceFile).then((parsed) => {
      if (cancelled) {
        return;
      }
      if (!parsed) {
        setAllAnimations([]);
        setMultipleTimelines(false);
        setUnsupportedTimelinePattern(false);
        return;
      }
      setAllAnimations(parsed.animations);
      setMultipleTimelines(parsed.multipleTimelines === true);
      setUnsupportedTimelinePattern(parsed.unsupportedTimelinePattern === true);

      // Retry once if initial fetch returned 0 animations — handles
      // cold-load race where the sourceFile isn't resolved yet.
      if (parsed.animations.length === 0 && targetKey) {
        retryTimerRef.current = setTimeout(() => {
          if (cancelled) return;
          fetchParsedAnimations(projectId, sourceFile).then((retryParsed) => {
            if (cancelled) return;
            if (retryParsed && retryParsed.animations.length > 0) {
              setAllAnimations(retryParsed.animations);
            }
          });
        }, 800);
      }
    });

    return () => {
      cancelled = true;
      if (retryTimerRef.current) {
        clearTimeout(retryTimerRef.current);
        retryTimerRef.current = null;
      }
    };
  }, [projectId, sourceFile, version, target?.id, target?.selector]);

  const targetId = target?.id ?? null;
  const targetSelector = target?.selector ?? null;
  const rawAnimations = useMemo(() => {
    if (!targetId && !targetSelector) return [];
    // Resolve the live element so class / descendant tweens (e.g.
    // gsap.from(".dot", {stagger})) attribute to every matching element, not
    // just the one whose exact selector equals the tween's. `version` re-runs
    // this after composition reloads.
    let element: Element | null = null;
    const doc = iframeRef?.current?.contentDocument;
    if (doc) {
      try {
        element =
          (targetId ? doc.getElementById(targetId) : null) ??
          (targetSelector ? doc.querySelector(targetSelector) : null);
      } catch {
        element = null;
      }
    }
    return getAnimationsForElement(
      allAnimations,
      { id: targetId, selector: targetSelector },
      element,
    );
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [allAnimations, targetId, targetSelector, version, iframeRef]);

  // fallow-ignore-next-line complexity
  const animations = useMemo(() => {
    const iframe = iframeRef?.current;
    let result = rawAnimations;

    // Enrich animations with unresolved keyframes from runtime
    if (iframe) {
      result = result.map((anim) => {
        if (!anim.hasUnresolvedKeyframes || anim.keyframes) return anim;
        const runtime = readRuntimeKeyframes(iframe, anim.targetSelector);
        if (!runtime) return anim;
        return {
          ...anim,
          keyframes: {
            format: "percentage" as const,
            keyframes: runtime.keyframes,
            ...(runtime.easeEach ? { easeEach: runtime.easeEach } : {}),
          },
          ...(runtime.arcPath ? { arcPath: runtime.arcPath } : {}),
        };
      });
    }

    // Match unresolved-selector animations from the parser to runtime tweens
    // targeting this element. This handles fully dynamic code (loop with variable selector).
    if (iframe && targetId && result.length === 0) {
      const unresolvedAnims = allAnimations.filter((a) => a.hasUnresolvedSelector);
      if (unresolvedAnims.length > 0) {
        const runtimeData = readRuntimeKeyframes(iframe, `#${targetId}`);
        if (runtimeData) {
          const scanned = scanAllRuntimeKeyframes(iframe);
          const runtimeEntry = scanned.get(targetId);
          if (runtimeEntry) {
            // Find which unresolved animation index matches this element
            // by correlating parser order with runtime tween order
            const runtimeIds = Array.from(scanned.keys());
            const runtimeIndex = runtimeIds.indexOf(targetId);
            const matchedAnim =
              runtimeIndex >= 0 && runtimeIndex < unresolvedAnims.length
                ? unresolvedAnims[runtimeIndex]
                : unresolvedAnims[0];
            if (matchedAnim) {
              result = [
                {
                  ...matchedAnim,
                  targetSelector: `#${targetId}`,
                  keyframes: {
                    format: "percentage" as const,
                    keyframes: runtimeEntry.keyframes,
                    ...(runtimeEntry.easeEach ? { easeEach: runtimeEntry.easeEach } : {}),
                  },
                  ...(runtimeEntry.arcPath ? { arcPath: runtimeEntry.arcPath } : {}),
                },
              ];
            }
          }
        }
      }
    }

    return result;
  }, [rawAnimations, allAnimations, iframeRef, targetId]);

  // Populate keyframe cache for the selected element.
  // Key format must match timeline element keys: "sourceFile#domId".
  // Merges keyframes from ALL animations targeting this element and synthesizes
  // flat tweens so the cache is never downgraded vs the bulk populate.
  const elementId = target?.id ?? null;
  // fallow-ignore-next-line complexity
  useEffect(() => {
    if (!elementId) return;

    // Resolve the element's time range from the player store so we can
    // convert tween-relative keyframe percentages to clip-relative ones.
    const { elements, domClipChildren } = usePlayerStore.getState();
    const { elStart, elDuration } = resolveClipTimingBasis(
      elementId,
      sourceFile,
      elements,
      domClipChildren,
    );

    const allKeyframes: Array<
      GsapKeyframesData["keyframes"][0] & { tweenPercentage?: number; propertyGroup?: string }
    > = [];
    let format: GsapKeyframesData["format"] = "percentage";
    let ease: string | undefined;
    let easeEach: string | undefined;
    for (const anim of animations) {
      // A static position hold (only x/y, no real motion) is a `set`, not a
      // keyframe — don't synthesize a diamond for it. Covers both `tl.set(...)`
      // and the `tl.to({ duration: 0, immediateRender: true })` hold that
      // remove-all-keyframes collapses to (which is otherwise shown as a stray
      // 0% keyframe).
      if (
        !anim.keyframes &&
        Object.keys(anim.properties).length > 0 &&
        Object.keys(anim.properties).every((k) => k === "x" || k === "y") &&
        (anim.method === "set" || (anim.duration ?? 0) === 0)
      )
        continue;
      const kf = anim.keyframes ?? synthesizeFlatTweenKeyframes(anim);
      if (!kf) continue;
      // Convert tween-relative percentages to clip-relative so diamonds
      // render at the correct position within the timeline clip.
      const tweenPos =
        anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0);
      const tweenDur = anim.duration ?? elDuration;
      for (const k of kf.keyframes) {
        const absTime = toAbsoluteTime(tweenPos, tweenDur, k.percentage);
        // 0.001% precision (was 0.1%) so a beat-snapped keyframe centers exactly
        // on the beat dot, which is rendered at the true beat time.
        const clipPct =
          elDuration > 0
            ? Math.round(((absTime - elStart) / elDuration) * 100000) / 1000
            : k.percentage;
        allKeyframes.push({
          ...k,
          percentage: clipPct,
          tweenPercentage: k.percentage,
          propertyGroup: anim.propertyGroup,
        });
      }
      format = kf.format;
      if (kf.ease) ease = kf.ease;
      if (kf.easeEach) easeEach = kf.easeEach;
    }
    if (allKeyframes.length === 0) {
      // The per-element parsed-animation match can transiently miss class /
      // selector tweens (e.g. `.dot`) that the file-wide populate or runtime
      // scan already cached. Only clear when no source cached this element —
      // otherwise selecting it would wipe its diamonds.
      const { keyframeCache } = usePlayerStore.getState();
      const hasCached =
        keyframeCache.has(`${sourceFile}#${elementId}`) || keyframeCache.has(elementId);
      if (!hasCached) clearKeyframeCacheForElement(sourceFile, elementId);
      return;
    }
    const dedupedKeyframes = deduplicateKeyframes(allKeyframes);
    const merged: GsapKeyframesData = {
      format,
      keyframes: dedupedKeyframes,
      ...(ease ? { ease } : {}),
      ...(easeEach ? { easeEach } : {}),
    };
    const { setKeyframeCache } = usePlayerStore.getState();
    setKeyframeCache(`${sourceFile}#${elementId}`, merged);
    // PropertyPanel reads the cache by bare elementId (without sourceFile prefix),
    // so write a duplicate entry under the bare key for cross-component lookups.
    setKeyframeCache(elementId, merged);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [elementId, sourceFile, animations, domClipChildrenKey]);

  return { animations, multipleTimelines, unsupportedTimelinePattern };
}

export function useGsapCacheVersion() {
  const [version, setVersion] = useState(0);
  const bump = useCallback(() => setVersion((v) => v + 1), []);
  return { version, bump };
}

/**
 * Fetch GSAP animations for a file and populate the keyframe cache for all
 * elements. Called from the Timeline component so diamonds show without
 * requiring a selection.
 */
export function usePopulateKeyframeCacheForFile(
  projectId: string | null,
  sourceFile: string,
  version: number,
  iframeRef?: React.RefObject<HTMLIFrameElement | null>,
): void {
  const elementCount = usePlayerStore((s) => s.elements.length);
  // Re-run when sub-comp DOM children appear (they supply the host bounds the
  // clip-relative keyframe percentages are computed against; without this the
  // cache is computed once before they exist and the percentages stay wrong).
  const domClipChildrenKey = usePlayerStore((s) =>
    s.domClipChildren.map((c) => `${c.id}<${c.hostId}`).join("|"),
  );
  const lastFetchKeyRef = useRef("");

  const runtimeScanDoneRef = useRef("");
  const astFetchDoneRef = useRef("");

  useEffect(() => {
    const fetchKey = `kf-cache:${projectId}:${sourceFile}:${version}:${elementCount}:${domClipChildrenKey}`;
    if (fetchKey === lastFetchKeyRef.current) return;
    lastFetchKeyRef.current = fetchKey;
    runtimeScanDoneRef.current = "";
    astFetchDoneRef.current = "";
    if (!projectId) return;

    const sf = sourceFile;
    // fallow-ignore-next-line complexity
    fetchParsedAnimations(projectId, sf).then((parsed) => {
      if (!parsed) return;
      const { setKeyframeCache } = usePlayerStore.getState();
      clearKeyframeCacheForFile(sf);
      const { elements, domClipChildren } = usePlayerStore.getState();
      const doc = iframeRef?.current?.contentDocument;
      const mergedByElement = new Map<string, GsapKeyframesData>();
      for (const anim of parsed.animations) {
        if (anim.hasUnresolvedKeyframes) continue;
        // Position-only static holds are not keyframed animations — skip them so
        // they don't draw a timeline diamond. Covers both a `tl.set(...)` and the
        // `tl.to({ duration: 0, immediateRender: true })` that remove-all-keyframes
        // collapses a keyframed tween to.
        if (!anim.keyframes && (anim.method === "set" || (anim.duration ?? 0) === 0)) {
          const propKeys = Object.keys(anim.properties).filter((k) => k !== "immediateRender");
          if (propKeys.length > 0 && propKeys.every((k) => k === "x" || k === "y")) {
            continue;
          }
        }
        const kfData = anim.keyframes ?? synthesizeFlatTweenKeyframes(anim);
        if (!kfData) continue;
        const tweenPos =
          anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0);
        const tweenDur = anim.duration ?? 1;
        // Attribute the tween to every element it animates (handles class /
        // group / descendant selectors, not just `#id`).
        for (const id of resolveSelectorElementIds(anim.targetSelector, doc)) {
          const { elStart, elDuration } = resolveClipTimingBasis(id, sf, elements, domClipChildren);
          const clipKeyframes = kfData.keyframes.map((kf) => {
            const absTime = toAbsoluteTime(tweenPos, tweenDur, kf.percentage);
            // 0.001% precision (matching useGsapAnimationsForElement above) so a
            // beat-snapped keyframe centers exactly on the beat dot and the two
            // caches agree on a keyframe's percentage.
            const clipPct =
              elDuration > 0
                ? Math.round(((absTime - elStart) / elDuration) * 100000) / 1000
                : kf.percentage;
            return {
              ...kf,
              percentage: clipPct,
              tweenPercentage: kf.percentage,
              propertyGroup: anim.propertyGroup,
            };
          });
          const existing = mergedByElement.get(id);
          if (existing) {
            existing.keyframes = deduplicateKeyframes([...existing.keyframes, ...clipKeyframes]);
          } else {
            mergedByElement.set(id, { ...kfData, keyframes: clipKeyframes });
          }
        }
      }
      for (const [id, kfData] of mergedByElement) {
        setKeyframeCache(`${sf}#${id}`, kfData);
        setKeyframeCache(id, kfData);
        if (sf !== "index.html") setKeyframeCache(`index.html#${id}`, kfData);
      }
      astFetchDoneRef.current = fetchKey;
    });
    // elementCount is in the deps because new timeline elements (e.g. after a
    // sub-composition expand) need their keyframe cache populated immediately;
    // without it the effect won't re-run when elements appear/disappear.
    // iframeRef is read for DOM selector resolution but intentionally not a dep
    // (it's a stable ref; the separate runtime-scan effect owns iframe timing).
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [projectId, sourceFile, version, elementCount, domClipChildrenKey]);

  // Separate effect for runtime keyframe discovery — polls until the iframe
  // has loaded GSAP timelines, independent of the AST fetch lifecycle.
  useEffect(() => {
    if (!projectId) return;
    const sf = sourceFile;

    let attempts = 0;
    const maxAttempts = 10;

    // fallow-ignore-next-line complexity
    const tryRuntimeScan = () => {
      if (runtimeScanDoneRef.current === `kf-cache:${projectId}:${sf}:${version}`) return true;
      const iframe =
        iframeRef?.current ?? document.querySelector<HTMLIFrameElement>("iframe[src*='/preview/']");
      if (!iframe) return false;
      // Clip dims per element so the scan converts tween-relative keyframes to
      // clip-relative (matching the static path) instead of timeline-relative.
      const clipById = new Map<string, { start: number; duration: number }>();
      for (const el of usePlayerStore.getState().elements) {
        if (el.domId) clipById.set(el.domId, { start: el.start, duration: el.duration });
      }
      const scanned = scanAllRuntimeKeyframes(iframe, clipById);
      if (scanned.size === 0) return false;
      const { setKeyframeCache, keyframeCache } = usePlayerStore.getState();
      for (const [id, data] of scanned) {
        const cacheKey = `${sf}#${id}`;
        const fallbackKey = `index.html#${id}`;
        const alreadyCached =
          keyframeCache.has(cacheKey) || keyframeCache.has(fallbackKey) || keyframeCache.has(id);
        if (alreadyCached) continue;
        // Skip position-only set tweens from runtime too — same filter as AST path
        const isPosOnly =
          data.keyframes.length === 1 &&
          Object.keys(data.keyframes[0].properties).every((k) => k === "x" || k === "y");
        if (isPosOnly) {
          continue;
        }
        const entry = {
          format: "percentage" as const,
          keyframes: data.keyframes,
          ...(data.easeEach ? { easeEach: data.easeEach } : {}),
        };
        setKeyframeCache(cacheKey, entry);
        if (sf !== "index.html") setKeyframeCache(fallbackKey, entry);
        setKeyframeCache(id, entry);
      }
      runtimeScanDoneRef.current = `kf-cache:${projectId}:${sf}:${version}`;
      return true;
    };

    if (tryRuntimeScan()) return;

    const interval = setInterval(() => {
      attempts++;
      if (tryRuntimeScan() || attempts >= maxAttempts) clearInterval(interval);
    }, 500);

    return () => clearInterval(interval);
  }, [projectId, sourceFile, version, iframeRef]);
}
