// 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 m from 'mithril';
import {Duration, duration, Time, time} from '../../base/time';
import {hasArgs} from '../../components/details/args';
import {renderDetails} from '../../components/details/slice_details';
import {
  getDescendantSliceTree,
  getSlice,
  SliceDetails,
  SliceTreeNode,
} from '../../components/sql_utils/slice';
import {asSliceSqlId, SliceSqlId} from '../../components/sql_utils/core_types';
import {Grid, GridColumn, GridHeaderCell, GridCell} from '../../widgets/grid';
import {TreeTable, TreeTableAttrs} from '../../components/widgets/treetable';
import {LONG, NUM, NUM_NULL, STR} from '../../trace_processor/query_result';
import {DetailsShell} from '../../widgets/details_shell';
import {GridLayout, GridLayoutColumn} from '../../widgets/grid_layout';
import {Section} from '../../widgets/section';
import {MultiParagraphText, TextParagraph} from '../../widgets/text_paragraph';
import {Tree, TreeNode} from '../../widgets/tree';
import {
  EventLatencyCauseThreadTracks,
  EventLatencyStage,
  getCauseLink,
  getEventLatencyCauseTracks,
  getScrollJankCauseStage,
} from './scroll_jank_cause_link_utils';
import {ScrollJankCauseMap} from './scroll_jank_cause_map';
import {
  infoTooltip,
  JANKS_TRACK_URI,
  renderSliceRef,
  renderSqlRef,
  stdlibRef,
  trackEventRefTreeNode,
} from './utils';
import {TrackEventDetailsPanel} from '../../public/details_panel';
import {Trace} from '../../public/trace';
import {renderSliceArguments} from '../../components/details/slice_args';
import {TrackEventRef} from '../../components/widgets/track_event_ref';
import {SLICE_TABLE} from '../../components/widgets/sql/table_definitions';
import {assertExists, assertTrue} from '../../base/assert';
import {
  EVENT_LATENCY_TRACK,
  SCROLL_TIMELINE_TRACK,
  SCROLL_TIMELINE_V4_TRACK,
} from './tracks';
import {EVENT_LATENCY_TABLE_DEFINITION} from './event_latency_model';

// Given a node in the slice tree, return a path from root to it.
function getPath(slice: SliceTreeNode): string[] {
  const result: string[] = [];
  let node: SliceTreeNode | undefined = slice;
  while (node.parent !== undefined) {
    result.push(node.name ?? '[null]');
    node = node.parent;
  }
  return result.reverse();
}

// Given a slice tree node and a path, find the node following the path from
// the given slice, or `undefined` if not found.
function findSliceInTreeByPath(
  slice: SliceTreeNode | undefined,
  path: string[],
): SliceTreeNode | undefined {
  if (slice === undefined) {
    return undefined;
  }
  let result = slice;
  for (const segment of path) {
    let found = false;
    for (const child of result.children) {
      if (child.name === segment) {
        found = true;
        result = child;
        break;
      }
    }
    if (!found) {
      return undefined;
    }
  }
  return result;
}

function durationDelta(value: duration, base?: duration): string {
  if (base === undefined) {
    return 'NULL';
  }
  const delta = value - base;
  return `${delta > 0 ? '+' : ''}${Duration.humanise(delta)}`;
}

export class EventLatencySliceDetailsPanel implements TrackEventDetailsPanel {
  private name = '';
  private topEventLatencyId: SliceSqlId | undefined = undefined;

  private sliceDetails?: SliceDetails;
  private jankySlice?: {
    ts: time;
    dur: duration;
    id: number;
    causeOfJank: string;
  };

  private references?: {
    // Values from `EVENT_LATENCY_TRACK.tableName[id=this.id]`:
    scrollUpdateId: bigint;
    parent: undefined | {kind: 'EventLatency' | 'stage'; id: number};

    // References to the corresponding slices in `SCROLL_TIMELINE_TRACK` and
    // `SCROLL_TIMELINE_V4_TRACK`.
    scrollUpdatePluginSliceId: number | undefined;
    presentedInFramePluginSliceId: number | undefined;
  };

  // Whether this stage has caused jank. This is also true for top level
  // EventLatency slices where a descendant is a cause of jank.
  private isJankStage = false;

