/*
 * Copyright (C) 2023 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import {ArrayUtils} from 'common/array_utils';
import {Timestamp, TimestampType} from 'common/time';
import {
  CustomQueryParamTypeMap,
  CustomQueryParserResultTypeMap,
  CustomQueryResultTypeMap,
  CustomQueryType,
  ProcessParserResult,
} from './custom_query';
import {FrameMap} from './frame_map';
import {
  AbsoluteEntryIndex,
  AbsoluteFrameIndex,
  EntriesRange,
  FramesRange,
  RelativeEntryIndex,
} from './index_types';
import {Parser} from './parser';
import {TraceType} from './trace_type';

export {
  AbsoluteEntryIndex,
  AbsoluteFrameIndex,
  EntriesRange,
  FramesRange,
  RelativeEntryIndex,
} from './index_types';

export abstract class TraceEntry<T> {
  constructor(
    protected readonly fullTrace: Trace<T>,
    protected readonly parser: Parser<T>,
    protected readonly index: AbsoluteEntryIndex,
    protected readonly timestamp: Timestamp,
    protected readonly framesRange: FramesRange | undefined,
  ) {}

  getFullTrace(): Trace<T> {
    return this.fullTrace;
  }

  getIndex(): AbsoluteEntryIndex {
    return this.index;
  }

  getTimestamp(): Timestamp {
    return this.timestamp;
  }

  getFramesRange(): FramesRange | undefined {
    if (!this.fullTrace.hasFrameInfo()) {
      throw new Error(
        `Trace ${this.fullTrace.type} can't be accessed in frame domain (no frame info available)`,
      );
    }
    return this.framesRange;
  }

  abstract getValue(): any;
}

export class TraceEntryLazy<T> extends TraceEntry<T> {
  constructor(
    fullTrace: Trace<T>,
    parser: Parser<T>,
    index: AbsoluteEntryIndex,
    timestamp: Timestamp,
    framesRange: FramesRange | undefined,
  ) {
    super(fullTrace, parser, index, timestamp, framesRange);
  }

  override async getValue(): Promise<T> {
    return await this.parser.getEntry(this.index, this.timestamp.getType());
  }
}

export class TraceEntryEager<T, U> extends TraceEntry<T> {
  private readonly value: U;

  constructor(
    fullTrace: Trace<T>,
    parser: Parser<T>,
    index: AbsoluteEntryIndex,
    timestamp: Timestamp,
    framesRange: FramesRange | undefined,
    value: U,
  ) {
    super(fullTrace, parser, index, timestamp, framesRange);
    this.value = value;
  }

  override getValue(): U {
    return this.value;
  }
}

export class Trace<T> {
  readonly type: TraceType;
  readonly lengthEntries: number;

  private readonly parser: Parser<T>;
  private readonly descriptors: string[];
  private readonly fullTrace: Trace<T>;
  private timestampType: TimestampType;
  private readonly entriesRange: EntriesRange;
  private frameMap?: FrameMap;
  private framesRange?: FramesRange;

  static fromParser<T>(
    parser: Parser<T>,
    timestampType: TimestampType,
  ): Trace<T> {
    return new Trace(
      parser.getTraceType(),
      parser,
      parser.getDescriptors(),
      undefined,
      timestampType,
      undefined,
    );
  }

  constructor(
    type: TraceType,
    parser: Parser<T>,
    descriptors: string[],
    fullTrace: Trace<T> | undefined,
    timestampType: TimestampType,
    entriesRange: EntriesRange | undefined,
  ) {
    this.type = type;
    this.parser = parser;
    this.descriptors = descriptors;
    this.fullTrace = fullTrace ?? this;
    this.entriesRange = entriesRange ?? {
      start: 0,
      end: parser.getLengthEntries(),
    };
    this.lengthEntries = this.entriesRange.end - this.entriesRange.start;
    this.timestampType = timestampType;
  }

  getDescriptors(): string[] {
    return this.parser.getDescriptors();
  }

  getTimestampType(): TimestampType {
    if (this.timestampType === undefined) {
      throw new Error('Trace no fully initialized yet!');
    }
    return this.timestampType;
  }

  setFrameInfo(frameMap: FrameMap, framesRange: FramesRange | undefined) {
    if (frameMap.lengthEntries !== this.fullTrace.lengthEntries) {
      throw new Error(
        'Attemped to set a frame map with incompatible number of entries',
      );
    }
    this.frameMap = frameMap;
    this.framesRange = framesRange;
  }

  hasFrameInfo(): boolean {
    return this.frameMap !== undefined;
  }

  getEntry(index: RelativeEntryIndex): TraceEntryLazy<T> {
    return this.getEntryInternal(index, (index, timestamp, frames) => {
      return new TraceEntryLazy<T>(
        this.fullTrace,
        this.parser,
        index,
        timestamp,
        frames,
      );
    });
  }

  async customQuery<Q extends CustomQueryType>(
    type: Q,
    param?: CustomQueryParamTypeMap[Q],
  ): Promise<CustomQueryResultTypeMap<T>[Q]> {
    const makeTraceEntry = <U>(
      index: RelativeEntryIndex,
      value: U,
    ): TraceEntryEager<T, U> => {
      return this.getEntryInternal(index, (index, timestamp, frames) => {
        return new TraceEntryEager<T, U>(
          this.fullTrace,
          this.parser,
          index,
          timestamp,
          frames,
          value,
        );
      });
    };

    const processParserResult = ProcessParserResult[type] as (
      parserResult: CustomQueryParserResultTypeMap[Q],
      make: typeof makeTraceEntry,
    ) => CustomQueryResultTypeMap<T>[Q];

    const parserResult = (await this.parser.customQuery(
      type,
      this.entriesRange,
      param,
    )) as CustomQueryParserResultTypeMap[Q];
    const finalResult = processParserResult(parserResult, makeTraceEntry);
    return Promise.resolve(finalResult);
  }

  getFrame(frame: AbsoluteFrameIndex): Trace<T> {
    this.checkTraceCanBeAccessedInFrameDomain();
    const entries = this.frameMap!.getEntriesRange({
      start: frame,
      end: frame + 1,
    });
    return this.createSlice(entries, {start: frame, end: frame + 1});
  }

  findClosestEntry(time: Timestamp): TraceEntryLazy<T> | undefined {
    this.checkTimestampIsCompatible(time);
    if (this.lengthEntries === 0) {
      return undefined;
    }

    const entry = this.clampEntryToSliceBounds(
      ArrayUtils.binarySearchFirstGreaterOrEqual(
        this.getFullTraceTimestamps(),
        time,
      ),
    );
    if (entry === undefined || entry === this.entriesRange.end) {
      return this.getEntry(this.lengthEntries - 1);
    }

    if (entry === this.entriesRange.start) {
      return this.getEntry(0);
    }

    const abs = (time: bigint) => (time < 0 ? -time : time);
    const timeDiff = abs(
      this.getFullTraceTimestamps()[entry].getValueNs() - time.getValueNs(),
    );
    const prevEntry = entry - 1;
    const prevTimeDiff = abs(
      this.getFullTraceTimestamps()[prevEntry].getValueNs() - time.getValueNs(),
    );
    if (prevTimeDiff < timeDiff) {
      return this.getEntry(prevEntry - this.entriesRange.start);
    }
    return this.getEntry(entry - this.entriesRange.start);
  }

  findFirstGreaterOrEqualEntry(time: Timestamp): TraceEntryLazy<T> | undefined {
    this.checkTimestampIsCompatible(time);
    if (this.lengthEntries === 0) {
      return undefined;
    }

    const pos = this.clampEntryToSliceBounds(
      ArrayUtils.binarySearchFirstGreaterOrEqual(
        this.getFullTraceTimestamps(),
        time,
      ),
    );
    if (pos === undefined || pos === this.entriesRange.end) {
      return undefined;
    }

    const entry = this.getEntry(pos - this.entriesRange.start);
    if (entry.getTimestamp() < time) {
      return undefined;
    }

    return entry;
  }

  findFirstGreaterEntry(time: Timestamp): TraceEntryLazy<T> | undefined {
    this.checkTimestampIsCompatible(time);
    if (this.lengthEntries === 0) {
      return undefined;
    }

    const pos = this.clampEntryToSliceBounds(
      ArrayUtils.binarySearchFirstGreater(this.getFullTraceTimestamps(), time),
    );
    if (pos === undefined || pos === this.entriesRange.end) {
      return undefined;
    }

    const entry = this.getEntry(pos - this.entriesRange.start);
    if (entry.getTimestamp() <= time) {
      return undefined;
    }

    return entry;
  }

  findLastLowerOrEqualEntry(
    timestamp: Timestamp,
  ): TraceEntryLazy<T> | undefined {
    if (this.lengthEntries === 0) {
      return undefined;
    }
    const firstGreater = this.findFirstGreaterEntry(timestamp);
    if (!firstGreater) {
      return this.getEntry(this.lengthEntries - 1);
    }
    if (firstGreater.getIndex() === this.entriesRange.start) {
      return undefined;
    }
    return this.getEntry(firstGreater.getIndex() - this.entriesRange.start - 1);
  }

  findLastLowerEntry(timestamp: Timestamp): TraceEntryLazy<T> | undefined {
    if (this.lengthEntries === 0) {
      return undefined;
    }
    const firstGreaterOrEqual = this.findFirstGreaterOrEqualEntry(timestamp);
    if (!firstGreaterOrEqual) {
      return this.getEntry(this.lengthEntries - 1);
    }
    if (firstGreaterOrEqual.getIndex() === this.entriesRange.start) {
      return undefined;
    }
    return this.getEntry(
      firstGreaterOrEqual.getIndex() - this.entriesRange.start - 1,
    );
  }

  sliceEntries(start?: RelativeEntryIndex, end?: RelativeEntryIndex): Trace<T> {
    const startEntry =
      this.clampEntryToSliceBounds(this.convertToAbsoluteEntryIndex(start)) ??
      this.entriesRange.start;
    const endEntry =
      this.clampEntryToSliceBounds(this.convertToAbsoluteEntryIndex(end)) ??
      this.entriesRange.end;
    const entries: EntriesRange = {
      start: startEntry,
      end: endEntry,
    };
    const frames = this.frameMap?.getFramesRange(entries);
    return this.createSlice(entries, frames);
  }

  sliceTime(start?: Timestamp, end?: Timestamp): Trace<T> {
    this.checkTimestampIsCompatible(start);
    this.checkTimestampIsCompatible(end);
    const startEntry =
      start === undefined
        ? this.entriesRange.start
        : this.clampEntryToSliceBounds(
            ArrayUtils.binarySearchFirstGreaterOrEqual(
              this.getFullTraceTimestamps(),
              start,
            ),
          ) ?? this.entriesRange.end;
    const endEntry =
      end === undefined
        ? this.entriesRange.end
        : this.clampEntryToSliceBounds(
            ArrayUtils.binarySearchFirstGreaterOrEqual(
              this.getFullTraceTimestamps(),
              end,
            ),
          ) ?? this.entriesRange.end;
    const entries: EntriesRange = {
      start: startEntry,
      end: endEntry,
    };
    const frames = this.frameMap?.getFramesRange(entries);
    return this.createSlice(entries, frames);
  }

  sliceFrames(start?: AbsoluteFrameIndex, end?: AbsoluteFrameIndex): Trace<T> {
    this.checkTraceCanBeAccessedInFrameDomain();
    if (!this.framesRange) {
      return this.createSlice(undefined, undefined);
    }
    const frames: FramesRange = {
      start: this.clampFrameToSliceBounds(start) ?? this.framesRange.start,
      end: this.clampFrameToSliceBounds(end) ?? this.framesRange.end,
    };
    const entries = this.frameMap!.getEntriesRange(frames);
    return this.createSlice(entries, frames);
  }

  forEachEntry(
    callback: (pos: TraceEntryLazy<T>, index: RelativeEntryIndex) => void,
  ) {
    for (let index = 0; index < this.lengthEntries; ++index) {
      callback(this.getEntry(index), index);
    }
  }

  mapEntry<U>(
    callback: (entry: TraceEntryLazy<T>, index: RelativeEntryIndex) => U,
  ): U[] {
    const result: U[] = [];
    this.forEachEntry((entry, index) => {
      result.push(callback(entry, index));
    });
    return result;
  }

  forEachTimestamp(
    callback: (timestamp: Timestamp, index: RelativeEntryIndex) => void,
  ) {
    const timestamps = this.getFullTraceTimestamps();
    for (let index = 0; index < this.lengthEntries; ++index) {
      callback(timestamps[this.entriesRange.start + index], index);
    }
  }

  forEachFrame(callback: (frame: Trace<T>, index: AbsoluteFrameIndex) => void) {
    this.checkTraceCanBeAccessedInFrameDomain();
    if (!this.framesRange) {
      return;
    }
    for (
      let frame = this.framesRange.start;
      frame < this.framesRange.end;
      ++frame
    ) {
      callback(this.getFrame(frame), frame);
    }
  }

  mapFrame<U>(
    callback: (frame: Trace<T>, index: AbsoluteFrameIndex) => U,
  ): U[] {
    const result: U[] = [];
    this.forEachFrame((traces, index) => {
      result.push(callback(traces, index));
    });
    return result;
  }

  getFramesRange(): FramesRange | undefined {
    this.checkTraceCanBeAccessedInFrameDomain();
    return this.framesRange;
  }

  private getEntryInternal<
    EntryType extends TraceEntryLazy<T> | TraceEntryEager<T, any>,
  >(
    index: RelativeEntryIndex,
    makeEntry: (
      absoluteIndex: AbsoluteEntryIndex,
      timestamp: Timestamp,
      frames: FramesRange | undefined,
    ) => EntryType,
  ): EntryType {
    const absoluteIndex = this.convertToAbsoluteEntryIndex(
      index,
    ) as AbsoluteEntryIndex;
    if (
      absoluteIndex < this.entriesRange.start ||
      absoluteIndex >= this.entriesRange.end
    ) {
      throw new Error(
        `Trace entry's index out of bounds. Input relative index: ${index}. Slice length: ${this.lengthEntries}.`,
      );
    }
    const timestamp = this.getFullTraceTimestamps()[absoluteIndex];
    const frames = this.clampFramesRangeToSliceBounds(
      this.frameMap?.getFramesRange({
        start: absoluteIndex,
        end: absoluteIndex + 1,
      }),
    );
    return makeEntry(absoluteIndex, timestamp, frames);
  }

  private getFullTraceTimestamps(): Timestamp[] {
    if (this.timestampType === undefined) {
      throw new Error('Forgot to initialize trace?');
    }

    const timestamps = this.parser.getTimestamps(this.timestampType);
    if (!timestamps) {
      throw new Error(
        `Timestamp type ${this.timestampType} is expected to be available`,
      );
    }
    return timestamps;
  }

  private convertToAbsoluteEntryIndex(
    index: RelativeEntryIndex | undefined,
  ): AbsoluteEntryIndex | undefined {
    if (index === undefined) {
      return undefined;
    }
    if (index < 0) {
      return this.entriesRange.end + index;
    }
    return this.entriesRange.start + index;
  }

  private createSlice(
    entries: EntriesRange | undefined,
    frames: FramesRange | undefined,
  ): Trace<T> {
    entries = this.clampEntriesRangeToSliceBounds(entries);
    frames = this.clampFramesRangeToSliceBounds(frames);

    if (entries === undefined || entries.start >= entries.end) {
      entries = {
        start: this.entriesRange.end,
        end: this.entriesRange.end,
      };
    }

    const slice = new Trace<T>(
      this.type,
      this.parser,
      this.descriptors,
      this.fullTrace,
      this.timestampType,
      entries,
    );

    if (this.frameMap) {
      slice.setFrameInfo(this.frameMap, frames);
    }

    return slice;
  }

  private clampEntriesRangeToSliceBounds(
    entries: EntriesRange | undefined,
  ): EntriesRange | undefined {
    if (entries === undefined) {
      return undefined;
    }
    return {
      start: this.clampEntryToSliceBounds(entries.start) as AbsoluteEntryIndex,
      end: this.clampEntryToSliceBounds(entries.end) as AbsoluteEntryIndex,
    };
  }

  private clampFramesRangeToSliceBounds(
    frames: FramesRange | undefined,
  ): FramesRange | undefined {
    if (frames === undefined) {
      return undefined;
    }
    return {
      start: this.clampFrameToSliceBounds(frames.start) as AbsoluteFrameIndex,
      end: this.clampFrameToSliceBounds(frames.end) as AbsoluteFrameIndex,
    };
  }

  private clampEntryToSliceBounds(
    entry: AbsoluteEntryIndex | undefined,
  ): AbsoluteEntryIndex | undefined {
    if (entry === undefined) {
      return undefined;
    }
    return Math.min(
      Math.max(entry, this.entriesRange.start),
      this.entriesRange.end,
    );
  }

  private clampFrameToSliceBounds(
    frame: AbsoluteFrameIndex | undefined,
  ): AbsoluteFrameIndex | undefined {
    if (!this.framesRange || frame === undefined) {
      return undefined;
    }

    if (frame < 0) {
      throw new Error(
        `Absolute frame index cannot be negative. Found '${frame}'`,
      );
    }

    return Math.min(
      Math.max(frame, this.framesRange.start),
      this.framesRange.end,
    );
  }

  private checkTraceCanBeAccessedInFrameDomain() {
    if (!this.frameMap) {
      throw new Error(
        `Trace ${this.type} can't be accessed in frame domain (no frame mapping available)`,
      );
    }
  }

  private checkTimestampIsCompatible(timestamp?: Timestamp) {
    if (!timestamp) {
      return;
    }
    const timestamps = this.parser.getTimestamps(timestamp.getType());
    if (timestamps === undefined) {
      throw new Error(
        `Trace ${
          this.type
        } can't be accessed using timestamp of type ${timestamp.getType()}`,
      );
    }
  }
}
