import { IGattCharacteristic, IGattPeripheral } from "./bluetooth";
import {
  MAX_STATE_READ_CHUNK,
  VALVE_RX_UUID,
  VALVE_SERVICE_UUID,
  VALVE_STATE_LENGTH,
  VALVE_TX_UUID,
} from "./constants";
import {
  PACKET_HEADER_LENGTH,
  PacketId,
  RESPONSE_FOOTER_LENGTH,
  createStateReadPacket,
  createStateWritePackets,
  createWakeUpPacket,
  decodeStateField,
  encodeStateField,
} from "./protocol";
import { RadiatorValvesOptions } from "./scanner";
import { TimeoutToken, withTimeout } from "./utils";
import {
  FIELD_BATTERY_VOLTAGE,
  FIELD_CURRENT_TEMPERATURE,
  FIELD_LOCKED,
  FIELD_MODE,
  FIELD_NAME,
  FIELD_SERIAL_NUMBER,
  FIELD_TARGET_TEMPERATURE_AUTO,
  FIELD_TARGET_TEMPERATURE_MANUAL,
  FIELD_TARGET_TEMPERATURE_SAVING,
  FIELD_TEMPERATURE_DEVIATION,
  StateFieldInfo,
} from "./valve-state";

export default class RadiatorValve {
  /** Characteristic used to read data from the device. */
  private rx?: IGattCharacteristic;

  /** Characteristic used to write data to the device. */
  private tx?: IGattCharacteristic;

  private lastSentWakeUpTime = 0;
  private logger = this.options.logger;

  constructor(
    public readonly peripheral: IGattPeripheral,
    private readonly options: Readonly<RadiatorValvesOptions>
  ) {}

  /**
   * Attempts to establish a connection with the device.
   *
   * @param attempt Counts how many attempts have been made so far. For internal use only.
   */
  public async connect(attempt: number = 0): Promise<void> {
    if (this.peripheral.state === "connected") {
      await this.peripheral.disconnectAsync();
    } else if (this.peripheral.state === "connecting") {
      throw new Error(`Already connecting to ${this.peripheral.address}`);
    }

    if (attempt >= this.options.maxConnectionAttempts) {
      throw new Error(`Too many attempts trying to connect to ${this.peripheral.address}`);
    }

    this.logger?.debug(
      `Connecting to ${this.peripheral.address} (timeout=${this.options.connectionTimeout}, attempt=${attempt})`
    );

    const timeoutToken = new TimeoutToken(this.options.connectionTimeout);

    if (this.peripheral.state !== "connected") {
      await withTimeout(this.peripheral.connectAsync(), timeoutToken);
      if (timeoutToken.timedOut) {
        this.logger?.warn(`Timed out connecting to ${this.peripheral.address}`);
        return this.connect(attempt + 1);
      }
    }

    // Find handles to the read/write service.
    const services = await withTimeout(
      this.peripheral.discoverServicesAsync([VALVE_SERVICE_UUID]),
      timeoutToken
    );
    if (timeoutToken.timedOut) {
      this.logger?.warn(`Timed out discovering services of ${this.peripheral.address}`);
      return this.connect(attempt + 1);
    }
    if (services.length === 0) {
      throw new Error(`${this.peripheral.address} did not report a communication service`);
    }

    // Find handles to read/write characteristics.
    const characteristics = await withTimeout(
      services[0].discoverCharacteristicsAsync([VALVE_RX_UUID, VALVE_TX_UUID]),
      timeoutToken
    );
    if (timeoutToken.timedOut) {
      this.logger?.warn(`Timed out discovering characteristics of ${this.peripheral.address}`);
      return this.connect(attempt + 1);
    }
    if (characteristics.length != 2) {
      throw new Error(`${this.peripheral.address} did not report read/write characteristics`);
    }

    [this.rx, this.tx] = characteristics;

    // Enable receiving notifications from RX characteristic.
    // Writing the value always times out, but somehow works fine on Raspberry.
    const descriptors = await withTimeout(this.rx.discoverDescriptorsAsync(), timeoutToken);
    if (timeoutToken.timedOut) {
      this.logger?.warn(`Timed out discovering descriptors of ${this.peripheral.address}`);
      return this.connect(attempt + 1);
    }
    if (this.options.raspberryFix) {
      descriptors[0].writeValueAsync(Buffer.from([0x01, 0x00]));
    }

    this.logger?.debug(`Connected to ${this.peripheral.address}`);
  }

