// Copyright (C) 2018 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 {hex} from 'color-convert';
import m from 'mithril';
import {removeFalsyValues} from '../base/array_utils';
import {canvasClip, canvasSave} from '../base/canvas_utils';
import {findRef, toHTMLElement} from '../base/dom_utils';
import {Size2D, VerticalBounds} from '../base/geom';
import {assertExists} from '../base/logging';
import {clamp} from '../base/math_utils';
import {Time, TimeSpan} from '../base/time';
import {TimeScale} from '../base/time_scale';
import {featureFlags} from '../core/feature_flags';
import {raf} from '../core/raf_scheduler';
import {TrackNode} from '../public/workspace';
import {TRACK_BORDER_COLOR, TRACK_SHELL_WIDTH} from './css_constants';
import {renderFlows} from './flow_events_renderer';
import {generateTicks, getMaxMajorTicks, TickType} from './gridline_helper';
import {NotesPanel} from './notes_panel';
import {OverviewTimelinePanel} from './overview_timeline_panel';
import {PanAndZoomHandler} from './pan_and_zoom_handler';
import {
  PanelContainer,
  PanelOrGroup,
  RenderedPanelInfo,
} from './panel_container';
import {TabPanel} from './tab_panel';
import {TickmarkPanel} from './tickmark_panel';
import {TimeAxisPanel} from './time_axis_panel';
import {TimeSelectionPanel} from './time_selection_panel';
import {TrackPanel} from './track_panel';
import {drawVerticalLineAtTime} from './vertical_line_helper';
import {TraceImpl} from '../core/trace_impl';
import {PageWithTraceImplAttrs} from '../core/page_manager';
import {AppImpl} from '../core/app_impl';

const OVERVIEW_PANEL_FLAG = featureFlags.register({
  id: 'overviewVisible',
  name: 'Overview Panel',
  description: 'Show the panel providing an overview of the trace',
  defaultValue: true,
});

// Checks if the mousePos is within 3px of the start or end of the
// current selected time range.
function onTimeRangeBoundary(
  trace: TraceImpl,
  timescale: TimeScale,
  mousePos: number,
): 'START' | 'END' | null {
  const selection = trace.selection.selection;
  if (selection.kind === 'area') {
    // If frontend selectedArea exists then we are in the process of editing the
    // time range and need to use that value instead.
    const area = trace.timeline.selectedArea
      ? trace.timeline.selectedArea
      : selection;
    const start = timescale.timeToPx(area.start);
    const end = timescale.timeToPx(area.end);
    const startDrag = mousePos - TRACK_SHELL_WIDTH;
    const startDistance = Math.abs(start - startDrag);
    const endDistance = Math.abs(end - startDrag);
    const range = 3 * window.devicePixelRatio;
    // We might be within 3px of both boundaries but we should choose
    // the closest one.
    if (startDistance < range && startDistance <= endDistance) return 'START';
    if (endDistance < range && endDistance <= startDistance) return 'END';
  }
  return null;
}

interface SelectedContainer {
  readonly containerClass: string;
  readonly dragStartAbsY: number;
  readonly dragEndAbsY: number;
}

/**
 * Top-most level component for the viewer page. Holds tracks, brush timeline,
 * panels, and everything else that's part of the main trace viewer page.
 */
export class ViewerPage implements m.ClassComponent<PageWithTraceImplAttrs> {
  private zoomContent?: PanAndZoomHandler;
  // Used to prevent global deselection if a pan/drag select occurred.
  private keepCurrentSelection = false;

  private overviewTimelinePanel: OverviewTimelinePanel;
  private timeAxisPanel: TimeAxisPanel;
  private timeSelectionPanel: TimeSelectionPanel;
  private notesPanel: NotesPanel;
  private tickmarkPanel: TickmarkPanel;
  private timelineWidthPx?: number;
  private selectedContainer?: SelectedContainer;
  private showPanningHint = false;

  private readonly PAN_ZOOM_CONTENT_REF = 'pan-and-zoom-content';

  constructor(vnode: m.CVnode<PageWithTraceImplAttrs>) {
    this.notesPanel = new NotesPanel(vnode.attrs.trace);
    this.timeAxisPanel = new TimeAxisPanel(vnode.attrs.trace);
    this.timeSelectionPanel = new TimeSelectionPanel(vnode.attrs.trace);
    this.tickmarkPanel = new TickmarkPanel(vnode.attrs.trace);
    this.overviewTimelinePanel = new OverviewTimelinePanel(vnode.attrs.trace);
    this.notesPanel = new NotesPanel(vnode.attrs.trace);
    this.timeSelectionPanel = new TimeSelectionPanel(vnode.attrs.trace);
  }