  // For top level EventLatency slices - if any descendant is a cause of jank,
  // this field stores information about that descendant slice. Otherwise, this
  // is stores information about the current stage;
  private relevantThreadStage: EventLatencyStage | undefined;
  private relevantThreadTracks: EventLatencyCauseThreadTracks[] = [];
  // Stages tree for the current EventLatency.
  private eventLatencyBreakdown?: SliceTreeNode;
  // Stages tree for the next EventLatency.
  private nextEventLatencyBreakdown?: SliceTreeNode;
  // Stages tree for the prev EventLatency.
  private prevEventLatencyBreakdown?: SliceTreeNode;

  private tracksByTrackId: Map<number, string>;

  constructor(
    private readonly trace: Trace,
    private readonly id: number,
  ) {
    this.tracksByTrackId = new Map<number, string>();
    this.trace.tracks.getAllTracks().forEach((td) => {
      td.tags?.trackIds?.forEach((trackId) => {
        this.tracksByTrackId.set(trackId, td.uri);
      });
    });
  }

  async load() {
    const queryResult = await this.trace.engine.query(`
      SELECT
        name
      FROM slice
      WHERE id = ${this.id}
      `);

    const iter = queryResult.firstRow({
      name: STR,
    });

    this.name = iter.name;

    await this.loadSlice();
    await this.loadJankSlice();
    await this.loadReferences();
    await this.loadRelevantThreads();
    await this.loadEventLatencyBreakdown();
  }

  async loadSlice() {
    this.sliceDetails = await getSlice(
      this.trace.engine,
      asSliceSqlId(this.id),
    );
  }

  async loadJankSlice() {
    if (!this.sliceDetails) return;
    // Get the id for the top-level EventLatency slice (this or parent), as
    // this id is used in the ScrollJankV3 track to identify the corresponding
    // janky interval.
    if (this.sliceDetails.name === 'EventLatency') {
      this.topEventLatencyId = this.sliceDetails.id;
    } else {
      this.topEventLatencyId = asSliceSqlId(
        await this.getOldestAncestorSliceId(),
      );
    }

    const it = (
      await this.trace.engine.query(`
      SELECT ts, dur, id, cause_of_jank as causeOfJank
      FROM chrome_janky_frame_presentation_intervals
      WHERE event_latency_id = ${this.topEventLatencyId}`)
    ).iter({
      id: NUM,
      ts: LONG,
      dur: LONG,
      causeOfJank: STR,
    });

    if (it.valid()) {
      this.jankySlice = {
        id: it.id,
        ts: Time.fromRaw(it.ts),
        dur: Duration.fromRaw(it.dur),
        causeOfJank: it.causeOfJank,
      };
    }
  }

  private async loadReferences() {
    assertTrue(this.references === undefined);
    const queryResult = await this.trace.engine.query(`
      SELECT
        model.scroll_update_id,
        model.parent_id,
        parent_model.parent_id AS grandparent_id,
        ${SCROLL_TIMELINE_TRACK.eventIdSqlSubqueryForEventLatency(
          'model.scroll_update_id',
        )} AS scroll_update_plugin_slice_id,
        ${SCROLL_TIMELINE_V4_TRACK.eventIdSqlSubqueryForEventLatency(
          'model.scroll_update_id',
        )} AS presented_in_frame_plugin_slice_id
      FROM ${EVENT_LATENCY_TRACK.tableName} AS model
      LEFT JOIN ${EVENT_LATENCY_TRACK.tableName} AS parent_model
        ON model.parent_id = parent_model.id
      WHERE model.id = ${this.id}`);
    const row = queryResult.firstRow({
      scroll_update_id: LONG,
      parent_id: NUM_NULL,
      grandparent_id: NUM_NULL,
      scroll_update_plugin_slice_id: NUM_NULL,
      presented_in_frame_plugin_slice_id: NUM_NULL,
    });
    this.references = {
      scrollUpdateId: row.scroll_update_id,
      parent:
        row.parent_id === null
          ? undefined
          : {
              kind: row.grandparent_id === null ? 'EventLatency' : 'stage',
              id: row.parent_id,
            },
      scrollUpdatePluginSliceId: row.scroll_update_plugin_slice_id ?? undefined,
      presentedInFramePluginSliceId:
        row.presented_in_frame_plugin_slice_id ?? undefined,
    };
  }

