import debug from "debug";
import { BandwidthCalculators, Playback } from "../internal-types.js";
import {
  CoreEventMap,
  RequestError,
  RequestAbortErrorType,
  RequestErrorType,
  SegmentWithStream,
  Segment,
} from "../types.js";
import * as StreamUtils from "../utils/stream.js";
import * as Utils from "../utils/utils.js";
import { EventTarget } from "../utils/event-target.js";

export type LoadProgress = {
  startTimestamp: number;
  lastLoadedChunkTimestamp?: number;
  startFromByte?: number;
  loadedBytes: number;
};

type HttpRequestAttempt = {
  downloadSource: "http";
  error?: RequestError;
};

type P2PRequestAttempt = {
  downloadSource: "p2p";
  peerId: string;
  error?: RequestError;
};

export type RequestAttempt = HttpRequestAttempt | P2PRequestAttempt;

export type RequestControls = Readonly<{
  firstBytesReceived: Request["firstBytesReceived"];
  addLoadedChunk: Request["addLoadedChunk"];
  completeOnSuccess: Request["completeOnSuccess"];
  failWithError: Request["failWithError"];
}>;

type OmitEncapsulated<T extends RequestAttempt> = Omit<
  T,
  "error" | "errorTimestamp"
>;
type StartRequestParameters =
  | OmitEncapsulated<HttpRequestAttempt>
  | OmitEncapsulated<P2PRequestAttempt>;

export type RequestStatus =
  | "not-started"
  | "loading"
  | "succeed"
  | "failed"
  | "aborted";

function mapSegmentWithStreamToSegment(segment: SegmentWithStream): Segment {
  return {
    runtimeId: segment.runtimeId,
    externalId: segment.externalId,
    url: segment.url,
    byteRange: segment.byteRange,
    startTime: segment.startTime,
    endTime: segment.endTime,
  };
}

export class Request {
  private currentAttempt?: RequestAttempt;
  private _failedAttempts = new FailedRequestAttempts();
  private finalData?: ArrayBuffer;
  private bytes: Uint8Array[] = [];
  private _loadedBytes = 0;
  private _totalBytes?: number;
  private _status: RequestStatus = "not-started";
  private progress?: LoadProgress;
  private notReceivingBytesTimeout: Timeout;
  private _onAbortCallback?: (
    error: RequestError<RequestAbortErrorType>,
  ) => void;
  private notReceivingBytesTimeoutMs?: number;
  private readonly _logger: debug.Debugger;
  private _isHandledByProcessQueue = false;
  private readonly onSegmentError: CoreEventMap["onSegmentError"];
  private readonly onSegmentAbort: CoreEventMap["onSegmentAbort"];
  private readonly onSegmentStart: CoreEventMap["onSegmentStart"];
  private readonly onSegmentLoaded: CoreEventMap["onSegmentLoaded"];

  constructor(
    readonly segment: SegmentWithStream,
    private readonly requestProcessQueueCallback: () => void,
    private readonly bandwidthCalculators: BandwidthCalculators,
    private readonly playback: Playback,
    private readonly playbackConfig: StreamUtils.PlaybackTimeWindowsConfig,
    eventTarget: EventTarget<CoreEventMap>,
    readonly infoHash: string,
  ) {
    this.onSegmentError = eventTarget.getEventDispatcher("onSegmentError");
    this.onSegmentAbort = eventTarget.getEventDispatcher("onSegmentAbort");
    this.onSegmentStart = eventTarget.getEventDispatcher("onSegmentStart");
    this.onSegmentLoaded = eventTarget.getEventDispatcher("onSegmentLoaded");

    const { byteRange } = this.segment;
    if (byteRange) {
      const { end, start } = byteRange;
      this._totalBytes = end - start + 1;
    }
    this.notReceivingBytesTimeout = new Timeout(this.abortOnTimeout);

    const { type } = this.segment.stream;
    this._logger = debug(`p2pml-core:request-${type}`);
  }

  clearLoadedBytes() {
    this._loadedBytes = 0;
    this.bytes = [];
    this._totalBytes = undefined;
    this.finalData = undefined;
  }

