import { html, LitElement } from "lit";
import { customElement } from "lit/decorators/custom-element.js";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";

import { ContextMixin } from "./ContextMixin.js";

// Required to test timeupdate event, we need a duration, and timegroups are a quick way to do that
import "../elements/EFTimegroup.js";

@customElement("test-context")
class TestContext extends ContextMixin(LitElement) {}

@customElement("test-context-reactivity")
class TestContextElement extends ContextMixin(LitElement) {
  render() {
    return html`<slot></slot>`;
  }
}

declare global {
  interface HTMLElementTagNameMap {
    "test-context": TestContext;
    "test-context-reactivity": TestContextElement;
  }
}

describe("ContextMixin", () => {
  test("should be defined", () => {
    expect(ContextMixin).toBeDefined();
  });

  describe("Token age checking", () => {
    let element: TestContext & { fetch: typeof fetch };
    let mockFetch: any;
    let originalFetch: any;

    // Helper to create a JWT token with specific expiration time
    const createJWTToken = (
      expirationMs: number,
      issuedAtMs?: number,
    ): string => {
      const iat = issuedAtMs
        ? Math.floor(issuedAtMs / 1000)
        : Math.floor(Date.now() / 1000);
      const header = btoa(JSON.stringify({ typ: "JWT", alg: "HS256" }));
      const payload = btoa(
        JSON.stringify({
          type: "url",
          url: "test-url",
          exp: Math.floor(expirationMs / 1000), // JWT exp is in seconds
          iat: iat,
        }),
      );
      const signature = "mock-signature";
      return `${header}.${payload}.${signature}`;
    };

    beforeEach(() => {
      originalFetch = window.fetch;
      mockFetch = vi.fn();
      window.fetch = mockFetch;

      element = document.createElement("test-context") as TestContext & {
        fetch: typeof fetch;
      };
      element.signingURL = "https://test.com/api/v1/url-token";
      document.body.appendChild(element);

      vi.useFakeTimers();
    });

    afterEach(() => {
      if (element.parentNode) {
        document.body.removeChild(element);
      }
      window.fetch = originalFetch;
      vi.useRealTimers();
    });

    test("should fetch new token when none exists", async () => {
      const futureTime = Date.now() + 60 * 60 * 1000; // 1 hour from now
      const token = createJWTToken(futureTime);

      mockFetch.mockResolvedValueOnce({
        ok: true,
        json: () => Promise.resolve({ token }),
      });

      mockFetch.mockResolvedValueOnce({
        ok: true,
        text: () => Promise.resolve("success"),
      });

      await element.fetch("https://example.com/media.mp4");

      expect(mockFetch).toHaveBeenCalledWith(
        "https://test.com/api/v1/url-token",
        {
          method: "POST",
          body: JSON.stringify({ url: "https://example.com/media.mp4" }),
        },
      );

      expect(mockFetch).toHaveBeenCalledWith("https://example.com/media.mp4", {
        headers: {
          "Content-Type": "application/json",
          authorization: `Bearer ${token}`,
        },
      });
    }, 1000);

    test("should reuse cached token when fresh", async () => {
      const futureTime = Date.now() + 60 * 60 * 1000; // 1 hour from now
      const token = createJWTToken(futureTime);

      mockFetch.mockResolvedValueOnce({
        ok: true,
        json: () => Promise.resolve({ token }),
      });

      mockFetch.mockResolvedValueOnce({
        ok: true,
        text: () => Promise.resolve("success1"),
      });

      await element.fetch("https://example.com/media.mp4");

      // Advance time by 30 minutes (token still valid for 30 more minutes)
      vi.advanceTimersByTime(30 * 60 * 1000);

      mockFetch.mockResolvedValueOnce({
        ok: true,
        text: () => Promise.resolve("success2"),
      });

      await element.fetch("https://example.com/media.mp4");

      // Should only call signing URL once
      expect(mockFetch).toHaveBeenCalledTimes(3); // 1 signing + 2 media requests
    }, 1000);

    test("should fetch new token when cached token is expired", async () => {
      const now = Date.now();
      const issuedAt = now;
      const expiresAt = now + 30 * 60 * 1000; // 30 minutes from now

      // For a 30-minute token: 10% = 3 minutes buffer (smaller than 5 minutes)
      // Token will be considered expired at 27 minutes
      const token1 = createJWTToken(expiresAt, issuedAt);

      mockFetch.mockResolvedValueOnce({
        ok: true,
        json: () => Promise.resolve({ token: token1 }),
      });

      mockFetch.mockResolvedValueOnce({
        ok: true,
        text: () => Promise.resolve("success1"),
      });

      await element.fetch("https://example.com/media.mp4");

      // Advance time by 28 minutes (past the 27-minute buffer threshold)
      vi.advanceTimersByTime(28 * 60 * 1000);

      const newExpiry = Date.now() + 60 * 60 * 1000; // 1 hour from current time
      const token2 = createJWTToken(newExpiry);

      mockFetch.mockResolvedValueOnce({
        ok: true,
        json: () => Promise.resolve({ token: token2 }),
      });

      mockFetch.mockResolvedValueOnce({
        ok: true,
        text: () => Promise.resolve("success2"),
      });

      await element.fetch("https://example.com/media.mp4");

      // Should call signing URL twice
      expect(mockFetch).toHaveBeenCalledTimes(4); // 2 signing + 2 media requests

      expect(mockFetch).toHaveBeenCalledWith(
        "https://test.com/api/v1/url-token",
        {
          method: "POST",
          body: JSON.stringify({ url: "https://example.com/media.mp4" }),
        },
      );

      expect(mockFetch).toHaveBeenCalledWith("https://example.com/media.mp4", {
        headers: {
          "Content-Type": "application/json",
          authorization: `Bearer ${token2}`,
        },
      });
    }, 1000);

    test("should handle different URLs with separate token expiry", async () => {
      const url1 = "https://example.com/media1.mp4";
      const url2 = "https://example.com/media2.mp4";

      const startTime = Date.now();

      // URL1 gets a 30-minute token (10% buffer = 3 min, so expires at 27 min)
      const url1IssuedAt = startTime;
      const url1ExpiresAt = startTime + 30 * 60 * 1000; // 30 minutes
      const token1 = createJWTToken(url1ExpiresAt, url1IssuedAt);

      mockFetch.mockResolvedValueOnce({
        ok: true,
        json: () => Promise.resolve({ token: token1 }),
      });
      mockFetch.mockResolvedValueOnce({
        ok: true,
        text: () => Promise.resolve("success1"),
      });

      await element.fetch(url1);

      // Advance time by 15 minutes
      vi.advanceTimersByTime(15 * 60 * 1000);

      // URL2 gets a 60-minute token (5 min buffer is smaller than 10% = 6 min, so expires at 55 min)
      const url2IssuedAt = Date.now();
      const url2ExpiresAt = url2IssuedAt + 60 * 60 * 1000; // 60 minutes from current time
      const token2 = createJWTToken(url2ExpiresAt, url2IssuedAt);

      mockFetch.mockResolvedValueOnce({
        ok: true,
        json: () => Promise.resolve({ token: token2 }),
      });
      mockFetch.mockResolvedValueOnce({
        ok: true,
        text: () => Promise.resolve("success2"),
      });

      await element.fetch(url2);

      // Advance time by another 15 minutes (total 30 min from start)
      // URL1 token: 30 min from start, past its 27-min threshold → expired
      // URL2 token: 15 min from its creation, well within its 55-min threshold → still valid
      vi.advanceTimersByTime(15 * 60 * 1000);

      // Request URL1 again (should get new token - expired)
      const url1NewExpiry = Date.now() + 60 * 60 * 1000;
      const token3 = createJWTToken(url1NewExpiry);

      mockFetch.mockResolvedValueOnce({
        ok: true,
        json: () => Promise.resolve({ token: token3 }),
      });
      mockFetch.mockResolvedValueOnce({
        ok: true,
        text: () => Promise.resolve("success3"),
      });

      await element.fetch(url1);

      // Request URL2 again (should reuse token - still valid)
      mockFetch.mockResolvedValueOnce({
        ok: true,
        text: () => Promise.resolve("success4"),
      });

      await element.fetch(url2);

      expect(mockFetch).toHaveBeenCalledTimes(7); // 3 signing + 4 media requests (URL2 reused token)
    }, 1000);

    test("should clear token cache on component disconnect", async () => {
      const futureTime = Date.now() + 60 * 60 * 1000; // 1 hour from now
      const token1 = createJWTToken(futureTime);

      mockFetch.mockResolvedValueOnce({
        ok: true,
        json: () => Promise.resolve({ token: token1 }),
      });

      mockFetch.mockResolvedValueOnce({
        ok: true,
        text: () => Promise.resolve("success"),
      });

      await element.fetch("https://example.com/media.mp4");

      if (element.parentNode) {
        document.body.removeChild(element);
      }

      // Create new element and add back to DOM
      element = document.createElement("test-context") as TestContext & {
        fetch: typeof fetch;
      };
      element.signingURL = "https://test.com/api/v1/url-token";
      document.body.appendChild(element);

      const token2 = createJWTToken(futureTime);

      mockFetch.mockResolvedValueOnce({
        ok: true,
        json: () => Promise.resolve({ token: token2 }),
      });

      mockFetch.mockResolvedValueOnce({
        ok: true,
        text: () => Promise.resolve("success2"),
      });

      await element.fetch("https://example.com/media.mp4");

      expect(mockFetch).toHaveBeenCalledTimes(4); // 2 signing + 2 media requests
    }, 1000);

    test("should use single token for multiple transcode segments with same source URL", async () => {
      const futureTime = Date.now() + 60 * 60 * 1000;
      const token = createJWTToken(futureTime);

      const sourceUrl = "https://example.com/source-video.mp4";
      const segment1 = `https://editframe.dev/api/v1/transcode/video-1080p/1.mp4?url=${encodeURIComponent(sourceUrl)}`;
      const segment2 = `https://editframe.dev/api/v1/transcode/video-1080p/2.mp4?url=${encodeURIComponent(sourceUrl)}`;
      const segment3 = `https://editframe.dev/api/v1/transcode/audio-44100/1.m4a?url=${encodeURIComponent(sourceUrl)}`;

      // Should only sign once for the base URL + params combination
      mockFetch.mockResolvedValueOnce({
        ok: true,
        json: () => Promise.resolve({ token }),
      });

      // Mock all segment requests
      mockFetch.mockResolvedValueOnce({
        ok: true,
        arrayBuffer: () => Promise.resolve(new ArrayBuffer(1024)),
      });
      mockFetch.mockResolvedValueOnce({
        ok: true,
        arrayBuffer: () => Promise.resolve(new ArrayBuffer(1024)),
      });
      mockFetch.mockResolvedValueOnce({
        ok: true,
        arrayBuffer: () => Promise.resolve(new ArrayBuffer(1024)),
      });

      // Fetch multiple segments
      await element.fetch(segment1);
      await element.fetch(segment2);
      await element.fetch(segment3);

      // Should only call signing URL once + 3 segment requests
      expect(mockFetch).toHaveBeenCalledTimes(4);

      // Verify the signing request used base URL + params format
      expect(mockFetch).toHaveBeenCalledWith(
        "https://test.com/api/v1/url-token",
        {
          method: "POST",
          body: JSON.stringify({
            url: "https://editframe.dev/api/v1/transcode",
            params: { url: sourceUrl },
          }),
        },
      );

      // All segment requests should use the same token
      expect(mockFetch).toHaveBeenCalledWith(segment1, {
        headers: {
          "Content-Type": "application/json",
          authorization: `Bearer ${token}`,
        },
      });
      expect(mockFetch).toHaveBeenCalledWith(segment2, {
        headers: {
          "Content-Type": "application/json",
          authorization: `Bearer ${token}`,
        },
      });
      expect(mockFetch).toHaveBeenCalledWith(segment3, {
        headers: {
          "Content-Type": "application/json",
          authorization: `Bearer ${token}`,
        },
      });
    }, 1000);

    test("should use different tokens for transcode segments with different source URLs", async () => {
      const futureTime = Date.now() + 60 * 60 * 1000;
      const token1 = createJWTToken(futureTime);
      const token2 = createJWTToken(futureTime);

      const sourceUrl1 = "https://example.com/video1.mp4";
      const sourceUrl2 = "https://example.com/video2.mp4";

      const segment1A = `https://editframe.dev/api/v1/transcode/video-1080p/1.mp4?url=${encodeURIComponent(sourceUrl1)}`;
      const segment1B = `https://editframe.dev/api/v1/transcode/video-1080p/2.mp4?url=${encodeURIComponent(sourceUrl1)}`;
      const segment2A = `https://editframe.dev/api/v1/transcode/video-720p/1.mp4?url=${encodeURIComponent(sourceUrl2)}`;

      // First source URL token request
      mockFetch.mockResolvedValueOnce({
        ok: true,
        json: () => Promise.resolve({ token: token1 }),
      });

      mockFetch.mockResolvedValueOnce({
        ok: true,
        arrayBuffer: () => Promise.resolve(new ArrayBuffer(1024)),
      });
      mockFetch.mockResolvedValueOnce({
        ok: true,
        arrayBuffer: () => Promise.resolve(new ArrayBuffer(1024)),
      });

      // Fetch segments for first source
      await element.fetch(segment1A);
      await element.fetch(segment1B);

      // Second source URL token request
      mockFetch.mockResolvedValueOnce({
        ok: true,
        json: () => Promise.resolve({ token: token2 }),
      });

      mockFetch.mockResolvedValueOnce({
        ok: true,
        arrayBuffer: () => Promise.resolve(new ArrayBuffer(1024)),
      });

      // Fetch segment for second source
      await element.fetch(segment2A);

      // Should call signing URL twice + 3 segment requests
      expect(mockFetch).toHaveBeenCalledTimes(5);

      // Verify separate signing requests
      expect(mockFetch).toHaveBeenCalledWith(
        "https://test.com/api/v1/url-token",
        {
          method: "POST",
          body: JSON.stringify({
            url: "https://editframe.dev/api/v1/transcode",
            params: { url: sourceUrl1 },
          }),
        },
      );
      expect(mockFetch).toHaveBeenCalledWith(
        "https://test.com/api/v1/url-token",
        {
          method: "POST",
          body: JSON.stringify({
            url: "https://editframe.dev/api/v1/transcode",
            params: { url: sourceUrl2 },
          }),
        },
      );
    }, 1000);

    test("should still sign individual URLs for non-transcode endpoints", async () => {
      const futureTime = Date.now() + 60 * 60 * 1000;
      const token1 = createJWTToken(futureTime);
      const token2 = createJWTToken(futureTime);

      const regularUrl1 = "https://example.com/api/v1/media/123";
      const regularUrl2 = "https://example.com/api/v1/assets/456";

      // First URL signing
      mockFetch.mockResolvedValueOnce({
        ok: true,
        json: () => Promise.resolve({ token: token1 }),
      });
      mockFetch.mockResolvedValueOnce({
        ok: true,
        text: () => Promise.resolve("success1"),
      });

      // Second URL signing
      mockFetch.mockResolvedValueOnce({
        ok: true,
        json: () => Promise.resolve({ token: token2 }),
      });
      mockFetch.mockResolvedValueOnce({
        ok: true,
        text: () => Promise.resolve("success2"),
      });

      await element.fetch(regularUrl1);
      await element.fetch(regularUrl2);

      expect(mockFetch).toHaveBeenCalledTimes(4); // 2 signing + 2 requests

      // Should sign each URL individually (existing behavior)
      expect(mockFetch).toHaveBeenCalledWith(
        "https://test.com/api/v1/url-token",
        {
          method: "POST",
          body: JSON.stringify({ url: regularUrl1 }),
        },
      );
      expect(mockFetch).toHaveBeenCalledWith(
        "https://test.com/api/v1/url-token",
        {
          method: "POST",
          body: JSON.stringify({ url: regularUrl2 }),
        },
      );
    }, 1000);
  });

  describe("Playback", () => {
    test("should start playback", () => {
      const element = document.createElement("test-context");
      element.playing = true;
      expect(element.playing).toBe(true);
    });

    test("playback starts immediately if connected", () => {
      const element = document.createElement("test-context");
      // @ts-expect-error startPlayback is private
      const playbackSpy = vi.spyOn(element, "startPlayback");
      element.playing = true;
      expect(element.playing).toBe(true);
      document.body.appendChild(element);
      expect(playbackSpy).toHaveBeenCalled();
    });

    test("playback stops immediately if disconnected", () => {
      const element = document.createElement("test-context");
      element.playing = true;
      expect(element.playing).toBe(true);
      document.body.appendChild(element);
      // @ts-expect-error stopPlayback is private
      const playbackSpy = vi.spyOn(element, "stopPlayback");
      document.body.removeChild(element);
      expect(playbackSpy).toHaveBeenCalled();
    });
  });

  test("Time update event when the currentTimeMs changed", async () => {
    const timegroup = document.createElement("ef-timegroup");
    timegroup.mode = "fixed";
    timegroup.duration = "10s";

    const preview = document.createElement("test-context");
    preview.append(timegroup);
    document.body.append(preview);

    type CurrentTimeEvent = CustomEvent<{ currentTimeMs: number }>;

    // Expect the timeupdate event to be dispatched
    const timeupdatePromise = new Promise<CurrentTimeEvent>((resolve) => {
      preview.addEventListener(
        "timeupdate",
        (event: Event) => resolve(event as CurrentTimeEvent),
        { once: true },
      );
    });
    preview.currentTimeMs = 1000;
    const event = await timeupdatePromise;
    expect(event.detail.currentTimeMs).toBe(1000);
  });

  describe("Reactivity", () => {
    test("should update durationMs when child tree changes", async () => {
      const element = document.createElement("test-context-reactivity");
      document.body.appendChild(element);

      // Wait for initial render
      await element.updateComplete;

      // Create a timegroup with initial duration
      const timegroup = document.createElement("ef-timegroup");
      timegroup.mode = "contain";

      const child = document.createElement("ef-timegroup");
      child.mode = "fixed";
      child.duration = "5s";
      timegroup.appendChild(child);

      element.appendChild(timegroup);

      // Wait for updates
      await element.updateComplete;
      await timegroup.updateComplete;

      // Initially, the timegroup should have 5s duration
      expect(element.targetTimegroup?.durationMs).toBe(5000);

      // Now change the child duration
      child.duration = "10s";

      // Wait for updates
      await element.updateComplete;
      await timegroup.updateComplete;

      // The targetTimegroup should now have 10s duration
      expect(element.targetTimegroup?.durationMs).toBe(10000);

      document.body.removeChild(element);
    });

    test("should update when media elements are added/removed", async () => {
      const element = document.createElement("test-context-reactivity");
      document.body.appendChild(element);

      await element.updateComplete;

      // Create initial timegroup with 5s duration
      const timegroup = document.createElement("ef-timegroup");
      timegroup.mode = "contain";

      const child = document.createElement("ef-timegroup");
      child.mode = "fixed";
      child.duration = "5s";
      timegroup.appendChild(child);

      element.appendChild(timegroup);

      await element.updateComplete;
      await timegroup.updateComplete;

      // Initially duration should be 5s
      expect(element.targetTimegroup?.durationMs).toBe(5000);

      // Add a new child with longer duration
      const newChild = document.createElement("ef-timegroup");
      newChild.mode = "fixed";
      newChild.duration = "15s";
      timegroup.appendChild(newChild);

      // Wait for updates
      await element.updateComplete;
      await timegroup.updateComplete;
      await newChild.updateComplete;

      // Wait for next animation frame to ensure temporal cache is cleared
      await new Promise((resolve) => requestAnimationFrame(resolve));

      // Duration should now be 15s (the max of all children)
      expect(element.targetTimegroup?.durationMs).toBe(15000);

      document.body.removeChild(element);
    });
  });
});