  async loadRelevantThreads() {
    if (!this.sliceDetails) return;
    if (!this.topEventLatencyId) return;

    // Relevant threads should only be available on a "Janky" EventLatency
    // slice to allow the user to jump to the possible cause of jank.
    if (this.sliceDetails.name === 'EventLatency' && !this.jankySlice) return;

    const possibleScrollJankStage = await getScrollJankCauseStage(
      this.trace.engine,
      this.topEventLatencyId,
    );
    if (this.sliceDetails.name === 'EventLatency') {
      this.isJankStage = true;
      this.relevantThreadStage = possibleScrollJankStage;
    } else {
      if (
        possibleScrollJankStage &&
        this.sliceDetails.name === possibleScrollJankStage.name
      ) {
        this.isJankStage = true;
      }
      this.relevantThreadStage = {
        name: this.sliceDetails.name,
        eventLatencyId: this.topEventLatencyId,
        ts: this.sliceDetails.ts,
        dur: this.sliceDetails.dur,
      };
    }

    if (this.relevantThreadStage) {
      this.relevantThreadTracks = await getEventLatencyCauseTracks(
        this.trace.engine,
        this.relevantThreadStage,
      );
    }
  }

  async loadEventLatencyBreakdown() {
    if (this.topEventLatencyId === undefined) {
      return;
    }
    this.eventLatencyBreakdown = await getDescendantSliceTree(
      this.trace.engine,
      this.topEventLatencyId,
    );

    // TODO(altimin): this should only consider EventLatencies within the same scroll.
    const prevEventLatency = (
      await this.trace.engine.query(`
      INCLUDE PERFETTO MODULE chrome.event_latency;
      SELECT
        id
      FROM chrome_event_latencies
      WHERE event_type IN (
        'FIRST_GESTURE_SCROLL_UPDATE',
        'GESTURE_SCROLL_UPDATE',
        'INERTIAL_GESTURE_SCROLL_UPDATE')
      AND is_presented
      AND id < ${this.topEventLatencyId}
      ORDER BY id DESC
      LIMIT 1
      ;
    `)
    ).maybeFirstRow({id: NUM});
    if (prevEventLatency !== undefined) {
      this.prevEventLatencyBreakdown = await getDescendantSliceTree(
        this.trace.engine,
        asSliceSqlId(prevEventLatency.id),
      );
    }

    const nextEventLatency = (
      await this.trace.engine.query(`
      INCLUDE PERFETTO MODULE chrome.event_latency;
      SELECT
        id
      FROM chrome_event_latencies
      WHERE event_type IN (
        'FIRST_GESTURE_SCROLL_UPDATE',
        'GESTURE_SCROLL_UPDATE',
        'INERTIAL_GESTURE_SCROLL_UPDATE')
      AND is_presented
      AND id > ${this.topEventLatencyId}
      ORDER BY id DESC
      LIMIT 1;
    `)
    ).maybeFirstRow({id: NUM});
    if (nextEventLatency !== undefined) {
      this.nextEventLatencyBreakdown = await getDescendantSliceTree(
        this.trace.engine,
        asSliceSqlId(nextEventLatency.id),
      );
    }
  }

  private getRelevantLinks(): m.Child {
    if (!this.sliceDetails) return undefined;

    // Relevant threads should only be available on a "Janky" EventLatency
    // slice to allow the user to jump to the possible cause of jank.
    if (
      this.sliceDetails.name === 'EventLatency' &&
      !this.relevantThreadStage
    ) {
      return undefined;
    }

    const name = this.relevantThreadStage
      ? this.relevantThreadStage.name
      : this.sliceDetails.name;
    const ts = this.relevantThreadStage
      ? this.relevantThreadStage.ts
      : this.sliceDetails.ts;
    const dur = this.relevantThreadStage
      ? this.relevantThreadStage.dur
      : this.sliceDetails.dur;
    const stageDetails = ScrollJankCauseMap.getEventLatencyDetails(name);
    if (stageDetails === undefined) return undefined;

    const childWidgets: m.Child[] = [];
    childWidgets.push(m(TextParagraph, {text: stageDetails.description}));

    if (this.relevantThreadTracks.length > 0) {
      const columns: GridColumn[] = [
        {key: 'relevantThread', header: m(GridHeaderCell, 'Relevant Thread')},
        {key: 'description', header: m(GridHeaderCell, 'Description')},
      ];

      const rows = this.relevantThreadTracks.map((track, i) => {
        let description = '';
        if (i == 0 || track.thread != this.relevantThreadTracks[i - 1].thread) {
          description = track.causeDescription;
        }
        return [
          m(
            GridCell,
            getCauseLink(this.trace, track, this.tracksByTrackId, ts, dur),
          ),
          m(
            GridCell,
            description === ''
              ? description
              : m(TextParagraph, {text: description}),
          ),
        ];
      });

      childWidgets.push(
        m(Grid, {
          columns,
          rowData: rows,
        }),
      );
    }

    return m(
      Section,
      {title: this.isJankStage ? `Jank Cause: ${name}` : name ?? '[null]'},
      childWidgets,
    );
  }