  get status() {
    return this._status;
  }

  private setStatus(status: RequestStatus) {
    this._status = status;
    this._isHandledByProcessQueue = false;
  }

  get downloadSource() {
    return this.currentAttempt?.downloadSource;
  }

  get loadedBytes() {
    return this._loadedBytes;
  }

  get totalBytes(): number | undefined {
    return this._totalBytes;
  }

  get data(): ArrayBuffer {
    this.finalData ??= Utils.joinChunks(this.bytes).buffer;
    return this.finalData;
  }

  get failedAttempts() {
    return this._failedAttempts;
  }

  get isHandledByProcessQueue() {
    return this._isHandledByProcessQueue;
  }

  markHandledByProcessQueue() {
    this._isHandledByProcessQueue = true;
  }

  setTotalBytes(value: number) {
    if (this._totalBytes !== undefined) {
      throw new Error("Request total bytes value is already set");
    }
    this._totalBytes = value;
  }

  /**
   * Checks if all bytes are already loaded and, if so, starts, validates,
   * and completes the request without making a network request.
   *
   * Handles three cases:
   * - loadedBytes === totalBytes: start → validate → complete (returns true)
   * - loadedBytes > totalBytes: corrupted state → clearLoadedBytes (returns false)
   * - otherwise: no-op (returns false)
   *
   * The request is started synchronously so that processQueue sees
   * it as "loading" immediately. Validation runs as fire-and-forget.
   *
   * @returns true if the request was started and is being handled,
   * false if caller should proceed with a normal download.
   */
  tryCompleteByLoadedBytes(
    requestData: StartRequestParameters,
    controls: {
      notReceivingBytesTimeoutMs?: number;
      onAbort: (errorType: RequestError<RequestAbortErrorType>) => void;
    },
    validate:
      | ((
          url: string,
          byteRange: Segment["byteRange"],
          data: ArrayBuffer,
        ) => Promise<boolean>)
      | undefined,
    validationErrorType: RequestErrorType,
  ): boolean {
    if (!this._totalBytes) return false;

    if (this._loadedBytes > this._totalBytes) {
      this.logger(
        `${requestData.downloadSource} ${this.segment.externalId} loaded bytes overflow: ${this._loadedBytes} > ${this._totalBytes}, clearing`,
      );
      this.clearLoadedBytes();
      return false;
    }

    if (this._loadedBytes !== this._totalBytes) return false;

    // Start synchronously so the request is immediately in "loading" status.
    const requestControls = this.start(requestData, controls);

    // No network bytes will arrive in this path, so disable the timeout
    // to prevent it from aborting the request during async validation.
    this.notReceivingBytesTimeout.clear();

    if (validate) {
      void this.validateAndComplete(
        requestData.downloadSource,
        requestControls,
        validate,
        validationErrorType,
      );
    } else {
      requestControls.completeOnSuccess();
    }

    return true;
  }

  async validateData(
    validate?: (
      url: string,
      byteRange: Segment["byteRange"],
      data: ArrayBuffer,
    ) => Promise<boolean>,
  ): Promise<boolean> {
    if (!validate) return true;
    try {
      return await validate(
        this.segment.url,
        this.segment.byteRange,
        this.data,
      );
    } catch (err) {
      this.logger(`validation threw an error: ${String(err)}`);
      return false;
    }
  }

  private async validateAndComplete(
    downloadSource: "http" | "p2p",
    requestControls: RequestControls,
    validate: (
      url: string,
      byteRange: Segment["byteRange"],
      data: ArrayBuffer,
    ) => Promise<boolean>,
    validationErrorType: RequestErrorType,
  ) {
    const isValid = await this.validateData(validate);

    // Request may have been aborted by processQueue while validation ran.
    if (this._status !== "loading") return;

    if (!isValid) {
      this.logger(
        `${downloadSource} ${this.segment.externalId} validation failed for already-loaded bytes, clearing`,
      );
      this.clearLoadedBytes();
      requestControls.failWithError(new RequestError(validationErrorType));
      return;
    }

    this.logger(
      `${downloadSource} ${this.segment.externalId} validation passed for already-loaded bytes`,
    );
    requestControls.completeOnSuccess();
  }

