import { injectable, inject } from 'inversify';
import { Emitter, Event } from '@theia/core/lib/common/event';
import { MessageService } from '@theia/core/lib/common/message-service';
import {
  SerialService,
  SerialConfig,
  SerialError,
  Status,
  SerialServiceClient,
} from '../../common/protocol/serial-service';
import { BoardsServiceProvider } from '../boards/boards-service-provider';
import {
  Board,
  BoardsService,
} from '../../common/protocol/boards-service';
import { BoardsConfig } from '../boards/boards-config';
import { SerialModel } from './serial-model';
import { ThemeService } from '@theia/core/lib/browser/theming';
import { CoreService } from '../../common/protocol';
import { nls } from '@theia/core/lib/common/nls';

@injectable()
export class SerialConnectionManager {
  protected config: Partial<SerialConfig> = {
    board: undefined,
    port: undefined,
    baudRate: undefined,
  };

  protected readonly onConnectionChangedEmitter = new Emitter<boolean>();

  /**
   * This emitter forwards all read events **if** the connection is established.
   */
  protected readonly onReadEmitter = new Emitter<{ messages: string[] }>();

  /**
   * Array for storing previous serial errors received from the server, and based on the number of elements in this array,
   * we adjust the reconnection delay.
   * Super naive way: we wait `array.length * 1000` ms. Once we hit 10 errors, we do not try to reconnect and clean the array.
   */
  protected serialErrors: SerialError[] = [];
  protected reconnectTimeout?: number;

  /**
   * When the websocket server is up on the backend, we save the port here, so that the client knows how to connect to it
   * */
  protected wsPort?: number;
  protected webSocket?: WebSocket;

  constructor(
    @inject(SerialModel) protected readonly serialModel: SerialModel,
    @inject(SerialService) protected readonly serialService: SerialService,
    @inject(SerialServiceClient)
    protected readonly serialServiceClient: SerialServiceClient,
    @inject(BoardsService) protected readonly boardsService: BoardsService,
    @inject(BoardsServiceProvider)
    protected readonly boardsServiceProvider: BoardsServiceProvider,
    @inject(MessageService) protected messageService: MessageService,
    @inject(ThemeService) protected readonly themeService: ThemeService,
    @inject(CoreService) protected readonly core: CoreService,
    @inject(BoardsServiceProvider)
    protected readonly boardsServiceClientImpl: BoardsServiceProvider
  ) {
    this.serialServiceClient.onWebSocketChanged(
      this.handleWebSocketChanged.bind(this)
    );
    this.serialServiceClient.onBaudRateChanged((baudRate) => {
      if (this.serialModel.baudRate !== baudRate) {
        this.serialModel.baudRate = baudRate;
      }
    });
    this.serialServiceClient.onLineEndingChanged((lineending) => {
      if (this.serialModel.lineEnding !== lineending) {
        this.serialModel.lineEnding = lineending;
      }
    });
    this.serialServiceClient.onInterpolateChanged((interpolate) => {
      if (this.serialModel.interpolate !== interpolate) {
        this.serialModel.interpolate = interpolate;
      }
    });

    this.serialServiceClient.onError(this.handleError.bind(this));
    this.boardsServiceProvider.onBoardsConfigChanged(
      this.handleBoardConfigChange.bind(this)
    );

    // Handles the `baudRate` changes by reconnecting if required.
    this.serialModel.onChange(async ({ property }) => {
      if (
        property === 'baudRate' &&
        (await this.serialService.isSerialPortOpen())
      ) {
        const { boardsConfig } = this.boardsServiceProvider;
        this.handleBoardConfigChange(boardsConfig);
      }

      // update the current values in the backend and propagate to websocket clients
      this.serialService.updateWsConfigParam({
        ...(property === 'lineEnding' && {
          currentLineEnding: this.serialModel.lineEnding,
        }),
        ...(property === 'interpolate' && {
          interpolate: this.serialModel.interpolate,
        }),
      });
    });

    this.themeService.onDidColorThemeChange((theme) => {
      this.serialService.updateWsConfigParam({
        darkTheme: theme.newTheme.type === 'dark',
      });
    });
  }

  /**
   * Updated the config in the BE passing only the properties that has changed.
   * BE will create a new connection if needed.
   *
   * @param newConfig the porperties of the config that has changed
   */
  async setConfig(newConfig: Partial<SerialConfig>): Promise<void> {
    let configHasChanged = false;
    Object.keys(this.config).forEach((key: keyof SerialConfig) => {
      if (newConfig[key] !== this.config[key]) {
        configHasChanged = true;
        this.config = { ...this.config, [key]: newConfig[key] };
      }
    });

    if (configHasChanged) {
      this.serialService.updateWsConfigParam({
        currentBaudrate: this.config.baudRate,
        serialPort: this.config.port?.address,
      });

      if (isSerialConfig(this.config)) {
        this.serialService.setSerialConfig(this.config);
      }
    }
  }

  getConfig(): Partial<SerialConfig> {
    return this.config;
  }

  getWsPort(): number | undefined {
    return this.wsPort;
  }

  protected handleWebSocketChanged(wsPort: number): void {
    this.wsPort = wsPort;
  }

  get serialConfig(): SerialConfig | undefined {
    return isSerialConfig(this.config)
      ? (this.config as SerialConfig)
      : undefined;
  }

  async isBESerialConnected(): Promise<boolean> {
    return await this.serialService.isSerialPortOpen();
  }