  oncreate({dom, attrs}: m.CVnodeDOM<PageWithTraceImplAttrs>) {
    const panZoomElRaw = findRef(dom, this.PAN_ZOOM_CONTENT_REF);
    const panZoomEl = toHTMLElement(assertExists(panZoomElRaw));

    const {top: panTop} = panZoomEl.getBoundingClientRect();
    this.zoomContent = new PanAndZoomHandler({
      element: panZoomEl,
      onPanned: (pannedPx: number) => {
        const timeline = attrs.trace.timeline;

        if (this.timelineWidthPx === undefined) return;

        this.keepCurrentSelection = true;
        const timescale = new TimeScale(timeline.visibleWindow, {
          left: 0,
          right: this.timelineWidthPx,
        });
        const tDelta = timescale.pxToDuration(pannedPx);
        timeline.panVisibleWindow(tDelta);
      },
      onZoomed: (zoomedPositionPx: number, zoomRatio: number) => {
        const timeline = attrs.trace.timeline;
        // TODO(hjd): Avoid hardcoding TRACK_SHELL_WIDTH.
        // TODO(hjd): Improve support for zooming in overview timeline.
        const zoomPx = zoomedPositionPx - TRACK_SHELL_WIDTH;
        const rect = dom.getBoundingClientRect();
        const centerPoint = zoomPx / (rect.width - TRACK_SHELL_WIDTH);
        timeline.zoomVisibleWindow(1 - zoomRatio, centerPoint);
        raf.scheduleCanvasRedraw();
      },
      editSelection: (currentPx: number) => {
        if (this.timelineWidthPx === undefined) return false;
        const timescale = new TimeScale(attrs.trace.timeline.visibleWindow, {
          left: 0,
          right: this.timelineWidthPx,
        });
        return onTimeRangeBoundary(attrs.trace, timescale, currentPx) !== null;
      },
      onSelection: (
        dragStartX: number,
        dragStartY: number,
        prevX: number,
        currentX: number,
        currentY: number,
        editing: boolean,
      ) => {
        const traceTime = attrs.trace.traceInfo;
        const timeline = attrs.trace.timeline;

        if (this.timelineWidthPx === undefined) return;

        // TODO(stevegolton): Don't get the windowSpan from globals, get it from
        // here!
        const {visibleWindow} = timeline;
        const timespan = visibleWindow.toTimeSpan();
        this.keepCurrentSelection = true;

        const timescale = new TimeScale(timeline.visibleWindow, {
          left: 0,
          right: this.timelineWidthPx,
        });

        if (editing) {
          const selection = attrs.trace.selection.selection;
          if (selection.kind === 'area') {
            const area = attrs.trace.timeline.selectedArea
              ? attrs.trace.timeline.selectedArea
              : selection;
            let newTime = timescale
              .pxToHpTime(currentX - TRACK_SHELL_WIDTH)
              .toTime();
            // Have to check again for when one boundary crosses over the other.
            const curBoundary = onTimeRangeBoundary(
              attrs.trace,
              timescale,
              prevX,
            );
            if (curBoundary == null) return;
            const keepTime = curBoundary === 'START' ? area.end : area.start;
            // Don't drag selection outside of current screen.
            if (newTime < keepTime) {
              newTime = Time.max(newTime, timespan.start);
            } else {
              newTime = Time.min(newTime, timespan.end);
            }
            // When editing the time range we always use the saved tracks,
            // since these will not change.
            timeline.selectArea(
              Time.max(Time.min(keepTime, newTime), traceTime.start),
              Time.min(Time.max(keepTime, newTime), traceTime.end),
              selection.trackUris,
            );
          }
        } else {
          let startPx = Math.min(dragStartX, currentX) - TRACK_SHELL_WIDTH;
          let endPx = Math.max(dragStartX, currentX) - TRACK_SHELL_WIDTH;
          if (startPx < 0 && endPx < 0) return;
          startPx = clamp(startPx, 0, this.timelineWidthPx);
          endPx = clamp(endPx, 0, this.timelineWidthPx);
          timeline.selectArea(
            timescale.pxToHpTime(startPx).toTime('floor'),
            timescale.pxToHpTime(endPx).toTime('ceil'),
          );

          const absStartY = dragStartY + panTop;
          const absCurrentY = currentY + panTop;
          if (this.selectedContainer === undefined) {
            for (const c of dom.querySelectorAll('.pf-panel-container')) {
              const {top, bottom} = c.getBoundingClientRect();
              if (top <= absStartY && absCurrentY <= bottom) {
                const stack = assertExists(c.querySelector('.pf-panel-stack'));
                const stackTop = stack.getBoundingClientRect().top;
                this.selectedContainer = {
                  containerClass: Array.from(c.classList).filter(
                    (x) => x !== 'pf-panel-container',
                  )[0],
                  dragStartAbsY: -stackTop + absStartY,
                  dragEndAbsY: -stackTop + absCurrentY,
                };
                break;
              }
            }
          } else {
            const c = assertExists(
              dom.querySelector(`.${this.selectedContainer.containerClass}`),
            );
            const {top, bottom} = c.getBoundingClientRect();
            const boundedCurrentY = Math.min(
              Math.max(top, absCurrentY),
              bottom,
            );
            const stack = assertExists(c.querySelector('.pf-panel-stack'));
            const stackTop = stack.getBoundingClientRect().top;
            this.selectedContainer = {
              ...this.selectedContainer,
              dragEndAbsY: -stackTop + boundedCurrentY,
            };
          }
          this.showPanningHint = true;
        }
        raf.scheduleCanvasRedraw();
      },
      endSelection: (edit: boolean) => {
        this.selectedContainer = undefined;
        const area = attrs.trace.timeline.selectedArea;
        // If we are editing we need to pass the current id through to ensure
        // the marked area with that id is also updated.
        if (edit) {
          const selection = attrs.trace.selection.selection;
          if (selection.kind === 'area' && area) {
            attrs.trace.selection.selectArea({...area});
          }
        } else if (area) {
          attrs.trace.selection.selectArea({...area});
        }
        // Now the selection has ended we stored the final selected area in the
        // global state and can remove the in progress selection from the
        // timeline.
        attrs.trace.timeline.deselectArea();
        // Full redraw to color track shell.
        raf.scheduleFullRedraw();
      },
    });
  }