  start(
    requestData: StartRequestParameters,
    controls: {
      notReceivingBytesTimeoutMs?: number;
      onAbort: (errorType: RequestError<RequestAbortErrorType>) => void;
    },
  ): RequestControls {
    if (this._status === "succeed") {
      throw new Error(
        `Request ${this.segment.externalId} has been already succeed.`,
      );
    }
    if (this._status === "loading") {
      throw new Error(
        `Request ${this.segment.externalId} has been already started.`,
      );
    }

    this.setStatus("loading");
    this.currentAttempt = { ...requestData };
    this.progress = {
      startFromByte: this._loadedBytes,
      loadedBytes: 0,
      startTimestamp: performance.now(),
    };
    this.manageBandwidthCalculatorsState("start");

    const { notReceivingBytesTimeoutMs } = controls;
    this._onAbortCallback = controls.onAbort;
    this.notReceivingBytesTimeoutMs = notReceivingBytesTimeoutMs;

    if (notReceivingBytesTimeoutMs !== undefined) {
      this.notReceivingBytesTimeout.start(notReceivingBytesTimeoutMs);
    }

    this.logger(
      `${requestData.downloadSource} ${this.segment.externalId} started`,
    );

    this.onSegmentStart({
      segment: mapSegmentWithStreamToSegment(this.segment),
      downloadSource: requestData.downloadSource,
      peerId:
        requestData.downloadSource === "p2p" ? requestData.peerId : undefined,
      infoHash: this.infoHash,
      streamType: this.segment.stream.type,
    });

    return {
      firstBytesReceived: this.firstBytesReceived,
      addLoadedChunk: this.addLoadedChunk,
      completeOnSuccess: this.completeOnSuccess,
      failWithError: this.failWithError,
    };
  }

  cancel() {
    this.throwErrorIfNotLoadingStatus();
    this.setStatus("aborted");
    this.logger(
      `${this.currentAttempt?.downloadSource} ${this.segment.externalId} aborted`,
    );
    this._onAbortCallback?.(new RequestError("abort"));
    this.onSegmentAbort({
      segment: mapSegmentWithStreamToSegment(this.segment),
      downloadSource: this.currentAttempt?.downloadSource,
      peerId:
        this.currentAttempt?.downloadSource === "p2p"
          ? this.currentAttempt.peerId
          : undefined,
      infoHash: this.infoHash,
      streamType: this.segment.stream.type,
    });
    this._onAbortCallback = undefined;
    this.manageBandwidthCalculatorsState("stop");
    this.notReceivingBytesTimeout.clear();
  }

  private abortOnTimeout = () => {
    this.throwErrorIfNotLoadingStatus();
    if (
      !this.currentAttempt ||
      !this.progress ||
      this.notReceivingBytesTimeoutMs === undefined
    ) {
      return;
    }

    const now = performance.now();
    const lastActive =
      this.progress.lastLoadedChunkTimestamp ?? this.progress.startTimestamp;
    const msSinceLastActive = now - lastActive;

    if (msSinceLastActive < this.notReceivingBytesTimeoutMs) {
      // False alarm! The stream is still downloading. Reschedule the timer.
      this.notReceivingBytesTimeout.restart(
        this.notReceivingBytesTimeoutMs - msSinceLastActive,
      );
      return;
    }

    const error = new RequestError("bytes-receiving-timeout");
    this._onAbortCallback?.(error);
    this.handleFailure(error);
  };

  private failWithError = (error: RequestError) => {
    this.throwErrorIfNotLoadingStatus();
    if (!this.currentAttempt) return;

    this.handleFailure(error);
  };

  private handleFailure = (error: RequestError) => {
    if (!this.currentAttempt) return;

    this.setStatus("failed");
    this.logger(
      `${this.downloadSource} ${this.segment.externalId} failed ${error.type}`,
    );
    this._failedAttempts.add({
      ...this.currentAttempt,
      error,
    });
    this.onSegmentError({
      segment: mapSegmentWithStreamToSegment(this.segment),
      error,
      downloadSource: this.currentAttempt.downloadSource,
      peerId:
        this.currentAttempt.downloadSource === "p2p"
          ? this.currentAttempt.peerId
          : undefined,
      infoHash: this.infoHash,
      streamType: this.segment.stream.type,
    });
    this.notReceivingBytesTimeout.clear();
    this.manageBandwidthCalculatorsState("stop");
    this.requestProcessQueueCallback();
  };

