import * as React from 'react';
import * as dateFormat from 'dateformat';
import { postConstruct, injectable, inject } from 'inversify';
import { OptionsType } from 'react-select/src/types';
import { isOSX } from '@theia/core/lib/common/os';
import { Event, Emitter } from '@theia/core/lib/common/event';
import { Key, KeyCode } from '@theia/core/lib/browser/keys';
import {
  DisposableCollection,
  Disposable,
} from '@theia/core/lib/common/disposable';
import {
  ReactWidget,
  Message,
  Widget,
  MessageLoop,
} from '@theia/core/lib/browser/widgets';
import { Board, Port } from '../../common/protocol/boards-service';
import { MonitorConfig } from '../../common/protocol/monitor-service';
import { ArduinoSelect } from '../widgets/arduino-select';
import { MonitorModel } from './monitor-model';
import { MonitorConnection } from './monitor-connection';
import { MonitorServiceClientImpl } from './monitor-service-client-impl';

@injectable()
export class MonitorWidget extends ReactWidget {
  static readonly ID = 'serial-monitor';

  @inject(MonitorModel)
  protected readonly monitorModel: MonitorModel;

  @inject(MonitorConnection)
  protected readonly monitorConnection: MonitorConnection;

  @inject(MonitorServiceClientImpl)
  protected readonly monitorServiceClient: MonitorServiceClientImpl;

  protected widgetHeight: number;

  /**
   * Do not touch or use it. It is for setting the focus on the `input` after the widget activation.
   */
  protected focusNode: HTMLElement | undefined;
  /**
   * Guard against re-rendering the view after the close was requested.
   * See: https://github.com/eclipse-theia/theia/issues/6704
   */
  protected closing = false;
  protected readonly clearOutputEmitter = new Emitter<void>();

  constructor() {
    super();
    this.id = MonitorWidget.ID;
    this.title.label = 'Serial Monitor';
    this.title.iconClass = 'monitor-tab-icon';
    this.title.closable = true;
    this.scrollOptions = undefined;
    this.toDispose.push(this.clearOutputEmitter);
    this.toDispose.push(
      Disposable.create(() => {
        this.monitorConnection.autoConnect = false;
        if (this.monitorConnection.connected) {
          this.monitorConnection.disconnect();
        }
      })
    );
  }

  @postConstruct()
  protected init(): void {
    this.update();
    this.toDispose.push(
      this.monitorConnection.onConnectionChanged(() => this.clearConsole())
    );
  }

  clearConsole(): void {
    this.clearOutputEmitter.fire(undefined);
    this.update();
  }

  dispose(): void {
    super.dispose();
  }

  protected onAfterAttach(msg: Message): void {
    super.onAfterAttach(msg);
    this.monitorConnection.autoConnect = true;
  }

  onCloseRequest(msg: Message): void {
    this.closing = true;
    super.onCloseRequest(msg);
  }

  protected onUpdateRequest(msg: Message): void {
    // TODO: `this.isAttached`
    // See: https://github.com/eclipse-theia/theia/issues/6704#issuecomment-562574713
    if (!this.closing && this.isAttached) {
      super.onUpdateRequest(msg);
    }
  }

  protected onResize(msg: Widget.ResizeMessage): void {
    super.onResize(msg);
    this.widgetHeight = msg.height;
    this.update();
  }

  protected onActivateRequest(msg: Message): void {
    super.onActivateRequest(msg);
    (this.focusNode || this.node).focus();
  }

  protected onFocusResolved = (element: HTMLElement | undefined) => {
    if (this.closing || !this.isAttached) {
      return;
    }
    this.focusNode = element;
    requestAnimationFrame(() =>
      MessageLoop.sendMessage(this, Widget.Msg.ActivateRequest)
    );
  };

  protected get lineEndings(): OptionsType<SelectOption<MonitorModel.EOL>> {
    return [
      {
        label: 'No Line Ending',
        value: '',
      },
      {
        label: 'New Line',
        value: '\n',
      },
      {
        label: 'Carriage Return',
        value: '\r',
      },
      {
        label: 'Both NL & CR',
        value: '\r\n',
      },
    ];
  }

  protected get baudRates(): OptionsType<SelectOption<MonitorConfig.BaudRate>> {
    const baudRates: Array<MonitorConfig.BaudRate> = [
      300, 1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200,
    ];
    return baudRates.map((baudRate) => ({
      label: baudRate + ' baud',
      value: baudRate,
    }));
  }

  protected render(): React.ReactNode {
    const { baudRates, lineEndings } = this;
    const lineEnding =
      lineEndings.find((item) => item.value === this.monitorModel.lineEnding) ||
      lineEndings[1]; // Defaults to `\n`.
    const baudRate =
      baudRates.find((item) => item.value === this.monitorModel.baudRate) ||
      baudRates[4]; // Defaults to `9600`.
    return (
      <div className="serial-monitor">
        <div className="head">
          <div className="send">
            <SerialMonitorSendInput
              monitorConfig={this.monitorConnection.monitorConfig}
              resolveFocus={this.onFocusResolved}
              onSend={this.onSend}
            />
          </div>
          <div className="config">
            <div className="select">
              <ArduinoSelect
                maxMenuHeight={this.widgetHeight - 40}
                options={lineEndings}
                defaultValue={lineEnding}
                onChange={this.onChangeLineEnding}
              />
            </div>
            <div className="select">
              <ArduinoSelect
                className="select"
                maxMenuHeight={this.widgetHeight - 40}
                options={baudRates}
                defaultValue={baudRate}
                onChange={this.onChangeBaudRate}
              />
            </div>
          </div>
        </div>
        <div className="body">
          <SerialMonitorOutput
            monitorModel={this.monitorModel}
            monitorConnection={this.monitorConnection}
            clearConsoleEvent={this.clearOutputEmitter.event}
          />
        </div>
      </div>
    );
  }