  onremove() {
    if (this.zoomContent) this.zoomContent[Symbol.dispose]();
  }

  view({attrs}: m.CVnode<PageWithTraceImplAttrs>) {
    const scrollingPanels = renderToplevelPanels(attrs.trace);

    const result = m(
      '.page.viewer-page',
      m(
        '.pan-and-zoom-content',
        {
          ref: this.PAN_ZOOM_CONTENT_REF,
          onclick: () => {
            // We don't want to deselect when panning/drag selecting.
            if (this.keepCurrentSelection) {
              this.keepCurrentSelection = false;
              return;
            }
            attrs.trace.selection.clear();
          },
        },
        m(
          '.pf-timeline-header',
          m(PanelContainer, {
            trace: attrs.trace,
            className: 'header-panel-container',
            panels: removeFalsyValues([
              OVERVIEW_PANEL_FLAG.get() && this.overviewTimelinePanel,
              this.timeAxisPanel,
              this.timeSelectionPanel,
              this.notesPanel,
              this.tickmarkPanel,
            ]),
            selectedYRange: this.getYRange('header-panel-container'),
          }),
          m('.scrollbar-spacer-vertical'),
        ),
        m(PanelContainer, {
          trace: attrs.trace,
          className: 'pinned-panel-container',
          panels: AppImpl.instance.isLoadingTrace
            ? []
            : attrs.trace.workspace.pinnedTracks.map((trackNode) => {
                if (trackNode.uri) {
                  const tr = attrs.trace.tracks.getTrackRenderer(trackNode.uri);
                  return new TrackPanel({
                    trace: attrs.trace,
                    reorderable: true,
                    node: trackNode,
                    trackRenderer: tr,
                    revealOnCreate: true,
                    indentationLevel: 0,
                    topOffsetPx: 0,
                  });
                } else {
                  return new TrackPanel({
                    trace: attrs.trace,
                    node: trackNode,
                    revealOnCreate: true,
                    indentationLevel: 0,
                    topOffsetPx: 0,
                  });
                }
              }),
          renderUnderlay: (ctx, size) => renderUnderlay(attrs.trace, ctx, size),
          renderOverlay: (ctx, size, panels) =>
            renderOverlay(
              attrs.trace,
              ctx,
              size,
              panels,
              attrs.trace.workspace.pinnedTracksNode,
            ),
          selectedYRange: this.getYRange('pinned-panel-container'),
        }),
        m(PanelContainer, {
          trace: attrs.trace,
          className: 'scrolling-panel-container',
          panels: AppImpl.instance.isLoadingTrace ? [] : scrollingPanels,
          onPanelStackResize: (width) => {
            const timelineWidth = width - TRACK_SHELL_WIDTH;
            this.timelineWidthPx = timelineWidth;
          },
          renderUnderlay: (ctx, size) => renderUnderlay(attrs.trace, ctx, size),
          renderOverlay: (ctx, size, panels) =>
            renderOverlay(
              attrs.trace,
              ctx,
              size,
              panels,
              attrs.trace.workspace.tracks,
            ),
          selectedYRange: this.getYRange('scrolling-panel-container'),
        }),
      ),
      m(TabPanel, {
        trace: attrs.trace,
      }),
      this.showPanningHint && m(HelpPanningNotification),
    );

    attrs.trace.tracks.flushOldTracks();
    return result;
  }