  /**
   * Closes connection with the peripheral.
   */
  public async disconnect() {
    if (this.peripheral.state !== "disconnected") {
      await this.peripheral.disconnectAsync();
      this.logger?.debug(`Closed connection to ${this.peripheral.address}`);
    }
  }

  /**
   * Writes data contained in given buffer to the device.
   * Make sure to wait for the message to be fully sent by `await`-ing this
   * method before writing more data.
   *
   * @param data Data to write.
   */
  private write(data: Buffer) {
    this.logger?.verbose(`[Host -> ${this.peripheral.address}]`, data);
    return this.tx?.writeAsync(data, false);
  }

  /**
   * Writes a request to the device and waits for the response.
   *
   * @param request Request to send.
   * @returns Response.
   */
  private sendRequest(request: Buffer) {
    const work = async (resolve: Function, reject: Function, attempt: number) => {
      if (!this.tx || !this.rx) {
        throw new Error("Connection must be open before sending requests.");
      }

      if (attempt >= this.options.maxReadAttempts) {
        // TODO: Probably we should re-connect, because it's difficult to say how
        // the peripheral will act in case of a small congestion.
        throw new Error(`Timed out reading response from ${this.peripheral.address}`);
      }

      let responseChunks: Array<Buffer> = [];

      this.rx.notify(true);
      this.rx.on("data", (data) => {
        this.logger?.verbose(`[${this.peripheral.address} -> Host]`, data);

        responseChunks.push(data);

        if (data.length >= 2 && data.readUInt16LE(data.length - 2) === 0x0a0d) {
          this.rx?.removeAllListeners("data");
          this.rx?.notify(false);
          clearTimeout(timeoutId);
          resolve(Buffer.concat(responseChunks));
        }
      });

      let timeoutId: NodeJS.Timeout;
      if (this.options.readTimeout > 0) {
        timeoutId = setTimeout(async () => {
          if (this.rx) {
            this.rx.removeAllListeners("data");
            this.rx.notify(false);
          }

          this.logger?.warn(
            `Timed out reading response from ${this.peripheral.address} (attempt ${attempt})`
          );

          try {
            await work(resolve, reject, attempt + 1);
          } catch (error) {
            reject(error);
          }
        }, this.options.readTimeout);
      }

      await this.write(request);
    };

    return new Promise<Buffer>(async (resolve, reject) => {
      try {
        await work(resolve, reject, 0);
      } catch (error) {
        reject(error);
      }
    });
  }

  /**
   * Sends Wake Up command to the peripheral and waits for a response.
   */
  public async requestWakeUp() {
    const timeSinceLastWakeUp = new Date().getTime() - this.lastSentWakeUpTime;
    if (timeSinceLastWakeUp < this.options.wakeUpInterval) {
      return;
    }
    await this.sendRequest(createWakeUpPacket());
    this.lastSentWakeUpTime = new Date().getTime();
  }

  /**
   * Requests value of a single field from peripheral's state buffer.
   * @returns Buffer containing the value.
   */
  public async requestReadField<T>(field: StateFieldInfo<T>): Promise<T> {
    const [position, encoding] = field;
    const packet = createStateReadPacket(position[0], position[1]);
    const response = await this.sendRequest(packet);

    // Skip header and checksum.
    // TODO: Verify checksum.
    const encodedValue = response.subarray(
      PACKET_HEADER_LENGTH,
      response.length - RESPONSE_FOOTER_LENGTH
    );

    return decodeStateField(encodedValue, encoding) as T;
  }

