import { provide } from "@lit/context";
import { Task, TaskStatus } from "@lit/task";
import debug from "debug";
import { css, html, LitElement, type PropertyValueMap } from "lit";
import { customElement, property } from "lit/decorators.js";

import { EF_INTERACTIVE } from "../EF_INTERACTIVE.js";
import { isContextMixin } from "../gui/ContextMixin.js";
import { durationConverter } from "./durationConverter.js";
import { deepGetMediaElements } from "./EFMedia.js";
import {
  deepGetElementsWithFrameTasks,
  EFTemporal,
  flushStartTimeMsCache,
  shallowGetTemporalElements,
  timegroupContext,
} from "./EFTemporal.js";
import { TimegroupController } from "./TimegroupController.js";
import { updateAnimations } from "./updateAnimations.ts";

const log = debug("ef:elements:EFTimegroup");

export const shallowGetTimegroups = (
  element: Element,
  groups: EFTimegroup[] = [],
) => {
  for (const child of Array.from(element.children)) {
    if (child instanceof EFTimegroup) {
      groups.push(child);
    } else {
      shallowGetTimegroups(child, groups);
    }
  }
  return groups;
};

@customElement("ef-timegroup")
export class EFTimegroup extends EFTemporal(LitElement) {
  static styles = css`
    :host {
      display: block;
      width: 100%;
      height: 100%;
      position: absolute;
      top: 0;
      left: 0;
    }
  `;

  @provide({ context: timegroupContext })
  _timeGroupContext = this;

  #currentTime = 0;

  @property({
    type: String,
    attribute: "mode",
  })
  mode: "fit" | "fixed" | "sequence" | "contain" = "contain";

  @property({
    type: Number,
    converter: durationConverter,
    attribute: "overlap",
  })
  overlapMs = 0;

  @property({ type: String })
  fit: "none" | "contain" | "cover" = "none";

  #resizeObserver?: ResizeObserver;

  #seekInProgress = false;

  #pendingSeekTime: number | undefined;

  @property({ type: Number, attribute: "currenttime" })
  set currentTime(time: number) {
    if (this.#seekInProgress) {
      this.#pendingSeekTime = time;
      return;
    }

    this.#seekInProgress = true;
    this.#pendingSeekTime = time;

    this.seekTask.run().finally(() => {
      this.#seekInProgress = false;
      if (
        this.#pendingSeekTime !== undefined &&
        this.#pendingSeekTime !== time
      ) {
        const pendingTime = this.#pendingSeekTime;
        this.#pendingSeekTime = undefined;
        this.currentTime = pendingTime;
      } else {
        this.#pendingSeekTime = undefined;
      }
    });
  }

  get currentTime() {
    return this.#currentTime;
  }

  get currentTimeMs() {
    return this.currentTime * 1000;
  }

  set currentTimeMs(ms: number) {
    this.currentTime = ms / 1000;
  }

  /**
   * Determines if this is a root timegroup (no parent timegroups)
   */
  get isRootTimegroup(): boolean {
    return this.closest("ef-timegroup") === this;
  }