  private completeOnSuccess = () => {
    this.throwErrorIfNotLoadingStatus();
    if (!this.currentAttempt) return;

    this.manageBandwidthCalculatorsState("stop");
    this.notReceivingBytesTimeout.clear();
    this.setStatus("succeed");
    this._totalBytes = this._loadedBytes;
    this.onSegmentLoaded({
      segmentUrl: this.segment.url,
      segment: mapSegmentWithStreamToSegment(this.segment),
      bytesLength: this.data.byteLength,
      downloadSource: this.currentAttempt.downloadSource,
      peerId:
        this.currentAttempt.downloadSource === "p2p"
          ? this.currentAttempt.peerId
          : undefined,
      infoHash: this.infoHash,
      streamType: this.segment.stream.type,
    });

    this.logger(
      `${this.currentAttempt.downloadSource} ${this.segment.externalId} succeed`,
    );
    this.requestProcessQueueCallback();
  };

  private addLoadedChunk = (chunk: Uint8Array) => {
    this.throwErrorIfNotLoadingStatus();
    if (!this.currentAttempt || !this.progress) return;

    const { byteLength } = chunk;
    const { all: allBC, http: httpBC } = this.bandwidthCalculators;
    allBC.addBytes(byteLength);
    if (this.currentAttempt.downloadSource === "http") {
      httpBC.addBytes(byteLength);
    }

    this.bytes.push(chunk);
    this.progress.lastLoadedChunkTimestamp = performance.now();
    this.progress.loadedBytes += byteLength;
    this._loadedBytes += byteLength;
  };

  private firstBytesReceived = () => {
    this.throwErrorIfNotLoadingStatus();
  };

  private throwErrorIfNotLoadingStatus() {
    if (this._status !== "loading") {
      throw new Error(`Request has been already ${this.status}.`);
    }
  }

  private logger(message: string) {
    this._logger.color =
      this.currentAttempt?.downloadSource === "http" ? "green" : "red";
    this._logger(message);
    this._logger.color = "";
  }

  private manageBandwidthCalculatorsState(state: "start" | "stop") {
    const { all, http } = this.bandwidthCalculators;
    const method = state === "start" ? "startLoading" : "stopLoading";
    if (this.currentAttempt?.downloadSource === "http") http[method]();
    all[method]();
  }
}

class FailedRequestAttempts {
  private attempts: Required<RequestAttempt>[] = [];

  add(attempt: Required<RequestAttempt>) {
    this.attempts.push(attempt);
  }

  get httpAttemptsCount() {
    return this.attempts.reduce(
      (sum, attempt) => (attempt.downloadSource === "http" ? sum + 1 : sum),
      0,
    );
  }

  get p2pAttemptsCount() {
    return this.attempts.reduce(
      (sum, attempt) => (attempt.downloadSource === "p2p" ? sum + 1 : sum),
      0,
    );
  }

  get lastAttempt(): Readonly<Required<RequestAttempt>> | undefined {
    return this.attempts[this.attempts.length - 1];
  }

  clear() {
    this.attempts = [];
  }
}

export class Timeout {
  private timeoutId?: number;
  private ms?: number;

  constructor(private readonly action: () => void) {}

  start(ms: number) {
    if (this.timeoutId) {
      throw new Error("Timeout is already started.");
    }
    this.ms = ms;
    this.timeoutId = window.setTimeout(this.action, this.ms);
  }

  restart(ms?: number) {
    this.clear();
    if (ms !== undefined) this.ms = ms;
    if (this.ms === undefined) return;
    this.timeoutId = window.setTimeout(this.action, this.ms);
  }

  clear() {
    clearTimeout(this.timeoutId);
    this.timeoutId = undefined;
  }
}