  protected readonly onSend = (value: string) => this.doSend(value);
  protected async doSend(value: string): Promise<void> {
    this.monitorConnection.send(value);
  }

  protected readonly onChangeLineEnding = (
    option: SelectOption<MonitorModel.EOL>
  ) => {
    this.monitorModel.lineEnding = option.value;
  };

  protected readonly onChangeBaudRate = (
    option: SelectOption<MonitorConfig.BaudRate>
  ) => {
    this.monitorModel.baudRate = option.value;
  };
}

export namespace SerialMonitorSendInput {
  export interface Props {
    readonly monitorConfig?: MonitorConfig;
    readonly onSend: (text: string) => void;
    readonly resolveFocus: (element: HTMLElement | undefined) => void;
  }
  export interface State {
    text: string;
  }
}

export class SerialMonitorSendInput extends React.Component<
  SerialMonitorSendInput.Props,
  SerialMonitorSendInput.State
> {
  constructor(props: Readonly<SerialMonitorSendInput.Props>) {
    super(props);
    this.state = { text: '' };
    this.onChange = this.onChange.bind(this);
    this.onSend = this.onSend.bind(this);
    this.onKeyDown = this.onKeyDown.bind(this);
  }

  render(): React.ReactNode {
    return (
      <input
        ref={this.setRef}
        type="text"
        className={`theia-input ${this.props.monitorConfig ? '' : 'warning'}`}
        placeholder={this.placeholder}
        value={this.state.text}
        onChange={this.onChange}
        onKeyDown={this.onKeyDown}
      />
    );
  }

  protected get placeholder(): string {
    const { monitorConfig } = this.props;
    if (!monitorConfig) {
      return 'Not connected. Select a board and a port to connect automatically.';
    }
    const { board, port } = monitorConfig;
    return `Message (${
      isOSX ? '⌘' : 'Ctrl'
    }+Enter to send message to '${Board.toString(board, {
      useFqbn: false,
    })}' on '${Port.toString(port)}')`;
  }

  protected setRef = (element: HTMLElement | null) => {
    if (this.props.resolveFocus) {
      this.props.resolveFocus(element || undefined);
    }
  };

  protected onChange(event: React.ChangeEvent<HTMLInputElement>): void {
    this.setState({ text: event.target.value });
  }

  protected onSend(): void {
    this.props.onSend(this.state.text);
    this.setState({ text: '' });
  }

  protected onKeyDown(event: React.KeyboardEvent<HTMLInputElement>): void {
    const keyCode = KeyCode.createKeyCode(event.nativeEvent);
    if (keyCode) {
      const { key, meta, ctrl } = keyCode;
      if (key === Key.ENTER && ((isOSX && meta) || (!isOSX && ctrl))) {
        this.onSend();
      }
    }
  }
}

export namespace SerialMonitorOutput {
  export interface Props {
    readonly monitorModel: MonitorModel;
    readonly monitorConnection: MonitorConnection;
    readonly clearConsoleEvent: Event<void>;
  }
  export interface State {
    content: string;
    timestamp: boolean;
  }
}

export class SerialMonitorOutput extends React.Component<
  SerialMonitorOutput.Props,
  SerialMonitorOutput.State
> {
  /**
   * Do not touch it. It is used to be able to "follow" the serial monitor log.
   */
  protected anchor: HTMLElement | null;
  protected toDisposeBeforeUnmount = new DisposableCollection();

  constructor(props: Readonly<SerialMonitorOutput.Props>) {
    super(props);
    this.state = {
      content: '',
      timestamp: this.props.monitorModel.timestamp,
    };
  }

  render(): React.ReactNode {
    return (
      <React.Fragment>
        <div style={{ whiteSpace: 'pre', fontFamily: 'monospace' }}>
          {this.state.content}
        </div>
        <div
          style={{ float: 'left', clear: 'both' }}
          ref={(element) => {
            this.anchor = element;
          }}
        />
      </React.Fragment>
    );
  }

  componentDidMount(): void {
    this.scrollToBottom();
    this.toDisposeBeforeUnmount.pushAll([
      this.props.monitorConnection.onRead(({ message }) => {
        const rawLines = message.split('\n');
        const lines: string[] = [];
        const timestamp = () =>
          this.state.timestamp
            ? `${dateFormat(new Date(), 'H:M:ss.l')} -> `
            : '';
        for (let i = 0; i < rawLines.length; i++) {
          if (i === 0 && this.state.content.length !== 0) {
            lines.push(rawLines[i]);
          } else {
            lines.push(timestamp() + rawLines[i]);
          }
        }
        const content = this.state.content + lines.join('\n');
        this.setState({ content });
      }),
      this.props.clearConsoleEvent(() => this.setState({ content: '' })),
      this.props.monitorModel.onChange(({ property }) => {
        if (property === 'timestamp') {
          const { timestamp } = this.props.monitorModel;
          this.setState({ timestamp });
        }
      }),
    ]);
  }

  componentDidUpdate(): void {
    this.scrollToBottom();
  }

  componentWillUnmount(): void {
    // TODO: "Your preferred browser's local storage is almost full." Discard `content` before saving layout?
    this.toDisposeBeforeUnmount.dispose();
  }

  protected scrollToBottom(): void {
    if (this.props.monitorModel.autoscroll && this.anchor) {
      this.anchor.scrollIntoView();
    }
  }
}

export interface SelectOption<T> {
  readonly label: string;
  readonly value: T;
}