// Copyright (C) 2019 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use size 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 {currentTargetOffset} from '../base/dom_utils';
import {Icons} from '../base/semantic_icons';
import {randomColor} from '../components/colorizer';
import {SpanNote, Note} from '../public/note';
import {raf} from '../core/raf_scheduler';
import {Button, ButtonBar} from '../widgets/button';
import {TRACK_SHELL_WIDTH} from './css_constants';
import {getMaxMajorTicks, generateTicks, TickType} from './gridline_helper';
import {Size2D} from '../base/geom';
import {Panel} from './panel_container';
import {Timestamp} from '../components/widgets/timestamp';
import {assertUnreachable} from '../base/logging';
import {DetailsPanel} from '../public/details_panel';
import {TimeScale} from '../base/time_scale';
import {canvasClip} from '../base/canvas_utils';
import {Selection} from '../public/selection';
import {TraceImpl} from '../core/trace_impl';

const FLAG_WIDTH = 16;
const AREA_TRIANGLE_WIDTH = 10;
const FLAG = `\uE153`;

function toSummary(s: string) {
  const newlineIndex = s.indexOf('\n') > 0 ? s.indexOf('\n') : s.length;
  return s.slice(0, Math.min(newlineIndex, s.length, 16));
}

function getStartTimestamp(note: Note | SpanNote) {
  const noteType = note.noteType;
  switch (noteType) {
    case 'SPAN':
      return note.start;
    case 'DEFAULT':
      return note.timestamp;
    default:
      assertUnreachable(noteType);
  }
}

export class NotesPanel implements Panel {
  readonly kind = 'panel';
  readonly selectable = false;
  private readonly trace: TraceImpl;
  private timescale?: TimeScale; // The timescale from the last render()
  private hoveredX: null | number = null;
  private mouseDragging = false;

  constructor(trace: TraceImpl) {
    this.trace = trace;
  }

  render(): m.Children {
    const allCollapsed = this.trace.workspace.flatTracks.every(
      (n) => n.collapsed,
    );

    return m(
      '.notes-panel',
      {
        onmousedown: () => {
          // If the user clicks & drags, very likely they just want to measure
          // the time horizontally, not set a flag. This debouncing is done to
          // avoid setting accidental flags like measuring the time on the brush
          // timeline.
          this.mouseDragging = false;
        },
        onclick: (e: MouseEvent) => {
          if (!this.mouseDragging) {
            const x = currentTargetOffset(e).x - TRACK_SHELL_WIDTH;
            this.onClick(x);
            e.stopPropagation();
          }
        },
        onmousemove: (e: MouseEvent) => {
          this.mouseDragging = true;
          this.hoveredX = currentTargetOffset(e).x - TRACK_SHELL_WIDTH;
          raf.scheduleCanvasRedraw();
        },
        onmouseenter: (e: MouseEvent) => {
          this.hoveredX = currentTargetOffset(e).x - TRACK_SHELL_WIDTH;
          raf.scheduleCanvasRedraw();
        },
        onmouseout: () => {
          this.hoveredX = null;
          this.trace.timeline.hoveredNoteTimestamp = undefined;
        },
      },
      m(
        ButtonBar,
        {className: 'pf-toolbar'},
        m(Button, {
          onclick: (e: Event) => {
            e.preventDefault();
            if (allCollapsed) {
              this.trace.commands.runCommand(
                'perfetto.CoreCommands#ExpandAllGroups',
              );
            } else {
              this.trace.commands.runCommand(
                'perfetto.CoreCommands#CollapseAllGroups',
              );
            }
          },
          title: allCollapsed ? 'Expand all' : 'Collapse all',
          icon: allCollapsed ? 'unfold_more' : 'unfold_less',
          compact: true,
        }),
        m(Button, {
          onclick: (e: Event) => {
            e.preventDefault();
            this.trace.workspace.pinnedTracks.forEach((t) =>
              this.trace.workspace.unpinTrack(t),
            );
            raf.scheduleFullRedraw();
          },
          title: 'Clear all pinned tracks',
          icon: 'clear_all',
          compact: true,
        }),
        // TODO(stevegolton): Re-introduce this when we fix track filtering
        // m(TextInput, {
        //   placeholder: 'Filter tracks...',
        //   title:
        //     'Track filter - enter one or more comma-separated search terms',
        //   value: this.trace.state.trackFilterTerm,
        //   oninput: (e: Event) => {
        //     const filterTerm = (e.target as HTMLInputElement).value;
        //     this.trace.dispatch(Actions.setTrackFilterTerm({filterTerm}));
        //   },
        // }),
        // m(Button, {
        //   type: 'reset',
        //   icon: 'backspace',
        //   onclick: () => {
        //     this.trace.dispatch(
        //       Actions.setTrackFilterTerm({filterTerm: undefined}),
        //     );
        //   },
        //   title: 'Clear track filter',
        // }),
      ),
    );
  }