  private async getOldestAncestorSliceId(): Promise<number> {
    let eventLatencyId = -1;
    if (!this.sliceDetails) return eventLatencyId;
    const queryResult = await this.trace.engine.query(`
      SELECT
        id
      FROM ancestor_slice(${this.sliceDetails.id})
      WHERE name = 'EventLatency'
    `);

    const it = queryResult.iter({
      id: NUM,
    });

    for (; it.valid(); it.next()) {
      eventLatencyId = it.id;
      break;
    }

    return eventLatencyId;
  }

  private getReferences(): m.Child {
    if (this.sliceDetails === undefined || this.references === undefined) {
      return undefined;
    }
    const isEventLatency = this.references.parent === undefined;
    const children: m.Children = [];
    if (this.jankySlice !== undefined) {
      children.push(
        m(TreeNode, {
          left: 'Jank reason',
          right: renderSliceRef({
            trace: this.trace,
            id: this.jankySlice.id,
            trackUri: JANKS_TRACK_URI,
            title: this.jankySlice.causeOfJank,
          }),
        }),
      );
    }
    children.push(
      isEventLatency
        ? this.getEventLatencyReferences()
        : this.getStageReferences(),
      m(
        TreeNode,
        {
          left: 'Self',
          startsCollapsed: true,
        },
        [
          m(TreeNode, {
            left: [
              `This ${isEventLatency ? 'EventLatency' : 'stage'} `,
              infoTooltip(
                `Slice on the "${EVENT_LATENCY_TRACK.name}" track created by ` +
                  'the plugin. It represents ' +
                  `${isEventLatency ? 'a' : 'a stage of a'} scroll update.`,
              ),
            ],
            right: renderSqlRef({
              trace: this.trace,
              tableName: EVENT_LATENCY_TRACK.tableName,
              tableDefinition: EVENT_LATENCY_TABLE_DEFINITION,
              id: this.id,
            }),
          }),
          m(TreeNode, {
            left: [
              m(TrackEventRef, {
                trace: this.trace,
                table: 'slice',
                id: this.sliceDetails.id,
                name: 'Original slice in context of other input events',
              }),
              infoTooltip(
                'The original slice which Chrome emitted in the trace, from ' +
                  'which the plugin derived this slice.',
              ),
            ],
            right: renderSqlRef({
              trace: this.trace,
              tableName: 'slice',
              tableDefinition: SLICE_TABLE,
              id: this.sliceDetails.id,
            }),
          }),
        ],
      ),
    );
    return m(Section, {title: 'References'}, m(Tree, children));
  }

  private getEventLatencyReferences(): m.Children {
    assertTrue(this.references!.parent === undefined);
    return [this.renderRelatedTrackReferences(), this.renderStdlibReferences()];
  }

  private renderRelatedTrackReferences(): m.Child {
    const references = assertExists(this.references);
    const children: m.Children = [];
    if (references.scrollUpdatePluginSliceId !== undefined) {
      children.push(
        trackEventRefTreeNode({
          trace: this.trace,
          table: SCROLL_TIMELINE_TRACK.tableName,
          id: references.scrollUpdatePluginSliceId,
          name: 'Corresponding scroll update',
        }),
      );
    }
    if (references.presentedInFramePluginSliceId !== undefined) {
      children.push(
        trackEventRefTreeNode({
          trace: this.trace,
          table: SCROLL_TIMELINE_V4_TRACK.tableName,
          id: references.presentedInFramePluginSliceId,
          name: 'Frame where this was the first presented EventLatency',
        }),
      );
    }
    if (children.length === 0) {
      return undefined;
    }
    return m(
      TreeNode,
      {left: 'Related tracks', startsCollapsed: false},
      children,
    );
  }

