import { consume, createContext, provide } from "@lit/context";
import type { LitElement } from "lit";
import { property, state } from "lit/decorators.js";
import type { EFTimegroup } from "../elements/EFTimegroup.js";
import {
  type EFConfiguration,
  efConfigurationContext,
} from "./EFConfiguration.ts";
import { efContext } from "./efContext.js";
import { fetchContext } from "./fetchContext.js";
import { type FocusContext, focusContext } from "./focusContext.js";
import { focusedElementContext } from "./focusedElementContext.js";
import { loopContext, playingContext } from "./playingContext.js";

export const targetTimegroupContext = createContext<EFTimegroup | null>(
  "target-timegroup",
);

export declare class ContextMixinInterface extends LitElement {
  signingURL?: string;
  apiHost?: string;
  rendering: boolean;
  playing: boolean;
  loop: boolean;
  currentTimeMs: number;
  focusedElement?: HTMLElement;
  targetTimegroup: EFTimegroup | null;
  play(): void;
  pause(): void;
}

const contextMixinSymbol = Symbol("contextMixin");

export function isContextMixin(value: any): value is ContextMixinInterface {
  return (
    typeof value === "object" &&
    value !== null &&
    contextMixinSymbol in value.constructor
  );
}

type Constructor<T = {}> = new (...args: any[]) => T;
export function ContextMixin<T extends Constructor<LitElement>>(superClass: T) {
  class ContextElement extends superClass {
    static [contextMixinSymbol] = true;

    @consume({ context: efConfigurationContext, subscribe: true })
    efConfiguration: EFConfiguration | null = null;

    @provide({ context: focusContext })
    focusContext = this as FocusContext;

    @provide({ context: focusedElementContext })
    @state()
    focusedElement?: HTMLElement;

    #apiHost?: string;
    @property({ type: String, attribute: "api-host" })
    get apiHost() {
      return this.#apiHost ?? this.efConfiguration?.apiHost ?? "";
    }

    set apiHost(value: string) {
      this.#apiHost = value;
    }

    @provide({ context: efContext })
    efContext = this;

    @provide({ context: targetTimegroupContext })
    @state()
    targetTimegroup: EFTimegroup | null = null;

    // Add reactive properties that depend on the targetTimegroup
    @state()
    get durationMs(): number {
      return this.targetTimegroup?.durationMs ?? 0;
    }

    @state()
    get endTimeMs(): number {
      return this.targetTimegroup?.endTimeMs ?? 0;
    }

    @provide({ context: fetchContext })
    fetch = async (url: string, init: RequestInit = {}) => {
      init.headers ||= {};
      Object.assign(init.headers, {
        "Content-Type": "application/json",
      });

      if (this.signingURL) {
        const now = Date.now();
        const { cacheKey, signingPayload } = this.#getTokenCacheKey(url);
        const tokenExpiration = this.#URLTokenExpirations[cacheKey] || 0;

        // Check if we need to fetch a new token (no token exists or token is expired)
        if (!this.#URLTokens[cacheKey] || now >= tokenExpiration) {
          this.#URLTokens[cacheKey] = fetch(this.signingURL, {
            method: "POST",
            body: JSON.stringify(signingPayload),
          }).then(async (response) => {
            if (response.ok) {
              const tokenData = await response.json();
              const token = tokenData.token;
              // Parse and store the token's actual expiration time
              this.#URLTokenExpirations[cacheKey] =
                this.#parseTokenExpiration(token);
              return token;
            }
            throw new Error(
              `Failed to sign URL: ${url}. SigningURL: ${this.signingURL} ${response.status} ${response.statusText}`,
            );
          });
        }

        const urlToken = await this.#URLTokens[cacheKey];

        Object.assign(init.headers, {
          authorization: `Bearer ${urlToken}`,
        });
      } else {
        init.credentials = "include";
      }

      return fetch(url, init);
    };

    #URLTokens: Record<string, Promise<string>> = {};
    #URLTokenExpirations: Record<string, number> = {};

    /**
     * Generate a cache key for URL token based on signing strategy
     *
     * Uses unified prefix + parameter matching approach:
     * - For transcode URLs: signs base "/api/v1/transcode" + params like {url: "source.mp4"}
     * - For regular URLs: signs full URL with empty params {}
     * - All validation uses prefix matching + exhaustive parameter matching
     * - Multiple transcode segments with same source share one token (reduces round-trips)
     */
    #getTokenCacheKey(url: string): {
      cacheKey: string;
      signingPayload: { url: string; params?: Record<string, string> };
    } {
      try {
        const urlObj = new URL(url);

        // Check if this is a transcode URL pattern
        if (urlObj.pathname.includes("/api/v1/transcode/")) {
          const urlParam = urlObj.searchParams.get("url");
          if (urlParam) {
            // For transcode URLs, sign the base path + url parameter
            const basePath = `${urlObj.origin}/api/v1/transcode`;
            const cacheKey = `${basePath}?url=${urlParam}`;
            return {
              cacheKey,
              signingPayload: { url: basePath, params: { url: urlParam } },
            };
          }
        }

        // For non-transcode URLs, use full URL (existing behavior)
        return {
          cacheKey: url,
          signingPayload: { url },
        };
      } catch {
        // If URL parsing fails, fall back to full URL
        return {
          cacheKey: url,
          signingPayload: { url },
        };
      }
    }

    /**
     * Parse JWT token to extract safe expiration time (with buffer)
     * @param token JWT token string
     * @returns Safe expiration timestamp in milliseconds (actual expiry minus buffer), or 0 if parsing fails
     */
    #parseTokenExpiration(token: string): number {
      try {
        // JWT has 3 parts separated by dots: header.payload.signature
        const parts = token.split(".");
        if (parts.length !== 3) return 0;

        // Decode the payload (second part)
        const payload = parts[1];
        if (!payload) return 0;

        const decoded = atob(payload.replace(/-/g, "+").replace(/_/g, "/"));
        const parsed = JSON.parse(decoded);

        // Extract timestamps (in seconds)
        const exp = parsed.exp;
        const iat = parsed.iat;
        if (!exp) return 0;

        // Calculate token lifetime and buffer
        const lifetimeSeconds = iat ? exp - iat : 3600; // Default to 1 hour if no iat
        const tenPercentBufferMs = lifetimeSeconds * 0.1 * 1000; // 10% of lifetime in ms
        const fiveMinutesMs = 5 * 60 * 1000; // 5 minutes in ms

        // Use whichever buffer is smaller (more conservative)
        const bufferMs = Math.min(fiveMinutesMs, tenPercentBufferMs);

        // Return expiration time minus buffer
        return exp * 1000 - bufferMs;
      } catch {
        return 0;
      }
    }

    #signingURL?: string;
    /**
     * A URL that will be used to generated signed tokens for accessing media files from the
     * editframe API. This is used to authenticate media requests per-user.
     */
    @property({ type: String, attribute: "signing-url" })
    get signingURL() {
      return this.#signingURL ?? this.efConfiguration?.signingURL ?? "";
    }
    set signingURL(value: string) {
      this.#signingURL = value;
    }

    @provide({ context: playingContext })
    @property({ type: Boolean, reflect: true })
    playing = false;

    @provide({ context: loopContext })
    @property({ type: Boolean, reflect: true })
    loop = false;

    @property({ type: Boolean })
    rendering = false;

    @state()
    currentTimeMs = 0;

    #FPS = 30;
    #MS_PER_FRAME = 1000 / this.#FPS;

    #timegroupObserver = new MutationObserver((mutations) => {
      let shouldUpdate = false;

      for (const mutation of mutations) {
        if (mutation.type === "childList") {
          const newTimegroup = this.querySelector("ef-timegroup");
          if (newTimegroup !== this.targetTimegroup) {
            this.targetTimegroup = newTimegroup;
            shouldUpdate = true;
          } else if (
            mutation.target instanceof Element &&
            (mutation.target.tagName === "EF-TIMEGROUP" ||
              mutation.target.closest("ef-timegroup"))
          ) {
            // Handle childList changes within existing timegroups
            shouldUpdate = true;
          }
        } else if (mutation.type === "attributes") {
          // Watch for attribute changes that might affect duration
          if (
            mutation.attributeName === "duration" ||
            mutation.attributeName === "mode" ||
            (mutation.target instanceof Element &&
              (mutation.target.tagName === "EF-TIMEGROUP" ||
                mutation.target.closest("ef-timegroup")))
          ) {
            shouldUpdate = true;
          }
        }
      }

      if (shouldUpdate) {
        // Trigger an update to ensure reactive properties recalculate
        // Use a microtask to ensure DOM updates are complete
        queueMicrotask(() => {
          this.requestUpdate();
          // Also ensure the targetTimegroup updates its computed properties
          if (this.targetTimegroup) {
            this.targetTimegroup.requestUpdate();
          }
        });
      }
    });

    connectedCallback(): void {
      super.connectedCallback();

      // Initialize targetTimegroup
      this.targetTimegroup = this.querySelector("ef-timegroup");

      this.#timegroupObserver.observe(this, {
        childList: true,
        subtree: true,
        attributes: true,
      });

      if (this.playing) {
        this.startPlayback();
      }
    }

    disconnectedCallback(): void {
      super.disconnectedCallback();
      this.#timegroupObserver.disconnect();
      this.stopPlayback();
      // Clear token cache on disconnect to prevent stale tokens
      this.#URLTokens = {};
      this.#URLTokenExpirations = {};
    }

    update(changedProperties: Map<string | number | symbol, unknown>) {
      if (changedProperties.has("playing")) {
        if (this.playing) {
          this.startPlayback();
        } else {
          this.stopPlayback();
        }
      }
      if (changedProperties.has("currentTimeMs") && this.targetTimegroup) {
        if (this.targetTimegroup.currentTimeMs !== this.currentTimeMs) {
          this.targetTimegroup.currentTimeMs = this.currentTimeMs;
          if (this.isConnected) {
            this.dispatchEvent(
              new CustomEvent("timeupdate", {
                detail: {
                  currentTimeMs: this.currentTimeMs,
                  progress:
                    this.currentTimeMs / this.targetTimegroup.durationMs,
                },
              }),
            );
          }
        }
      }
      super.update(changedProperties);
    }

    play() {
      this.playing = true;
    }

    pause() {
      this.playing = false;
    }

    #playbackAudioContext: AudioContext | null = null;
    #playbackAnimationFrameRequest: number | null = null;
    #AUDIO_PLAYBACK_SLICE_MS = ((47 * 1024) / 48000) * 1000; // AAC-aligned: ~1002.67ms

    #syncPlayheadToAudioContext(target: EFTimegroup, startMs: number) {
      const rawTimeMs =
        startMs + (this.#playbackAudioContext?.currentTime ?? 0) * 1000;
      const nextTimeMs =
        Math.round(rawTimeMs / this.#MS_PER_FRAME) * this.#MS_PER_FRAME;
      if (nextTimeMs !== this.currentTimeMs) {
        this.currentTimeMs = nextTimeMs;
      }
      this.#playbackAnimationFrameRequest = requestAnimationFrame(() => {
        this.#syncPlayheadToAudioContext(target, startMs);
      });
    }

    private async stopPlayback() {
      if (this.#playbackAudioContext) {
        if (this.#playbackAudioContext.state !== "closed") {
          await this.#playbackAudioContext.close();
        }
      }
      if (this.#playbackAnimationFrameRequest) {
        cancelAnimationFrame(this.#playbackAnimationFrameRequest);
      }
      this.#playbackAudioContext = null;
      this.#playbackAnimationFrameRequest = null;
    }

    private async startPlayback() {
      await this.stopPlayback();
      const timegroup = this.targetTimegroup;
      if (!timegroup) {
        return;
      }

      await timegroup.waitForMediaDurations();

      let currentMs = timegroup.currentTimeMs;
      const fromMs = currentMs;
      const toMs = timegroup.endTimeMs;

      if (fromMs >= toMs) {
        this.pause();
        return;
      }

      let bufferCount = 0;
      this.#playbackAudioContext = new AudioContext({
        latencyHint: "playback",
      });

      if (this.#playbackAnimationFrameRequest) {
        cancelAnimationFrame(this.#playbackAnimationFrameRequest);
      }
      this.#syncPlayheadToAudioContext(timegroup, currentMs);
      const playbackContext = this.#playbackAudioContext;
      if (playbackContext.state === "suspended") {
        console.warn(
          "AudioContext is suspended, media playback will not work until user has interacted with page.",
        );
        this.playing = false;
        return;
      }
      await playbackContext.suspend();

      const fillBuffer = async () => {
        if (bufferCount > 2) {
          return;
        }
        const canFillBuffer = await queueBufferSource();
        if (canFillBuffer) {
          fillBuffer();
        }
      };

      const queueBufferSource = async () => {
        if (currentMs >= toMs) {
          return false;
        }
        const startMs = currentMs;
        const endMs = currentMs + this.#AUDIO_PLAYBACK_SLICE_MS;
        currentMs += this.#AUDIO_PLAYBACK_SLICE_MS;
        const audioBuffer = await timegroup.renderAudio(startMs, endMs);
        bufferCount++;
        const source = playbackContext.createBufferSource();
        source.buffer = audioBuffer;
        source.connect(playbackContext.destination);
        source.start((startMs - fromMs) / 1000);
        source.onended = () => {
          bufferCount--;
          if (endMs >= toMs) {
            this.pause();
            if (this.loop) {
              this.updateComplete.then(() => {
                this.currentTimeMs = 0;
                this.updateComplete.then(() => {
                  this.play();
                });
              });
            }
          } else {
            fillBuffer();
          }
        };
        return true;
      };

      await fillBuffer();
      await playbackContext.resume();
    }
  }

  return ContextElement as Constructor<ContextMixinInterface> & T;
}