  /**
   * Updates the value of a field.
   *
   * @param field Field to update.
   * @param value New value.
   */
  public async requestWriteField<T>(field: StateFieldInfo<T>, value: T) {
    const [[offset, length], encoding] = field;
    const encodedValue = encodeStateField(value, encoding);
    if (encodedValue.length > length) {
      throw new Error(
        `Overflow when writing field value. Expected at most ${length} bytes, got ${encodedValue.length}`
      );
    }

    const paddedValue = Buffer.concat([encodedValue], length);
    const packets = createStateWritePackets(paddedValue, offset);

    const work = async (attempt = 0) => {
      if (attempt >= this.options.maxWriteAttempts) {
        throw new Error(
          `Too many failed attempts at updating configuration of ${this.peripheral.address}`
        );
      }

      let failed = false;
      for (let packetIndex = 0; packetIndex < packets.length; packetIndex++) {
        const packet = packets[packetIndex];
        const response = await this.sendRequest(packet);
        if (response[2] !== PacketId.SaveSuccess) {
          this.logger?.warn(
            `Unable to update configuration of ${this.peripheral.address} (offset=${offset}, packet=${packet}, packetIndex=${packetIndex}, attempt=${attempt})`
          );
          failed = true;
          break;
        }
      }

      if (failed) {
        await work(attempt + 1);
      }
    };

    await work(0);
  }

  /**
   * Requests a snapshot of the entire state buffer from the peripheral.
   * @returns Buffer containing the state.
   */
  public async requestStateSnapshot() {
    let buffer = Buffer.alloc(0);

    for (let offset = 0; offset < VALVE_STATE_LENGTH; offset += MAX_STATE_READ_CHUNK) {
      const packet = createStateReadPacket(offset, MAX_STATE_READ_CHUNK);
      let response = await this.sendRequest(packet);

      // Skip header and checksum.
      response = response.subarray(PACKET_HEADER_LENGTH, response.length - RESPONSE_FOOTER_LENGTH);

      buffer = Buffer.concat([buffer, response]);
    }

    return buffer;
  }

  public async setName(name: string) {
    if (name.length > 64) {
      throw new Error("Name can not be longer than 64 characters");
    }

    await this.requestWakeUp();
    await this.requestWriteField(FIELD_NAME, name);
  }

  public async getName() {
    await this.requestWakeUp();
    return this.requestReadField(FIELD_NAME);
  }

  public async getSerialNumber() {
    await this.requestWakeUp();
    return this.requestReadField(FIELD_SERIAL_NUMBER);
  }

  public async setLocked(locked: boolean) {
    await this.requestWakeUp();
    await this.requestWriteField(FIELD_LOCKED, locked ? 1 : 0);
  }

  public async getLocked() {
    await this.requestWakeUp();
    return this.requestReadField(FIELD_LOCKED);
  }

  public async getBatteryVoltage() {
    await this.requestWakeUp();
    return this.requestReadField(FIELD_BATTERY_VOLTAGE);
  }

  public async getTemperatureDeviation() {
    await this.requestWakeUp();
    return this.requestReadField(FIELD_TEMPERATURE_DEVIATION);
  }

  public async getCurrentTemperature() {
    await this.requestWakeUp();
    return this.requestReadField(FIELD_CURRENT_TEMPERATURE);
  }

  /**
   * Sets the target temperature.
   * It takes up to 9 minutes for the valve to actually apply
   * the update in case of this field.
   *
   * @param value New target temperature.
   */
  public async setTargetTemperature(value: number) {
    if (value < 0.5 || value > 29.5) {
      throw new Error("Target temperature must be in [0.5-29.5] range");
    }
    await this.requestWakeUp();
    await this.requestWriteField(await this.getTargetTemperatureField(), value);
  }

  private async getTargetTemperatureField() {
    const mode = await this.getMode();
    if (mode === 0) {
      return FIELD_TARGET_TEMPERATURE_AUTO;
    } else if (mode === 1) {
      return FIELD_TARGET_TEMPERATURE_MANUAL;
    } else if (mode === 2) {
      return FIELD_TARGET_TEMPERATURE_SAVING;
    }
    throw new Error(`Unknown mode: ${mode}`);
  }

  public async getTargetTemperature() {
    await this.requestWakeUp();
    return this.requestReadField(await this.getTargetTemperatureField());
  }

  public async getMode() {
    await this.requestWakeUp();
    return this.requestReadField(FIELD_MODE);
  }
}