  private getYRange(cls: string): VerticalBounds | undefined {
    if (this.selectedContainer?.containerClass !== cls) {
      return undefined;
    }
    const {dragStartAbsY, dragEndAbsY} = this.selectedContainer;
    return {
      top: Math.min(dragStartAbsY, dragEndAbsY),
      bottom: Math.max(dragStartAbsY, dragEndAbsY),
    };
  }
}

function renderUnderlay(
  trace: TraceImpl,
  ctx: CanvasRenderingContext2D,
  canvasSize: Size2D,
): void {
  const size = {
    width: canvasSize.width - TRACK_SHELL_WIDTH,
    height: canvasSize.height,
  };

  using _ = canvasSave(ctx);
  ctx.translate(TRACK_SHELL_WIDTH, 0);

  const timewindow = trace.timeline.visibleWindow;
  const timescale = new TimeScale(timewindow, {left: 0, right: size.width});

  // Just render the gridlines - these should appear underneath all tracks
  drawGridLines(trace, ctx, timewindow.toTimeSpan(), timescale, size);
}

function renderOverlay(
  trace: TraceImpl,
  ctx: CanvasRenderingContext2D,
  canvasSize: Size2D,
  panels: ReadonlyArray<RenderedPanelInfo>,
  trackContainer: TrackNode,
): void {
  const size = {
    width: canvasSize.width - TRACK_SHELL_WIDTH,
    height: canvasSize.height,
  };

  using _ = canvasSave(ctx);
  ctx.translate(TRACK_SHELL_WIDTH, 0);
  canvasClip(ctx, 0, 0, size.width, size.height);

  // TODO(primiano): plumb the TraceImpl obj throughout the viwer page.
  renderFlows(trace, ctx, size, panels, trackContainer);

  const timewindow = trace.timeline.visibleWindow;
  const timescale = new TimeScale(timewindow, {left: 0, right: size.width});

  renderHoveredNoteVertical(trace, ctx, timescale, size);
  renderHoveredCursorVertical(trace, ctx, timescale, size);
  renderWakeupVertical(trace, ctx, timescale, size);
  renderNoteVerticals(trace, ctx, timescale, size);
}

// Render the toplevel "scrolling" tracks and track groups
function renderToplevelPanels(trace: TraceImpl): PanelOrGroup[] {
  return renderNodes(trace, trace.workspace.children, 0, 0);
}

// Given a list of tracks and a filter term, return a list pf panels filtered by
// the filter term
function renderNodes(
  trace: TraceImpl,
  nodes: ReadonlyArray<TrackNode>,
  indent: number,
  topOffsetPx: number,
): PanelOrGroup[] {
  return nodes.flatMap((node) => {
    if (node.headless) {
      // Render children as if this node doesn't exist
      return renderNodes(trace, node.children, indent, topOffsetPx);
    } else if (node.children.length === 0) {
      return renderTrackPanel(trace, node, indent, topOffsetPx);
    } else {
      const headerPanel = renderTrackPanel(trace, node, indent, topOffsetPx);
      const isSticky = node.isSummary;
      const nextTopOffsetPx = isSticky
        ? topOffsetPx + headerPanel.heightPx
        : topOffsetPx;
      return {
        kind: 'group',
        collapsed: node.collapsed,
        header: headerPanel,
        sticky: isSticky, // && node.collapsed??
        topOffsetPx,
        childPanels: node.collapsed
          ? []
          : renderNodes(trace, node.children, indent + 1, nextTopOffsetPx),
      };
    }
  });
}

