import { describe, it, expect, beforeEach, vi } from "vitest";
import CmcdDataBuilder from "../cmcd_data_builder";

/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-argument */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */

const { mockLogDebug, mockCreateUuid, mockGetRelativeUrl } = vi.hoisted(() => {
  return {
    mockLogDebug: vi.fn(),
    mockCreateUuid: vi.fn(() => "test-uuid"),
    mockGetRelativeUrl: vi.fn(),
  };
});

vi.mock("../../../log", () => ({
  default: {
    debug: mockLogDebug,
  },
}));

vi.mock("../../../utils/create_uuid", () => ({
  default: mockCreateUuid,
}));

vi.mock("../../../utils/url-utils", () => ({
  getRelativeUrl: mockGetRelativeUrl,
}));

/** Helpers */
function makePlaybackObserver(
  overrides: Partial<{
    rebuffering: object | null;
    speed: number;
    buffered: Record<string, Array<{ start: number; end: number }> | null>;
    position: { getWanted: () => number | undefined; getPolled: () => number };
  }> = {},
): any {
  const obs = {
    rebuffering: null,
    speed: 1,
    buffered: { video: null, audio: null, text: null },
    position: {
      getWanted: () => 10,
      getPolled: () => 10,
    },
    ...overrides,
  };

  const getReference = vi.fn(() => ({ getValue: () => obs }));
  const getCurrentTime = vi.fn(() => 10);
  const listen = vi.fn((cb: (o: typeof obs) => void, _opts: unknown) => {
    cb(obs);
  });

  return { getReference, getCurrentTime, listen, obs };
}

function makeRepresentation(bitrate: number, playable = true) {
  return {
    bitrate,
    isPlayable: vi.fn(() => (playable ? true : false)),
  };
}

function makeSegmentInfo(
  overrides: Partial<{
    nextSegment: object | null | undefined;
    isInit: boolean;
    segmentUrl: string | null;
    nextSegmentUrl: string | null;
  }> = {},
): any {
  const {
    nextSegment,
    isInit = false,
    segmentUrl = "https://cdn.example.com/video/seg1.m4s",
    nextSegmentUrl = "https://cdn.example.com/video/seg2.m4s",
  } = overrides;

  const representations = [makeRepresentation(2_000_000), makeRepresentation(4_000_000)];

  return {
    manifest: { transport: "dash", isDynamic: false },
    period: {},
    adaptation: {
      type: "video",
      representations,
    },
    representation: { bitrate: 2_000_000 },
    segment: {
      duration: 4,
      url: segmentUrl,
      isInit,
      range: undefined,
      indexRange: undefined,
    },
    nextSegment:
      nextSegment !== undefined
        ? nextSegment
        : {
            url: nextSegmentUrl,
            isInit: false,
            range: undefined,
            indexRange: undefined,
          },
  };
}