  renderCanvas(ctx: CanvasRenderingContext2D, size: Size2D) {
    ctx.fillStyle = '#999';
    ctx.fillRect(TRACK_SHELL_WIDTH - 2, 0, 2, size.height);

    const trackSize = {...size, width: size.width - TRACK_SHELL_WIDTH};

    ctx.save();
    ctx.translate(TRACK_SHELL_WIDTH, 0);
    canvasClip(ctx, 0, 0, trackSize.width, trackSize.height);
    this.renderPanel(ctx, trackSize);
    ctx.restore();
  }

  private renderPanel(ctx: CanvasRenderingContext2D, size: Size2D): void {
    let aNoteIsHovered = false;

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

    this.timescale = timescale;

    if (size.width > 0 && timespan.duration > 0n) {
      const maxMajorTicks = getMaxMajorTicks(size.width);
      const offset = this.trace.timeline.timestampOffset();
      const tickGen = generateTicks(timespan, maxMajorTicks, offset);
      for (const {type, time} of tickGen) {
        const px = Math.floor(timescale.timeToPx(time));
        if (type === TickType.MAJOR) {
          ctx.fillRect(px, 0, 1, size.height);
        }
      }
    }

    ctx.textBaseline = 'bottom';
    ctx.font = '10px Helvetica';

    for (const note of this.trace.notes.notes.values()) {
      const timestamp = getStartTimestamp(note);
      // TODO(hjd): We should still render area selection marks in viewport is
      // *within* the area (e.g. both lhs and rhs are out of bounds).
      if (
        (note.noteType === 'DEFAULT' &&
          !visibleWindow.contains(note.timestamp)) ||
        (note.noteType === 'SPAN' &&
          !visibleWindow.overlaps(note.start, note.end))
      ) {
        continue;
      }
      const currentIsHovered =
        this.hoveredX !== null && this.hitTestNote(this.hoveredX, note);
      if (currentIsHovered) aNoteIsHovered = true;

      const selection = this.trace.selection.selection;
      const isSelected = selection.kind === 'note' && selection.id === note.id;
      const x = timescale.timeToPx(timestamp);
      const left = Math.floor(x);

      // Draw flag or marker.
      if (note.noteType === 'SPAN') {
        this.drawAreaMarker(
          ctx,
          left,
          Math.floor(timescale.timeToPx(note.end)),
          note.color,
          isSelected,
        );
      } else {
        this.drawFlag(ctx, left, size.height, note.color, isSelected);
      }

      if (note.text) {
        const summary = toSummary(note.text);
        const measured = ctx.measureText(summary);
        // Add a white semi-transparent background for the text.
        ctx.fillStyle = 'rgba(255, 255, 255, 0.8)';
        ctx.fillRect(
          left + FLAG_WIDTH + 2,
          size.height + 2,
          measured.width + 2,
          -12,
        );
        ctx.fillStyle = '#3c4b5d';
        ctx.fillText(summary, left + FLAG_WIDTH + 3, size.height + 1);
      }
    }

    // A real note is hovered so we don't need to see the preview line.
    // TODO(hjd): Change cursor to pointer here.
    if (aNoteIsHovered) {
      this.trace.timeline.hoveredNoteTimestamp = undefined;
    }

    // View preview note flag when hovering on notes panel.
    if (!aNoteIsHovered && this.hoveredX !== null) {
      const timestamp = timescale.pxToHpTime(this.hoveredX).toTime();
      if (visibleWindow.contains(timestamp)) {
        this.trace.timeline.hoveredNoteTimestamp = timestamp;
        const x = timescale.timeToPx(timestamp);
        const left = Math.floor(x);
        this.drawFlag(ctx, left, size.height, '#aaa', /* fill */ true);
      }
    }

    ctx.restore();
  }