  /**
   * Saves time to localStorage (extracted for reuse)
   */
  #saveTimeToLocalStorage(time: number) {
    try {
      if (this.id && this.isConnected) {
        localStorage.setItem(this.storageKey, time.toString());
      }
    } catch (error) {
      log("Failed to save time to localStorage", error);
    }
  }

  render() {
    return html`<slot></slot> `;
  }

  maybeLoadTimeFromLocalStorage() {
    if (this.id) {
      try {
        return Number.parseFloat(localStorage.getItem(this.storageKey) || "0");
      } catch (error) {
        log("Failed to load time from localStorage", error);
      }
    }
    return 0;
  }

  connectedCallback() {
    super.connectedCallback();
    if (this.id) {
      this.waitForMediaDurations().then(() => {
        this.currentTime = this.maybeLoadTimeFromLocalStorage();
      });
    }

    if (this.parentTimegroup) {
      new TimegroupController(this.parentTimegroup, this);
    }

    if (this.shouldWrapWithWorkbench()) {
      this.wrapWithWorkbench();
    }

    requestAnimationFrame(() => {
      this.updateAnimations();
    });
  }

  disconnectedCallback() {
    super.disconnectedCallback();
    this.#resizeObserver?.disconnect();
  }

  get storageKey() {
    if (!this.id) {
      throw new Error("Timegroup must have an id to use localStorage.");
    }
    return `ef-timegroup-${this.id}`;
  }

  get intrinsicDurationMs() {
    if (this.hasExplicitDuration) {
      return this.explicitDurationMs;
    }
    return undefined;
  }

  get hasOwnDuration() {
    return (
      this.mode === "contain" ||
      this.mode === "sequence" ||
      (this.mode === "fixed" && this.hasExplicitDuration)
    );
  }

  get durationMs(): number {
    switch (this.mode) {
      case "fit": {
        if (!this.parentTimegroup) {
          return 0;
        }
        return this.parentTimegroup.durationMs;
      }
      case "fixed":
        return super.durationMs;
      case "sequence": {
        let duration = 0;
        this.childTemporals.forEach((child, index) => {
          if (child instanceof EFTimegroup && child.mode === "fit") {
            return;
          }
          if (index > 0) {
            duration -= this.overlapMs;
          }
          duration += child.durationMs;
        });
        return duration;
      }
      case "contain": {
        let maxDuration = 0;
        for (const child of this.childTemporals) {
          // fit timegroups look "up" to their parent timegroup for their duration
          // so we need to skip them to avoid an infinite loop
          if (child instanceof EFTimegroup && child.mode === "fit") {
            continue;
          }
          if (!child.hasOwnDuration) {
            continue;
          }
          maxDuration = Math.max(maxDuration, child.durationMs);
        }
        return maxDuration;
      }
      default:
        throw new Error(`Invalid time mode: ${this.mode}`);
    }
  }

  async getPendingFrameTasks(signal?: AbortSignal) {
    await this.waitForNestedUpdates(signal);
    signal?.throwIfAborted();
    const temporals = deepGetElementsWithFrameTasks(this);
    return temporals
      .map((temporal) => temporal.frameTask)
      .filter((task) => task.status < TaskStatus.COMPLETE);
  }

  async waitForNestedUpdates(signal?: AbortSignal) {
    const limit = 10;
    let steps = 0;
    let isComplete = true;
    while (true) {
      steps++;
      if (steps > limit) {
        throw new Error("Reached update depth limit.");
      }
      isComplete = await this.updateComplete;
      signal?.throwIfAborted();
      if (isComplete) {
        break;
      }
    }
  }

  async waitForFrameTasks(signal?: AbortSignal) {
    const limit = 10;
    let step = 0;
    await this.waitForNestedUpdates(signal);
    while (step < limit) {
      step++;
      let pendingTasks = await this.getPendingFrameTasks(signal);
      signal?.throwIfAborted();
      await Promise.all(pendingTasks.map((task) => task.taskComplete));
      signal?.throwIfAborted();
      await this.updateComplete;
      signal?.throwIfAborted();
      pendingTasks = await this.getPendingFrameTasks(signal);
      if (pendingTasks.length === 0) {
        break;
      }
    }
  }

  /**
   * Wait for all media elements to load their initial segments.
   * Ideally we would only need the extracted index json data, but
   * that caused issues with constructing audio data. We had negative durations
   * in calculations and it was not clear why.
   */
  async waitForMediaDurations() {
    // We must await updateComplete to ensure all media elements inside this are connected
    // and will match deepGetMediaElements
    await this.updateComplete;
    const mediaElements = deepGetMediaElements(this);
    // Then, we must await the fragmentIndexTask to ensure all media elements have their
    // fragment index loaded, which is where their duration is parsed from.
    await Promise.all(mediaElements.map((m) => m.mediaEngineTask.taskComplete));

    // After waiting for durations, we must force some updates to cascade and ensure all temporal elements
    // have correct durations and start times. It is not ideal that we have to do this inside here,
    // but it is the best current way to ensure that all temporal elements have correct durations and start times.

    // Next, we must flush the startTimeMs cache to ensure all media elements have their
    // startTimeMs parsed fresh, otherwise the startTimeMs is cached per animation frame.
    flushStartTimeMsCache();

    // Request an update to the currentTime of this group, ensuring that time updates will cascade
    // down to children, forcing sequence groups to arrange correctly.
    // This also makes the filmstrip update correctly.
    this.requestUpdate("currentTime");
    // Finally, we must await updateComplete to ensure all temporal elements have their
    // currentTime updated and all animations have run.

    await this.updateComplete;
  }

  get childTemporals() {
    return shallowGetTemporalElements(this);
  }

  protected updated(
    changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>,
  ): void {
    super.updated(changedProperties);
    if (
      changedProperties.has("currentTime") ||
      changedProperties.has("ownCurrentTimeMs")
    ) {
      this.updateAnimations();
    }
  }

  private updateAnimations() {
    updateAnimations(this);
  }

  get contextProvider() {
    let parent = this.parentNode;
    while (parent) {
      if (isContextMixin(parent)) {
        return parent;
      }
      parent = parent.parentNode;
    }
    return null;
  }

  /**
   * Returns true if the timegroup should be wrapped with a workbench.
   *
   * A timegroup should be wrapped with a workbench if it is the root-most timegroup
   * and EF_INTERACTIVE is true.
   *
   * If the timegroup is already wrappedin a context provider like ef-preview,
   * it should NOT be wrapped in a workbench.
   */
  shouldWrapWithWorkbench() {
    return (
      EF_INTERACTIVE &&
      this.closest("ef-timegroup") === this &&
      this.closest("ef-preview") === null &&
      this.closest("ef-workbench") === null &&
      this.closest("test-context") === null
    );
  }

  wrapWithWorkbench() {
    const workbench = document.createElement("ef-workbench");
    this.parentElement?.append(workbench);
    if (!this.hasAttribute("id")) {
      this.setAttribute("id", "root-this");
    }
    this.setAttribute("slot", "canvas");
    workbench.append(this as unknown as Element);

    const filmstrip = document.createElement("ef-filmstrip");
    filmstrip.setAttribute("slot", "timeline");
    filmstrip.setAttribute("target", this.id);
    workbench.append(filmstrip);
  }

  get efElements() {
    return Array.from(
      this.querySelectorAll(
        "ef-audio, ef-video, ef-image, ef-captions, ef-waveform",
      ),
    );
  }

  async #addAudioToContext(
    audioContext: AudioContext | OfflineAudioContext,
    fromMs: number,
    toMs: number,
  ) {
    await this.waitForMediaDurations();

    // Create AbortController for audio fetch operations
    const abortController = new AbortController();

    await Promise.all(
      deepGetMediaElements(this).map(async (mediaElement) => {
        // Skip muted elements entirely - no audio fetching or processing needed
        if (mediaElement.mute) {
          return;
        }

        const mediaStartsBeforeEnd = mediaElement.startTimeMs <= toMs;
        const mediaEndsAfterStart = mediaElement.endTimeMs >= fromMs;
        const mediaOverlaps = mediaStartsBeforeEnd && mediaEndsAfterStart;
        if (!mediaOverlaps) {
          return;
        }

        // Convert from root timegroup timeline to media element's local timeline
        const mediaLocalFromMs = Math.max(0, fromMs - mediaElement.startTimeMs);
        const mediaLocalToMs = Math.min(
          mediaElement.endTimeMs - mediaElement.startTimeMs,
          toMs - mediaElement.startTimeMs,
        );

        // Skip if no valid local time range
        if (mediaLocalFromMs >= mediaLocalToMs) {
          return;
        }

        const audio = await mediaElement.fetchAudioSpanningTime(
          mediaLocalFromMs, // ✅ Now using media element's local timeline
          mediaLocalToMs, // ✅ Now using media element's local timeline
          abortController.signal,
        );
        if (!audio) {
          throw new Error("Failed to fetch audio");
        }

        const bufferSource = audioContext.createBufferSource();
        bufferSource.buffer = await audioContext.decodeAudioData(
          await audio.blob.arrayBuffer(),
        );
        bufferSource.connect(audioContext.destination);

        // Calculate timing for placing this audio in the output context
        const ctxStartMs = Math.max(0, mediaElement.startTimeMs - fromMs);
        const ctxEndMs = mediaElement.endTimeMs - fromMs;
        const ctxDurationMs = ctxEndMs - ctxStartMs;

        // Calculate offset within the fetched audio buffer
        // Since we now use local timeline coordinates, audio.startMs is relative to media start
        const requestedOffsetInMedia = mediaLocalFromMs; // Already in local timeline
        const actualOffsetInBuffer = requestedOffsetInMedia - audio.startMs; // Both in local timeline

        // Ensure offset is never negative (this would cause audio scheduling errors)
        const safeOffset = Math.max(0, actualOffsetInBuffer);

        if (safeOffset !== actualOffsetInBuffer) {
        }

        bufferSource.start(
          ctxStartMs / 1000,
          safeOffset / 1000,
          ctxDurationMs / 1000,
        );
      }),
    );
  }

  async renderAudio(fromMs: number, toMs: number) {
    // Here we determine the number of samples we need to render rather than the duration.
    // We cannot tolerate having more or fewer samples than fit exactlly into AAC frames.
    const durationMs = toMs - fromMs;
    const duration = durationMs / 1000;
    const exactSamples = 48000 * duration;
    const aacFrames = exactSamples / 1024;
    const alignedFrames = Math.round(aacFrames);
    const contextSize = alignedFrames * 1024; // AAC-aligned sample count

    // Debug logging for audio duration calculations
    if (contextSize <= 0) {
      throw new Error(
        `Duration must be greater than 0 when rendering audio. ${contextSize}ms`,
      );
    }

    let audioContext: OfflineAudioContext;
    try {
      audioContext = new OfflineAudioContext(2, contextSize, 48000);
    } catch (error) {
      throw new Error(
        `[EFTimegroup.renderAudio] Failed to create OfflineAudioContext(2, ${contextSize}, 48000) for renderAudio(${fromMs}, ${toMs}) with contextSize=${contextSize}: ${error instanceof Error ? error.message : String(error)}. This typically happens when audio parameters are invalid (e.g., contextSize <= 0).`,
      );
    }

    await this.#addAudioToContext(audioContext, fromMs, toMs);
    const renderedBuffer = await audioContext.startRendering();

    return renderedBuffer;
  }

  /**
   * TEMPORARY TEST METHOD: Renders audio and immediately plays it back
   * Usage: timegroup.testPlayAudio(0, 5000) // Play first 5 seconds
   */
  async testPlayAudio(fromMs: number, toMs: number) {
    // Render the audio using the existing renderAudio method
    const renderedBuffer = await this.renderAudio(fromMs, toMs);

    // Create a regular AudioContext for playback
    const playbackContext = new AudioContext();

    // Create a buffer source and connect it
    const bufferSource = playbackContext.createBufferSource();
    bufferSource.buffer = renderedBuffer;
    bufferSource.connect(playbackContext.destination);

    // Start playback immediately
    bufferSource.start(0);

    // Return a promise that resolves when playback ends
    return new Promise<void>((resolve) => {
      bufferSource.onended = () => {
        playbackContext.close();
        resolve();
      };
    });
  }

  async loadMd5Sums() {
    const efElements = this.efElements;
    const loaderTasks: Promise<any>[] = [];
    for (const el of efElements) {
      const md5SumLoader = (el as any).md5SumLoader;
      if (md5SumLoader instanceof Task) {
        md5SumLoader.run();
        loaderTasks.push(md5SumLoader.taskComplete);
      }
    }

    await Promise.all(loaderTasks);

    efElements.map((el) => {
      if ("productionSrc" in el && el.productionSrc instanceof Function) {
        el.setAttribute("src", el.productionSrc());
      }
    });
  }

  frameTask = new Task(this, {
    autoRun: EF_INTERACTIVE,
    args: () => [this.ownCurrentTimeMs, this.currentTimeMs] as const,
    task: async ([], { signal }) => {
      if (this.isRootTimegroup) {
        await this.waitForFrameTasks(signal);
      }
    },
  });

  seekTask = new Task(this, {
    args: () => [this.#pendingSeekTime ?? this.#currentTime] as const,
    task: async ([targetTime], { signal }) => {
      const newTime = Math.max(0, Math.min(targetTime, this.durationMs / 1000));
      this.#currentTime = newTime;
      this.requestUpdate("currentTime");

      // Wait for update to propagate to child elements
      await this.updateComplete;
      signal.throwIfAborted();

      // Trigger child video seek tasks since they don't auto-run anymore
      const videoElements = this.querySelectorAll(
        "ef-video",
      ) as NodeListOf<any>;
      for (const video of videoElements) {
        if (video.videoSeekTask) {
          video.videoSeekTask.run();
        }
      }

      // Run frame task and wait for completion
      await this.frameTask.run();
      this.#saveTimeToLocalStorage(newTime);
    },
  });
}

declare global {
  interface HTMLElementTagNameMap {
    "ef-timegroup": EFTimegroup & Element;
  }
}