describe("CmcdDataBuilder", () => {
  beforeEach(() => {
    mockCreateUuid.mockReturnValue("test-uuid");
    mockGetRelativeUrl.mockReturnValue(null);
  });

  describe("constructor", () => {
    it("uses provided sessionId and contentId", () => {
      const builder = new CmcdDataBuilder({
        sessionId: "my-session",
        contentId: "my-content",
        communicationType: "query",
      });
      const payload: any = builder.getCmcdDataForManifest("dash");
      expect(payload.type).toBe("query");
      const qs = decodeURIComponent(payload.value[0][1]);
      expect(qs).toContain("my-session");
      expect(qs).toContain("my-content");
    });

    it("generates UUIDs when sessionId/contentId not provided", () => {
      mockCreateUuid
        .mockReturnValueOnce("uuid-session")
        .mockReturnValueOnce("uuid-content");
      const builder = new CmcdDataBuilder({ communicationType: "query" });
      const payload: any = builder.getCmcdDataForManifest("dash");
      const qs = decodeURIComponent(payload.value[0][1]);
      expect(qs).toContain("uuid-session");
      expect(qs).toContain("uuid-content");
    });
  });

  describe("getCmcdDataForManifest", () => {
    it("returns headers payload when communicationType is headers", () => {
      const builder = new CmcdDataBuilder({
        sessionId: "s1",
        contentId: "c1",
        communicationType: "headers",
      });
      const payload: any = builder.getCmcdDataForManifest("dash");
      expect(payload.type).toBe("headers");
      expect(payload.value).toHaveProperty("CMCD-Object");
      expect(payload.value).toHaveProperty("CMCD-Session");
    });

    it("returns query payload when communicationType is query", () => {
      const builder = new CmcdDataBuilder({
        sessionId: "s1",
        contentId: "c1",
        communicationType: "query",
      });
      const payload: any = builder.getCmcdDataForManifest("dash");
      expect(payload.type).toBe("query");
    });

    it("sets ot=m for manifest", () => {
      const builder = new CmcdDataBuilder({ communicationType: "headers" });
      const payload: any = builder.getCmcdDataForManifest("dash");
      const objectHeader = payload.value["CMCD-Object"];
      expect(objectHeader).toContain("ot=m");
    });

    it("sets sf=d for dash transport", () => {
      const builder = new CmcdDataBuilder({ communicationType: "headers" });
      const payload: any = builder.getCmcdDataForManifest("dash");
      const sessionHeader = payload.value["CMCD-Session"];
      expect(sessionHeader).toContain("sf=d");
    });

    it("sets sf=s for smooth transport", () => {
      const builder = new CmcdDataBuilder({ communicationType: "headers" });
      const payload: any = builder.getCmcdDataForManifest("smooth");
      const sessionHeader = payload.value["CMCD-Session"];
      expect(sessionHeader).toContain("sf=s");
    });

    it("sets sf=o for unknown transport", () => {
      const builder = new CmcdDataBuilder({ communicationType: "headers" });
      const payload: any = builder.getCmcdDataForManifest("hls");
      const sessionHeader = payload.value["CMCD-Session"];
      expect(sessionHeader).toContain("sf=o");
    });
  });

  describe("getCmcdDataForSegmentRequest", () => {
    it("sets br from representation bitrate (in kbps)", () => {
      const builder = new CmcdDataBuilder({ communicationType: "headers" });
      const payload: any = builder.getCmcdDataForSegmentRequest(makeSegmentInfo());
      const objectHeader = payload.value["CMCD-Object"];
      expect(objectHeader).toContain("br=2000");
    });

    it("sets d from segment duration (in ms)", () => {
      const builder = new CmcdDataBuilder({ communicationType: "headers" });
      const payload: any = builder.getCmcdDataForSegmentRequest(makeSegmentInfo());
      const objectHeader = payload.value["CMCD-Object"];
      expect(objectHeader).toContain("d=4000");
    });

    it("sets ot=v for video segments", () => {
      const builder = new CmcdDataBuilder({ communicationType: "headers" });
      const payload: any = builder.getCmcdDataForSegmentRequest(makeSegmentInfo());
      const objectHeader = payload.value["CMCD-Object"];
      expect(objectHeader).toContain("ot=v");
    });

    it("sets ot=a for audio segments", () => {
      const builder = new CmcdDataBuilder({ communicationType: "headers" });
      const info = makeSegmentInfo();
      info.adaptation.type = "audio";
      const payload: any = builder.getCmcdDataForSegmentRequest(info);
      const objectHeader = payload.value["CMCD-Object"];
      expect(objectHeader).toContain("ot=a");
    });

    it("sets ot=c for text segments", () => {
      const builder = new CmcdDataBuilder({ communicationType: "headers" });
      const info = makeSegmentInfo();
      info.adaptation.type = "text";
      const payload: any = builder.getCmcdDataForSegmentRequest(info);
      const objectHeader = payload.value["CMCD-Object"];
      expect(objectHeader).toContain("ot=c");
    });

    it("overrides ot to i for init segments", () => {
      const builder = new CmcdDataBuilder({ communicationType: "headers" });
      const info = makeSegmentInfo({ isInit: true });
      const payload: any = builder.getCmcdDataForSegmentRequest(info);
      const objectHeader = payload.value["CMCD-Object"];
      expect(objectHeader).toContain("ot=i");
    });

    it("sets st=v for VOD", () => {
      const builder = new CmcdDataBuilder({ communicationType: "headers" });
      const payload: any = builder.getCmcdDataForSegmentRequest(makeSegmentInfo());
      const sessionHeader = payload.value["CMCD-Session"];
      expect(sessionHeader).toContain("st=v");
    });

    it("sets st=l for live", () => {
      const builder = new CmcdDataBuilder({ communicationType: "headers" });
      const info = makeSegmentInfo();
      info.manifest.isDynamic = true;
      const payload: any = builder.getCmcdDataForSegmentRequest(info);
      const sessionHeader = payload.value["CMCD-Session"];
      expect(sessionHeader).toContain("st=l");
    });

    it("sets tb to the highest playable bitrate in kbps", () => {
      const builder = new CmcdDataBuilder({ communicationType: "headers" });
      const payload: any = builder.getCmcdDataForSegmentRequest(makeSegmentInfo());
      const objectHeader = payload.value["CMCD-Object"];
      expect(objectHeader).toContain("tb=4000");
    });

    it("ignores non-playable representations when computing tb", () => {
      const builder = new CmcdDataBuilder({ communicationType: "headers" });
      const info = makeSegmentInfo();
      info.adaptation.representations = [
        makeRepresentation(6_000_000, false),
        makeRepresentation(2_000_000, true),
      ];
      const payload: any = builder.getCmcdDataForSegmentRequest(info);
      const objectHeader = payload.value["CMCD-Object"];
      expect(objectHeader).toContain("tb=2000");
    });

    it("sets nor when nextSegment has a relative url", () => {
      mockGetRelativeUrl.mockReturnValue("seg2.m4s");
      const builder = new CmcdDataBuilder({ communicationType: "headers" });
      const payload: any = builder.getCmcdDataForSegmentRequest(makeSegmentInfo());
      const requestHeader = payload.value["CMCD-Request"];
      expect(requestHeader).toContain("nor=");
    });

    it("does not set nor when relative url is '.' (same file)", () => {
      mockGetRelativeUrl.mockReturnValue(".");
      const builder = new CmcdDataBuilder({ communicationType: "headers" });
      const payload: any = builder.getCmcdDataForSegmentRequest(makeSegmentInfo());
      const requestHeader = payload.value["CMCD-Request"];
      expect(requestHeader).not.toContain("nor=");
    });

    it("does not set nor when nextSegment is null", () => {
      mockGetRelativeUrl.mockReturnValue("seg2.m4s");
      const builder = new CmcdDataBuilder({ communicationType: "headers" });
      const payload: any = builder.getCmcdDataForSegmentRequest(
        makeSegmentInfo({ nextSegment: null }),
      );
      const requestHeader = payload.value["CMCD-Request"];
      expect(requestHeader).not.toContain("nor=");
    });

    it("sets nrr when next segment has a range", () => {
      mockGetRelativeUrl.mockReturnValue("seg2.m4s");
      const builder = new CmcdDataBuilder({ communicationType: "headers" });
      const info = makeSegmentInfo();
      (info.nextSegment as { range: [number, number] }).range = [0, 999];
      const payload: any = builder.getCmcdDataForSegmentRequest(info);
      const requestHeader = payload.value["CMCD-Request"];
      expect(requestHeader).toContain("nrr=");
      expect(requestHeader).toContain("0-999");
    });
  });

  describe("updateThroughput + mtp", () => {
    it("includes mtp rounded to nearest 100kbps when throughput is set", () => {
      const builder = new CmcdDataBuilder({ communicationType: "headers" });
      builder.updateThroughput("video", 3_450_000); // 3450 kbps → round to 3500
      const payload: any = builder.getCmcdDataForSegmentRequest(makeSegmentInfo());
      const requestHeader = payload.value["CMCD-Request"];
      expect(requestHeader).toContain("mtp=3500");
    });

    it("does not include mtp when throughput is undefined", () => {
      const builder = new CmcdDataBuilder({ communicationType: "headers" });
      const payload: any = builder.getCmcdDataForSegmentRequest(makeSegmentInfo());
      const requestHeader = payload.value["CMCD-Request"];
      expect(requestHeader).not.toContain("mtp=");
    });
  });

  describe("startMonitoringPlayback / buffer starvation", () => {
    it("sets bs=true when rebuffering was observed, then clears it on next request", () => {
      const builder = new CmcdDataBuilder({ communicationType: "headers" });
      const observer = makePlaybackObserver({ rebuffering: { timestamp: 0 } });
      builder.startMonitoringPlayback(observer);

      const payload1: any = builder.getCmcdDataForManifest("dash");
      const status1 = payload1.value["CMCD-Status"];
      // bs is a boolean flag - present means true
      expect(status1).toContain("bs");

      // second request - bs should have been reset
      const payload2: any = builder.getCmcdDataForManifest("dash");
      const status2 = payload2.value["CMCD-Status"];
      expect(status2).not.toContain("bs");
    });

    it("includes bl (buffer length) for video when playback observer is set", () => {
      const builder = new CmcdDataBuilder({ communicationType: "headers" });
      const observer = makePlaybackObserver({
        buffered: {
          video: [{ start: 5, end: 20 }],
          audio: null,
          text: null,
        },
        speed: 1,
        rebuffering: null,
      });
      builder.startMonitoringPlayback(observer);

      const payload: any = builder.getCmcdDataForSegmentRequest(makeSegmentInfo());
      const requestHeader = payload.value["CMCD-Request"];
      // position = 10, range.end = 20 → bufferLength = 10000ms → rounded to 10000
      expect(requestHeader).toContain("bl=10000");
    });

    it("includes pr when playback speed is not 1", () => {
      const builder = new CmcdDataBuilder({ communicationType: "headers" });
      const observer = makePlaybackObserver({ speed: 2, rebuffering: null });
      builder.startMonitoringPlayback(observer);

      const payload: any = builder.getCmcdDataForManifest("dash");
      const sessionHeader = payload.value["CMCD-Session"];
      expect(sessionHeader).toContain("pr=2");
    });

    it("does not include pr when speed is 1", () => {
      const builder = new CmcdDataBuilder({ communicationType: "headers" });
      const observer = makePlaybackObserver({ speed: 1, rebuffering: null });
      builder.startMonitoringPlayback(observer);

      const payload: any = builder.getCmcdDataForManifest("dash");
      const sessionHeader = payload.value["CMCD-Session"];
      expect(sessionHeader).not.toContain("pr=");
    });

    it("does not include pr when playback speed is negative", () => {
      const builder = new CmcdDataBuilder({ communicationType: "headers" });
      const observer = makePlaybackObserver({ speed: -1, rebuffering: null });
      builder.startMonitoringPlayback(observer);

      const payload: any = builder.getCmcdDataForManifest("dash");
      const sessionHeader = payload.value["CMCD-Session"];
      expect(sessionHeader).not.toContain("pr=");
    });

    it("includes su when rebuffering is active", () => {
      const builder = new CmcdDataBuilder({ communicationType: "headers" });
      const observer = makePlaybackObserver({ rebuffering: { timestamp: 0 }, speed: 1 });
      builder.startMonitoringPlayback(observer);

      const payload: any = builder.getCmcdDataForManifest("dash");
      const requestHeader = payload.value["CMCD-Request"];
      expect(requestHeader).toContain("su");
    });

    it("does not include dl or rtp when playback speed is 0", () => {
      const builder = new CmcdDataBuilder({ communicationType: "headers" });
      const observer = makePlaybackObserver({
        buffered: {
          video: [{ start: 5, end: 20 }],
          audio: null,
          text: null,
        },
        speed: 0,
        rebuffering: null,
      });
      builder.startMonitoringPlayback(observer);

      const payload: any = builder.getCmcdDataForSegmentRequest(makeSegmentInfo());
      const requestHeader = payload.value["CMCD-Request"];
      const statusHeader = payload.value["CMCD-Status"];
      expect(requestHeader).toContain("bl=10000");
      expect(requestHeader).not.toContain("dl=");
      expect(statusHeader).not.toContain("rtp=");
    });
  });

  describe("stopMonitoringPlayback", () => {
    it("clears the playback observer so subsequent calls work without observation data", () => {
      const builder = new CmcdDataBuilder({ communicationType: "headers" });
      const observer = makePlaybackObserver({ speed: 2, rebuffering: null });
      builder.startMonitoringPlayback(observer);
      builder.stopMonitoringPlayback();

      const payload: any = builder.getCmcdDataForManifest("dash");
      const sessionHeader = payload.value["CMCD-Session"];
      // no pr since observer is gone
      expect(sessionHeader).not.toContain("pr=");
    });
  });

  describe("query string output format", () => {
    it("encodes the query string and wraps it as CMCD param", () => {
      const builder = new CmcdDataBuilder({
        sessionId: "s",
        contentId: "c",
        communicationType: "query",
      });
      const payload: any = builder.getCmcdDataForManifest("dash");
      expect(payload.type).toBe("query");
      const value = payload.value;
      expect(value[0][0]).toBe("CMCD");
    });

    it("does not have trailing comma in query string", () => {
      const builder = new CmcdDataBuilder({
        sessionId: "s",
        contentId: "c",
        communicationType: "query",
      });
      const payload: any = builder.getCmcdDataForManifest("dash");
      const qs = decodeURIComponent(payload.value[0][1]);
      expect(qs[qs.length - 1]).not.toBe(",");
    });

    it("does not have trailing comma in headers", () => {
      const builder = new CmcdDataBuilder({
        sessionId: "s",
        contentId: "c",
        communicationType: "headers",
      });
      const payload = builder.getCmcdDataForManifest("dash");
      const headers = payload.value;
      // eslint-disable-next-line no-restricted-properties
      for (const val of Object.values(headers)) {
        if (val.length > 0) {
          expect(val[val.length - 1]).not.toBe(",");
        }
      }
    });

    it("escapes all backslashes and quotes in header strings", () => {
      const builder = new CmcdDataBuilder({
        sessionId: 's\\"id\\tail',
        contentId: 'c\\"id\\tail',
        communicationType: "headers",
      });
      const payload: any = builder.getCmcdDataForManifest("dash");
      const sessionHeader = payload.value["CMCD-Session"];
      expect(sessionHeader).toContain('cid="c\\\\\\"id\\\\tail"');
      expect(sessionHeader).toContain('sid="s\\\\\\"id\\\\tail"');
    });

    it("preserves all backslashes and quotes after query decoding", () => {
      const builder = new CmcdDataBuilder({
        sessionId: 's\\"id\\tail',
        contentId: 'c\\"id\\tail',
        communicationType: "query",
      });
      const payload: any = builder.getCmcdDataForManifest("dash");
      const qs = decodeURIComponent(payload.value[0][1]);
      expect(qs).toContain('cid="c\\\\\\"id\\\\tail"');
      expect(qs).toContain('sid="s\\\\\\"id\\\\tail"');
    });
  });
});