  private drawAreaMarker(
    ctx: CanvasRenderingContext2D,
    x: number,
    xEnd: number,
    color: string,
    fill: boolean,
  ) {
    ctx.fillStyle = color;
    ctx.strokeStyle = color;
    const topOffset = 10;
    // Don't draw in the track shell section.
    if (x >= 0) {
      // Draw left triangle.
      ctx.beginPath();
      ctx.moveTo(x, topOffset);
      ctx.lineTo(x, topOffset + AREA_TRIANGLE_WIDTH);
      ctx.lineTo(x + AREA_TRIANGLE_WIDTH, topOffset);
      ctx.lineTo(x, topOffset);
      if (fill) ctx.fill();
      ctx.stroke();
    }
    // Draw right triangle.
    ctx.beginPath();
    ctx.moveTo(xEnd, topOffset);
    ctx.lineTo(xEnd, topOffset + AREA_TRIANGLE_WIDTH);
    ctx.lineTo(xEnd - AREA_TRIANGLE_WIDTH, topOffset);
    ctx.lineTo(xEnd, topOffset);
    if (fill) ctx.fill();
    ctx.stroke();

    // Start line after track shell section, join triangles.
    const startDraw = Math.max(x, 0);
    ctx.beginPath();
    ctx.moveTo(startDraw, topOffset);
    ctx.lineTo(xEnd, topOffset);
    ctx.stroke();
  }

  private drawFlag(
    ctx: CanvasRenderingContext2D,
    x: number,
    height: number,
    color: string,
    fill?: boolean,
  ) {
    const prevFont = ctx.font;
    const prevBaseline = ctx.textBaseline;
    ctx.textBaseline = 'alphabetic';
    // Adjust height for icon font.
    ctx.font = '24px Material Symbols Sharp';
    ctx.fillStyle = color;
    ctx.strokeStyle = color;
    // The ligatures have padding included that means the icon is not drawn
    // exactly at the x value. This adjusts for that.
    const iconPadding = 6;
    if (fill) {
      ctx.fillText(FLAG, x - iconPadding, height + 2);
    } else {
      ctx.strokeText(FLAG, x - iconPadding, height + 2.5);
    }
    ctx.font = prevFont;
    ctx.textBaseline = prevBaseline;
  }

  private onClick(x: number) {
    if (!this.timescale) {
      return;
    }

    // Select the hovered note, or create a new single note & select it
    if (x < 0) return;
    for (const note of this.trace.notes.notes.values()) {
      if (this.hoveredX !== null && this.hitTestNote(this.hoveredX, note)) {
        this.trace.selection.selectNote({id: note.id});
        return;
      }
    }
    const timestamp = this.timescale.pxToHpTime(x).toTime();
    const color = randomColor();
    const noteId = this.trace.notes.addNote({timestamp, color});
    this.trace.selection.selectNote({id: noteId});
  }

  private hitTestNote(x: number, note: SpanNote | Note): boolean {
    if (!this.timescale) {
      return false;
    }

    const timescale = this.timescale;
    const noteX = timescale.timeToPx(getStartTimestamp(note));
    if (note.noteType === 'SPAN') {
      return (
        (noteX <= x && x < noteX + AREA_TRIANGLE_WIDTH) ||
        (timescale.timeToPx(note.end) > x &&
          x > timescale.timeToPx(note.end) - AREA_TRIANGLE_WIDTH)
      );
    } else {
      const width = FLAG_WIDTH;
      return noteX <= x && x < noteX + width;
    }
  }
}

export class NotesEditorTab implements DetailsPanel {
  constructor(private trace: TraceImpl) {}

  render(selection: Selection) {
    if (selection.kind !== 'note') {
      return undefined;
    }

    const id = selection.id;

    const note = this.trace.notes.getNote(id);
    if (note === undefined) {
      return m('.', `No Note with id ${id}`);
    }
    const startTime = getStartTimestamp(note);
    return m(
      '.notes-editor-panel',
      {
        key: id, // Every note shoul get its own brand new DOM.
      },
      m(
        '.notes-editor-panel-heading-bar',
        m(
          '.notes-editor-panel-heading',
          `Annotation at `,
          m(Timestamp, {ts: startTime}),
        ),
        m('input[type=text]', {
          oncreate: (v: m.VnodeDOM) => {
            // NOTE: due to bad design decisions elsewhere this component is
            // rendered every time the mouse moves on the canvas. We cannot set
            // `value: note.text` as an input as that will clobber the input
            // value as we move the mouse.
            const inputElement = v.dom as HTMLInputElement;
            inputElement.value = note.text;
            inputElement.focus();
          },
          onchange: (e: InputEvent) => {
            const newText = (e.target as HTMLInputElement).value;
            this.trace.notes.changeNote(id, {text: newText});
          },
        }),
        m(
          'span.color-change',
          `Change color: `,
          m('input[type=color]', {
            value: note.color,
            onchange: (e: Event) => {
              const newColor = (e.target as HTMLInputElement).value;
              this.trace.notes.changeNote(id, {color: newColor});
            },
          }),
        ),
        m(Button, {
          label: 'Remove',
          icon: Icons.Delete,
          onclick: () => this.trace.notes.removeNote(id),
        }),
      ),
    );
  }
}