  openWSToBE(): void {
    if (!isSerialConfig(this.config)) {
      this.messageService.error(
        `Please select a board and a port to open the serial connection.`
      );
    }

    if (!this.webSocket && this.wsPort) {
      try {
        this.webSocket = new WebSocket(`ws://localhost:${this.wsPort}`);
        this.webSocket.onmessage = (res) => {
          const messages = JSON.parse(res.data);
          this.onReadEmitter.fire({ messages });
        };
      } catch {
        this.messageService.error(`Unable to connect to websocket`);
      }
    }
  }

  closeWStoBE(): void {
    if (this.webSocket) {
      try {
        this.webSocket.close();
        this.webSocket = undefined;
      } catch {
        this.messageService.error(`Unable to close websocket`);
      }
    }
  }

  /**
   * Handles error on the SerialServiceClient and try to reconnect, eventually
   */
  async handleError(error: SerialError): Promise<void> {
    if (!(await this.serialService.isSerialPortOpen())) return;
    const { code, config } = error;
    const { board, port } = config;
    const options = { timeout: 3000 };
    switch (code) {
      case SerialError.ErrorCodes.CLIENT_CANCEL: {
        console.debug(
          `Serial connection was canceled by client: ${Serial.Config.toString(
            this.config
          )}.`
        );
        break;
      }
      case SerialError.ErrorCodes.DEVICE_BUSY: {
        this.messageService.warn(
          nls.localize(
            'arduino/serial/connectionBusy',
            'Connection failed. Serial port is busy: {0}',
            port.address
          ),
          options
        );
        this.serialErrors.push(error);
        break;
      }
      case SerialError.ErrorCodes.DEVICE_NOT_CONFIGURED: {
        this.messageService.info(
          nls.localize(
            'arduino/serial/disconnected',
            'Disconnected {0} from {1}.',
            Board.toString(board, {
              useFqbn: false,
            }),
            port.address
          ),
          options
        );
        break;
      }
      case undefined: {
        this.messageService.error(
          nls.localize(
            'arduino/serial/unexpectedError',
            'Unexpected error. Reconnecting {0} on port {1}.',
            Board.toString(board),
            port.address
          ),
          options
        );
        console.error(JSON.stringify(error));
        break;
      }
    }

    if ((await this.serialService.clientsAttached()) > 0) {
      if (this.serialErrors.length >= 10) {
        this.messageService.warn(
          nls.localize(
            'arduino/serial/failedReconnect',
            'Failed to reconnect {0} to serial port after 10 consecutive attempts. The {1} serial port is busy.',
            Board.toString(board, {
              useFqbn: false,
            }),
            port.address
          )
        );
        this.serialErrors.length = 0;
      } else {
        const attempts = this.serialErrors.length || 1;
        if (this.reconnectTimeout !== undefined) {
          // Clear the previous timer.
          window.clearTimeout(this.reconnectTimeout);
        }
        const timeout = attempts * 1000;
        this.messageService.warn(
          nls.localize(
            'arduino/serial/reconnect',
            'Reconnecting {0} to {1} in {2} seconds...',
            Board.toString(board, {
              useFqbn: false,
            }),
            port.address,
            attempts.toString()
          )
        );
        this.reconnectTimeout = window.setTimeout(
          () => this.reconnectAfterUpload(),
          timeout
        );
      }
    }
  }

  async reconnectAfterUpload(): Promise<void> {
    try {
      if (isSerialConfig(this.config)) {
        await this.boardsServiceClientImpl.waitUntilAvailable(
          Object.assign(this.config.board, { port: this.config.port }),
          10_000
        );
        this.serialService.connectSerialIfRequired();
      }
    } catch (waitError) {
      this.messageService.error(
        nls.localize(
          'arduino/sketch/couldNotConnectToSerial',
          'Could not reconnect to serial port. {0}',
          waitError.toString()
        )
      );
    }
  }

  /**
   * Sends the data to the connected serial port.
   * The desired EOL is appended to `data`, you do not have to add it.
   * It is a NOOP if connected.
   */
  async send(data: string): Promise<Status> {
    if (!(await this.serialService.isSerialPortOpen())) {
      return Status.NOT_CONNECTED;
    }
    return new Promise<Status>((resolve) => {
      this.serialService
        .sendMessageToSerial(data + this.serialModel.lineEnding)
        .then(() => resolve(Status.OK));
    });
  }

  get onConnectionChanged(): Event<boolean> {
    return this.onConnectionChangedEmitter.event;
  }

  get onRead(): Event<{ messages: any }> {
    return this.onReadEmitter.event;
  }

  protected async handleBoardConfigChange(
    boardsConfig: BoardsConfig.Config
  ): Promise<void> {
    const { selectedBoard: board, selectedPort: port } = boardsConfig;
    const { baudRate } = this.serialModel;
    const newConfig: Partial<SerialConfig> = { board, port, baudRate };
    this.setConfig(newConfig);
  }
}

export namespace Serial {
  export namespace Config {
    export function toString(config: Partial<SerialConfig>): string {
      if (!isSerialConfig(config)) return '';
      const { board, port } = config;
      return `${Board.toString(board)} ${port.address}`;
    }
  }
}

function isSerialConfig(config: Partial<SerialConfig>): config is SerialConfig {
  return !!config.board && !!config.baudRate && !!config.port;
}