  private renderStdlibReferences(): m.Child {
    const references = assertExists(this.references);
    return m(
      TreeNode,
      {
        left: 'Standard library tables',
        startsCollapsed: false,
      },
      [
        m(TreeNode, {
          right: stdlibRef({
            trace: this.trace,
            table: 'chrome_event_latencies',
            idColumnName: 'scroll_update_id',
            id: references.scrollUpdateId,
          }),
        }),
        m(TreeNode, {
          right: stdlibRef({
            trace: this.trace,
            table: 'chrome_gesture_scroll_updates',
            idColumnName: 'scroll_update_id',
            id: references.scrollUpdateId,
          }),
        }),
      ],
    );
  }

  private getStageReferences(): m.Child {
    const parent = assertExists(this.references!.parent);
    return trackEventRefTreeNode({
      trace: this.trace,
      table: EVENT_LATENCY_TRACK.tableName,
      id: parent.id,
      name: `Parent ${parent.kind}`,
    });
  }

  private getBreakdownSection(): m.Child {
    if (this.eventLatencyBreakdown === undefined) {
      return undefined;
    }

    const attrs: TreeTableAttrs<SliceTreeNode> = {
      rows: [this.eventLatencyBreakdown],
      getChildren: (slice) => slice.children,
      columns: [
        {name: 'Name', getData: (slice) => slice.name ?? '[null]'},
        {name: 'Duration', getData: (slice) => Duration.humanise(slice.dur)},
        {
          name: 'vs prev',
          getData: (slice) =>
            durationDelta(
              slice.dur,
              findSliceInTreeByPath(
                this.prevEventLatencyBreakdown,
                getPath(slice),
              )?.dur,
            ),
        },
        {
          name: 'vs next',
          getData: (slice) =>
            durationDelta(
              slice.dur,
              findSliceInTreeByPath(
                this.nextEventLatencyBreakdown,
                getPath(slice),
              )?.dur,
            ),
        },
      ],
    };

    return m(
      Section,
      {
        title: 'EventLatency Stage Breakdown',
      },
      m(TreeTable<SliceTreeNode>, attrs),
    );
  }

  private getDescriptionText(): m.Child {
    return m(
      MultiParagraphText,
      m(TextParagraph, {
        text: `EventLatency tracks the latency of handling a given input event
                 (Scrolls, Touches, Taps, etc). Ideally from when the input was
                 read by the hardware to when it was reflected on the screen.`,
      }),
      m(TextParagraph, {
        text: `Note however the concept of coalescing or terminating early. This
               occurs when we receive multiple events or handle them quickly by
               converting them into a different event. Such as a TOUCH_MOVE
               being converted into a GESTURE_SCROLL_UPDATE type, or a multiple
               GESTURE_SCROLL_UPDATE events being formed into a single frame at
               the end of the RendererCompositorQueuingDelay.`,
      }),
      m(TextParagraph, {
        text: `*Important:* On some platforms (MacOS) we do not get feedback on
               when something is presented on the screen so the timings are only
               accurate for what we know on a given platform.`,
      }),
    );
  }

  render() {
    if (this.sliceDetails) {
      const slice = this.sliceDetails;

      const rightSideWidgets: m.Child[] = [];
      rightSideWidgets.push(this.getReferences());
      rightSideWidgets.push(
        m(
          Section,
          {title: 'Description'},
          m('.div', this.getDescriptionText()),
        ),
      );

      const stageWidget = this.getRelevantLinks();
      // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
      if (stageWidget) {
        rightSideWidgets.push(stageWidget);
      }
      rightSideWidgets.push(this.getBreakdownSection());

      return m(
        DetailsShell,
        {
          title: 'Slice',
          description: this.name,
        },
        m(
          GridLayout,
          m(
            GridLayoutColumn,
            renderDetails(this.trace, slice),
            hasArgs(slice.args) &&
              m(
                Section,
                {title: 'Arguments'},
                m(Tree, renderSliceArguments(this.trace, slice.args)),
              ),
          ),
          m(GridLayoutColumn, rightSideWidgets),
        ),
      );
    } else {
      return m(DetailsShell, {title: 'Slice', description: 'Loading...'});
    }
  }
}