function renderTrackPanel(
  trace: TraceImpl,
  trackNode: TrackNode,
  indent: number,
  topOffsetPx: number,
) {
  let tr = undefined;
  if (trackNode.uri) {
    tr = trace.tracks.getTrackRenderer(trackNode.uri);
  }
  return new TrackPanel({
    trace,
    node: trackNode,
    trackRenderer: tr,
    indentationLevel: indent,
    topOffsetPx,
  });
}

export function drawGridLines(
  trace: TraceImpl,
  ctx: CanvasRenderingContext2D,
  timespan: TimeSpan,
  timescale: TimeScale,
  size: Size2D,
): void {
  ctx.strokeStyle = TRACK_BORDER_COLOR;
  ctx.lineWidth = 1;

  if (size.width > 0 && timespan.duration > 0n) {
    const maxMajorTicks = getMaxMajorTicks(size.width);
    const offset = trace.timeline.timestampOffset();
    for (const {type, time} of generateTicks(timespan, maxMajorTicks, offset)) {
      const px = Math.floor(timescale.timeToPx(time));
      if (type === TickType.MAJOR) {
        ctx.beginPath();
        ctx.moveTo(px + 0.5, 0);
        ctx.lineTo(px + 0.5, size.height);
        ctx.stroke();
      }
    }
  }
}

export function renderHoveredCursorVertical(
  trace: TraceImpl,
  ctx: CanvasRenderingContext2D,
  timescale: TimeScale,
  size: Size2D,
) {
  if (trace.timeline.hoverCursorTimestamp !== undefined) {
    drawVerticalLineAtTime(
      ctx,
      timescale,
      trace.timeline.hoverCursorTimestamp,
      size.height,
      `#344596`,
    );
  }
}

export function renderHoveredNoteVertical(
  trace: TraceImpl,
  ctx: CanvasRenderingContext2D,
  timescale: TimeScale,
  size: Size2D,
) {
  if (trace.timeline.hoveredNoteTimestamp !== undefined) {
    drawVerticalLineAtTime(
      ctx,
      timescale,
      trace.timeline.hoveredNoteTimestamp,
      size.height,
      `#aaa`,
    );
  }
}

export function renderWakeupVertical(
  trace: TraceImpl,
  ctx: CanvasRenderingContext2D,
  timescale: TimeScale,
  size: Size2D,
) {
  const selection = trace.selection.selection;
  if (selection.kind === 'track_event' && selection.wakeupTs) {
    drawVerticalLineAtTime(
      ctx,
      timescale,
      selection.wakeupTs,
      size.height,
      `black`,
    );
  }
}

export function renderNoteVerticals(
  trace: TraceImpl,
  ctx: CanvasRenderingContext2D,
  timescale: TimeScale,
  size: Size2D,
) {
  // All marked areas should have semi-transparent vertical lines
  // marking the start and end.
  for (const note of trace.notes.notes.values()) {
    if (note.noteType === 'SPAN') {
      const transparentNoteColor =
        'rgba(' + hex.rgb(note.color.substr(1)).toString() + ', 0.65)';
      drawVerticalLineAtTime(
        ctx,
        timescale,
        note.start,
        size.height,
        transparentNoteColor,
        1,
      );
      drawVerticalLineAtTime(
        ctx,
        timescale,
        note.end,
        size.height,
        transparentNoteColor,
        1,
      );
    } else if (note.noteType === 'DEFAULT') {
      drawVerticalLineAtTime(
        ctx,
        timescale,
        note.timestamp,
        size.height,
        note.color,
      );
    }
  }
}

class HelpPanningNotification implements m.ClassComponent {
  private readonly PANNING_HINT_KEY = 'dismissedPanningHint';
  private dismissed = localStorage.getItem(this.PANNING_HINT_KEY) === 'true';

  view() {
    // Do not show the help notification in embedded mode because local storage
    // does not persist for iFrames. The host is responsible for communicating
    // to users that they can press '?' for help.
    if (AppImpl.instance.embeddedMode || this.dismissed) {
      return;
    }
    return m(
      '.helpful-hint',
      m(
        '.hint-text',
        'Are you trying to pan? Use the WASD keys or hold shift to click ' +
          "and drag. Press '?' for more help.",
      ),
      m(
        'button.hint-dismiss-button',
        {
          onclick: () => {
            this.dismissed = true;
            localStorage.setItem(this.PANNING_HINT_KEY, 'true');
            raf.scheduleFullRedraw();
          },
        },
        'Dismiss',
      ),
    );
  }
}
