import * as plugins from '../../plugins.js';
import { SimpleLocalClient, SimpleLocalMapper, type ISimpleLocalEntitySnapshot, type IServiceCallRequest, type IServiceCallResult } from '../../core/index.js';
import { NibeHeatpumpMapper } from './nibe_heatpump.mapper.js';
import type { INibeHeatpumpCoilConfig, INibeHeatpumpConfig, INibeHeatpumpSnapshot, TNibeHeatpumpCoilSize } from './nibe_heatpump.types.js';
import { nibeHeatpumpDefaultModbusCoils, nibeHeatpumpProfile } from './nibe_heatpump.types.js';

export class NibeHeatpumpClient extends SimpleLocalClient<INibeHeatpumpConfig> {
  private liveSnapshot?: INibeHeatpumpSnapshot;

  constructor(private readonly configArg: INibeHeatpumpConfig) {
    super(nibeHeatpumpProfile, configArg);
  }

  public override async getSnapshot(forceRefreshArg = false): Promise<INibeHeatpumpSnapshot> {
    if (!forceRefreshArg && this.liveSnapshot) {
      return SimpleLocalMapper.clone(this.liveSnapshot);
    }

    if (this.shouldUseNativeModbus(forceRefreshArg)) {
      try {
        this.liveSnapshot = await this.fetchModbusSnapshot();
      } catch (errorArg) {
        this.liveSnapshot = NibeHeatpumpMapper.toSnapshot({ config: this.configArg, online: false, source: 'runtime', error: errorMessage(errorArg) });
      }
      return SimpleLocalMapper.clone(this.liveSnapshot);
    }

    const snapshot = await super.getSnapshot(forceRefreshArg);
    this.liveSnapshot = NibeHeatpumpMapper.toSnapshot({ config: this.configArg, rawData: snapshot.rawData ?? snapshot, online: snapshot.online, source: snapshot.source, error: snapshot.error });
    return SimpleLocalMapper.clone(this.liveSnapshot);
  }

  public override async refresh(): Promise<IServiceCallResult> {
    this.liveSnapshot = undefined;
    const snapshot = await this.getSnapshot(true);
    const success = snapshot.online && !snapshot.error;
    return { success, error: success ? undefined : snapshot.error, data: snapshot };
  }

  public override async execute(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
    if (this.configArg.client?.execute || this.configArg.commandExecutor) {
      return super.execute(requestArg);
    }

    try {
      if (!this.modbusEndpoint()) {
        return super.execute(requestArg);
      }
    } catch (errorArg) {
      return { success: false, error: errorMessage(errorArg) };
    }

    try {
      if (requestArg.service === 'refresh' || requestArg.service === 'reload') {
        return this.refresh();
      }
      if (requestArg.service !== 'set_value' && requestArg.service !== 'select_option' && requestArg.service !== 'turn_on' && requestArg.service !== 'turn_off' && requestArg.service !== 'toggle') {
        return super.execute(requestArg);
      }
      await this.writeConfiguredCoil(requestArg);
      this.liveSnapshot = undefined;
      return { success: true };
    } catch (errorArg) {
      return { success: false, error: errorMessage(errorArg) };
    }
  }

  private shouldUseNativeModbus(forceRefreshArg: boolean): boolean {
    if (this.configArg.client || this.configArg.snapshotProvider) {
      return false;
    }
    if (!forceRefreshArg && this.configArg.snapshot) {
      return false;
    }
    return Boolean(this.configArg.host || this.configArg.url || this.configArg.modbusUrl || this.configArg.connectionType === 'nibegw' || this.configArg.connectionType === 'serial');
  }

  private async fetchModbusSnapshot(): Promise<INibeHeatpumpSnapshot> {
    const endpoint = this.modbusEndpointOrThrow();
    const unitId = this.configArg.modbusUnit ?? 1;
    const coils = this.configArg.coils?.length ? this.configArg.coils : nibeHeatpumpDefaultModbusCoils;
    const connection = new NibeModbusTcpConnection(endpoint.host, endpoint.port, this.configArg.timeoutMs || 5000);
    const readCoils: INibeHeatpumpReadCoil[] = [];

    for (const coil of coils) {
      const registers = await connection.readCoil(unitId, coil);
      readCoils.push({ coil, registers, value: decodeCoilValue(coil, registers, this.configArg.wordSwap ?? true) });
    }

    const deviceId = this.configArg.uniqueId || this.configArg.deviceId || `${endpoint.host}:${endpoint.port}`;
    const entities = readCoils.map((coilArg) => this.entityFromCoil(deviceId, coilArg));

    return {
      device: {
        id: deviceId,
        name: this.configArg.name || nibeHeatpumpProfile.defaultName || nibeHeatpumpProfile.displayName,
        manufacturer: 'NIBE Energy Systems',
        model: this.configArg.model || 'NIBE heat pump Modbus TCP',
        host: endpoint.host,
        port: endpoint.port,
        protocol: 'tcp',
        attributes: cleanAttributes({ modbusUnit: unitId, wordSwap: this.configArg.wordSwap ?? true }),
      },
      entities,
      online: true,
      updatedAt: new Date().toISOString(),
      source: 'tcp',
      rawData: {
        connectionType: 'modbus',
        modbusUnit: unitId,
        coils: readCoils.map((coilArg) => ({ ...coilArg.coil, registers: coilArg.registers, value: coilArg.value })),
      },
      capabilities: {
        localRead: true,
        localControl: Boolean(this.configArg.allowWrites),
        modbusTcp: true,
      },
    };
  }

  private entityFromCoil(deviceIdArg: string, readCoilArg: INibeHeatpumpReadCoil): ISimpleLocalEntitySnapshot {
    const coil = readCoilArg.coil;
    const value = readCoilArg.value;
    const platform = coil.platform || (isBooleanCoil(coil) ? 'binary_sensor' : coil.mappings ? 'select' : coil.write ? 'number' : 'sensor');
    return {
      id: SimpleLocalMapper.slug(coil.name || String(coil.address)),
      uniqueId: `${nibeHeatpumpProfile.domain}_${SimpleLocalMapper.slug(deviceIdArg)}_${coil.address}`,
      name: coil.title || coil.name || `NIBE coil ${coil.address}`,
      platform,
      state: value,
      available: true,
      writable: Boolean(coil.write && this.configArg.allowWrites),
      unit: coil.unit,
      attributes: cleanAttributes({
        address: coil.address,
        name: coil.name,
        size: coil.size,
        factor: coil.factor ?? 1,
        registers: readCoilArg.registers,
        writable: coil.write,
        mappings: coil.mappings,
      }),
    };
  }

  private async writeConfiguredCoil(requestArg: IServiceCallRequest): Promise<void> {
    if (!this.configArg.allowWrites) {
      throw new Error('NIBE Heat Pump native Modbus writes require config.allowWrites=true.');
    }
    const endpoint = this.modbusEndpointOrThrow();
    const coil = await this.coilForRequest(requestArg);
    if (!coil.write) {
      throw new Error(`NIBE Heat Pump coil ${coil.name || coil.address} is not configured as writable.`);
    }

    const data = requestArg.data || {};
    let value: unknown;
    if (requestArg.service === 'turn_on') {
      value = true;
    } else if (requestArg.service === 'turn_off') {
      value = false;
    } else if (requestArg.service === 'toggle') {
      const snapshot = this.liveSnapshot || await this.getSnapshot();
      const entity = snapshot.entities.find((entityArg) => Number(entityArg.attributes?.address) === coil.address);
      value = entity?.state !== true;
    } else {
      value = data.value ?? data.option ?? data.state;
    }
    if (value === undefined) {
      throw new Error('NIBE Heat Pump set_value/select_option requires data.value or data.option.');
    }

    const rawValues = encodeCoilValue(coil, value, this.configArg.wordSwap ?? true);
    const connection = new NibeModbusTcpConnection(endpoint.host, endpoint.port, this.configArg.timeoutMs || 5000);
    await connection.writeCoil(this.configArg.modbusUnit ?? 1, coil, rawValues);
  }

  private async coilForRequest(requestArg: IServiceCallRequest): Promise<INibeHeatpumpCoilConfig> {
    const data = requestArg.data || {};
    const configuredCoils = this.configArg.coils?.length ? this.configArg.coils : nibeHeatpumpDefaultModbusCoils;
    const requestedAddress = numberValue(data.address ?? data.coilAddress);
    if (requestedAddress !== undefined) {
      const coil = configuredCoils.find((coilArg) => coilArg.address === requestedAddress);
      if (coil) {
        return coil;
      }
    }

    const requestedName = stringValue(data.name ?? data.coil ?? data.coilName);
    if (requestedName) {
      const coil = configuredCoils.find((coilArg) => coilArg.name === requestedName || SimpleLocalMapper.slug(coilArg.name) === SimpleLocalMapper.slug(requestedName));
      if (coil) {
        return coil;
      }
    }

    const entityId = stringValue(requestArg.target.entityId) || stringValue(data.entityId);
    if (entityId) {
      const snapshot = this.liveSnapshot || await this.getSnapshot();
      const entity = snapshot.entities.find((entityArg) => entityArg.id === entityId || entityArg.uniqueId === entityId || entityId.endsWith(`.${SimpleLocalMapper.slug(entityArg.name)}`));
      const address = numberValue(entity?.attributes?.address);
      const coil = configuredCoils.find((coilArg) => coilArg.address === address);
      if (coil) {
        return coil;
      }
    }

    throw new Error('NIBE Heat Pump command requires data.address, data.coil/name, or target.entityId matching a configured coil.');
  }

  private modbusEndpointOrThrow(): INibeHeatpumpEndpoint {
    if (this.configArg.connectionType === 'nibegw') {
      throw new Error('NIBE NibeGW is explicitly unsupported here because the documented protocol uses UDP listener/send ports 9999/10000; this client only implements TCP Modbus.');
    }
    if (this.configArg.connectionType === 'serial') {
      throw new Error('NIBE serial Modbus is explicitly unsupported because it requires a serial transport outside the existing dependencies.');
    }

    const endpoint = this.modbusEndpoint();
    if (!endpoint) {
      throw new Error('NIBE Heat Pump native snapshots require tcp:// modbusUrl/url or config.host for Modbus TCP.');
    }
    return endpoint;
  }

  private modbusEndpoint(): INibeHeatpumpEndpoint | undefined {
    const urlValue = this.configArg.modbusUrl || this.configArg.url || (this.configArg.host?.includes('://') ? this.configArg.host : undefined);
    const url = safeUrl(urlValue);
    if (url) {
      if (url.protocol === 'serial:') {
        throw new Error('NIBE serial Modbus URLs are unsupported by this TCP client.');
      }
      if (url.protocol === 'rfc2217:') {
        throw new Error('NIBE RFC2217 serial bridge URLs are unsupported because they require serial-over-TCP semantics.');
      }
      if (url.protocol !== 'tcp:') {
        throw new Error(`NIBE Modbus URL protocol ${url.protocol.replace(':', '')} is unsupported; use tcp://host:port.`);
      }
      return { host: url.hostname, port: url.port ? Number(url.port) : nibeHeatpumpProfile.defaultPort || 502 };
    }
    if (!this.configArg.host) {
      return undefined;
    }
    return { host: this.configArg.host, port: this.configArg.port || nibeHeatpumpProfile.defaultPort || 502 };
  }
}

interface INibeHeatpumpEndpoint {
  host: string;
  port: number;
}

interface INibeHeatpumpReadCoil {
  coil: INibeHeatpumpCoilConfig;
  registers: number[];
  value: unknown;
}

class NibeModbusTcpConnection {
  private transactionId = 0;

  constructor(private readonly host: string, private readonly port: number, private readonly timeoutMs: number) {}

  public async readCoil(unitIdArg: number, coilArg: INibeHeatpumpCoilConfig): Promise<number[]> {
    const modbusData = splitModbusData(coilArg);
    const functionCode = readFunctionCode(modbusData.entityType);
    const body = Buffer.alloc(4);
    body.writeUInt16BE(modbusData.startingAddress, 0);
    body.writeUInt16BE(modbusData.quantity, 2);
    const response = await this.request(unitIdArg, functionCode, body);
    assertModbusFunction(response, functionCode, `read coil ${coilArg.address}`);

    if (functionCode === 1 || functionCode === 2) {
      const byteCount = response[1];
      const bytes = response.subarray(2, 2 + byteCount);
      const values: number[] = [];
      for (let index = 0; index < modbusData.quantity; index++) {
        values.push((bytes[Math.floor(index / 8)] & (1 << (index % 8))) ? 1 : 0);
      }
      return values;
    }

    const byteCount = response[1];
    if (byteCount !== modbusData.quantity * 2) {
      throw new Error(`NIBE Modbus read coil ${coilArg.address} returned ${byteCount} bytes, expected ${modbusData.quantity * 2}.`);
    }
    const registers: number[] = [];
    for (let offset = 2; offset < 2 + byteCount; offset += 2) {
      registers.push(response.readUInt16BE(offset));
    }
    return registers;
  }

  public async writeCoil(unitIdArg: number, coilArg: INibeHeatpumpCoilConfig, valuesArg: number[]): Promise<void> {
    const modbusData = splitModbusData(coilArg);
    if (modbusData.entityType === 4) {
      const body = Buffer.alloc(5 + valuesArg.length * 2);
      body.writeUInt16BE(modbusData.startingAddress, 0);
      body.writeUInt16BE(valuesArg.length, 2);
      body.writeUInt8(valuesArg.length * 2, 4);
      valuesArg.forEach((valueArg, indexArg) => body.writeUInt16BE(valueArg & 0xffff, 5 + indexArg * 2));
      const response = await this.request(unitIdArg, 16, body);
      assertModbusFunction(response, 16, `write coil ${coilArg.address}`);
      return;
    }

    if (modbusData.entityType === 0) {
      const byteCount = Math.ceil(valuesArg.length / 8);
      const body = Buffer.alloc(5 + byteCount);
      body.writeUInt16BE(modbusData.startingAddress, 0);
      body.writeUInt16BE(valuesArg.length, 2);
      body.writeUInt8(byteCount, 4);
      valuesArg.forEach((valueArg, indexArg) => {
        if (valueArg) {
          body[5 + Math.floor(indexArg / 8)] |= 1 << (indexArg % 8);
        }
      });
      const response = await this.request(unitIdArg, 15, body);
      assertModbusFunction(response, 15, `write coil ${coilArg.address}`);
      return;
    }

    throw new Error(`NIBE Modbus writes only support holding register (4xxxx) or coil (0xxxx) addresses, not ${coilArg.address}.`);
  }

  private async request(unitIdArg: number, functionCodeArg: number, bodyArg: Buffer): Promise<Buffer> {
    const transactionId = this.nextTransactionId();
    const pdu = Buffer.concat([Buffer.from([functionCodeArg]), bodyArg]);
    const request = Buffer.alloc(7 + pdu.length);
    request.writeUInt16BE(transactionId, 0);
    request.writeUInt16BE(0, 2);
    request.writeUInt16BE(1 + pdu.length, 4);
    request.writeUInt8(unitIdArg, 6);
    pdu.copy(request, 7);

    return new Promise<Buffer>((resolve, reject) => {
      const chunks: Buffer[] = [];
      let settled = false;
      const socket = plugins.net.createConnection({ host: this.host, port: this.port });
      const timer = setTimeout(() => finish(new Error(`NIBE Modbus TCP request to ${this.host}:${this.port} timed out after ${this.timeoutMs}ms.`)), this.timeoutMs);

      const finish = (errorArg?: Error) => {
        if (settled) {
          return;
        }
        settled = true;
        clearTimeout(timer);
        socket.removeAllListeners();
        socket.destroy();
        if (errorArg) {
          reject(errorArg);
          return;
        }
        const response = Buffer.concat(chunks);
        if (response.length < 8) {
          reject(new Error('NIBE Modbus TCP response was too short.'));
          return;
        }
        const responseTransactionId = response.readUInt16BE(0);
        const protocolId = response.readUInt16BE(2);
        const length = response.readUInt16BE(4);
        if (responseTransactionId !== transactionId || protocolId !== 0 || response.length < 6 + length) {
          reject(new Error('NIBE Modbus TCP response header did not match the request.'));
          return;
        }
        resolve(response.subarray(7, 6 + length));
      };

      socket.on('connect', () => socket.write(request));
      socket.on('data', (chunkArg) => {
        chunks.push(Buffer.isBuffer(chunkArg) ? chunkArg : Buffer.from(chunkArg));
        const response = Buffer.concat(chunks);
        if (response.length >= 6) {
          const length = response.readUInt16BE(4);
          if (response.length >= 6 + length) {
            finish();
          }
        }
      });
      socket.on('error', (errorArg) => finish(errorArg));
      socket.on('close', () => chunks.length ? finish() : finish(new Error('NIBE Modbus TCP connection closed before a response was received.')));
    });
  }

  private nextTransactionId(): number {
    this.transactionId = (this.transactionId + 1) & 0xffff;
    return this.transactionId || 1;
  }
}

const splitModbusData = (coilArg: INibeHeatpumpCoilConfig): { entityType: number; startingAddress: number; quantity: number } => {
  const entityType = Math.floor(coilArg.address / 10000);
  const startingAddress = (coilArg.address % 10000) - 1;
  if (startingAddress < 0) {
    throw new Error(`NIBE coil address ${coilArg.address} is invalid.`);
  }
  return { entityType, startingAddress, quantity: coilArg.size === 's32' || coilArg.size === 'u32' ? 2 : 1 };
};

const readFunctionCode = (entityTypeArg: number): number => {
  if (entityTypeArg === 0) return 1;
  if (entityTypeArg === 1) return 2;
  if (entityTypeArg === 3) return 4;
  if (entityTypeArg === 4) return 3;
  throw new Error(`Unsupported NIBE Modbus entity type ${entityTypeArg}.`);
};

const assertModbusFunction = (responseArg: Buffer, functionCodeArg: number, actionArg: string): void => {
  const returnedFunction = responseArg[0];
  if (returnedFunction === (functionCodeArg | 0x80)) {
    throw new Error(`NIBE Modbus ${actionArg} returned exception code ${responseArg[1]}.`);
  }
  if (returnedFunction !== functionCodeArg) {
    throw new Error(`NIBE Modbus ${actionArg} returned function ${returnedFunction}, expected ${functionCodeArg}.`);
  }
};

const decodeCoilValue = (coilArg: INibeHeatpumpCoilConfig, registersArg: number[], wordSwapArg: boolean): unknown => {
  const rawValue = rawValueFromRegisters(coilArg.size, registersArg, wordSwapArg);
  if (isIntegerLimit(coilArg.size, rawValue)) {
    return null;
  }
  if (coilArg.type === 'date') {
    return new Date(Date.UTC(2007, 0, 1 + rawValue)).toISOString().slice(0, 10);
  }
  if (coilArg.mappings) {
    return coilArg.mappings[String(rawValue)] ?? `UNKNOWN (${rawValue})`;
  }
  const factor = coilArg.factor || 1;
  return factor === 1 ? rawValue : rawValue / factor;
};

const encodeCoilValue = (coilArg: INibeHeatpumpCoilConfig, valueArg: unknown, wordSwapArg: boolean): number[] => {
  if (Math.floor(coilArg.address / 10000) === 0) {
    return [boolValue(valueArg) ? 1 : 0];
  }
  const rawValue = rawValueForWrite(coilArg, valueArg);
  return registersFromRawValue(coilArg.size, rawValue, wordSwapArg);
};

const rawValueFromRegisters = (sizeArg: TNibeHeatpumpCoilSize, registersArg: number[], wordSwapArg: boolean): number => {
  const signed = sizeArg === 's32' || sizeArg === 's16' || sizeArg === 's8';
  const ordered = sizeArg === 's32' || sizeArg === 'u32'
    ? wordSwapArg ? registersArg : [...registersArg].reverse()
    : registersArg;
  const rawBytes = Buffer.from(ordered.flatMap((valueArg) => [valueArg & 0xff, (valueArg >> 8) & 0xff]));
  return signed ? rawBytes.readIntLE(0, rawBytes.length) : rawBytes.readUIntLE(0, rawBytes.length);
};

const registersFromRawValue = (sizeArg: TNibeHeatpumpCoilSize, rawValueArg: number, wordSwapArg: boolean): number[] => {
  const signed = sizeArg === 's32' || sizeArg === 's16' || sizeArg === 's8';
  const bytes = littleEndianBytes(rawValueArg, signed);
  if (sizeArg === 's32' || sizeArg === 'u32') {
    const values = [bytes.readUInt16LE(0), bytes.readUInt16LE(2)];
    return wordSwapArg ? values : values.reverse();
  }
  return [bytes.readUInt16LE(0)];
};

const littleEndianBytes = (valueArg: number, signedArg: boolean): Buffer => {
  let value = BigInt(Math.trunc(valueArg));
  if (signedArg && value < 0n) {
    value = (1n << 64n) + value;
  }
  const bytes = Buffer.alloc(8);
  for (let index = 0; index < 8; index++) {
    bytes[index] = Number((value >> BigInt(index * 8)) & 0xffn);
  }
  return bytes;
};

const rawValueForWrite = (coilArg: INibeHeatpumpCoilConfig, valueArg: unknown): number => {
  if (isBooleanCoil(coilArg) && typeof valueArg === 'boolean') {
    return valueArg ? 1 : 0;
  }
  if (coilArg.mappings) {
    const requested = String(valueArg).toUpperCase();
    const found = Object.entries(coilArg.mappings).find(([, value]) => value.toUpperCase() === requested);
    if (!found) {
      throw new Error(`NIBE Heat Pump value ${String(valueArg)} is not a valid mapping for ${coilArg.name}.`);
    }
    return Number(found[0]);
  }
  const value = numberValue(valueArg);
  if (value === undefined) {
    throw new Error(`NIBE Heat Pump value for ${coilArg.name} must be numeric.`);
  }
  return Math.round(value * (coilArg.factor || 1));
};

const isIntegerLimit = (sizeArg: TNibeHeatpumpCoilSize, valueArg: number): boolean => {
  if (sizeArg === 'u8') return valueArg >= 0xff;
  if (sizeArg === 's8') return valueArg <= -0x80;
  if (sizeArg === 'u16') return valueArg >= 0xffff;
  if (sizeArg === 's16') return valueArg <= -0x8000;
  if (sizeArg === 'u32') return valueArg >= 0xffffffff;
  return valueArg <= -0x80000000;
};

const isBooleanCoil = (coilArg: INibeHeatpumpCoilConfig): boolean => {
  const mappingKeys = coilArg.mappings ? Object.keys(coilArg.mappings) : [];
  return (coilArg.factor ?? 1) === 1 && (mappingKeys.length ? mappingKeys.every((keyArg) => keyArg === '0' || keyArg === '1') : coilArg.min === 0 && coilArg.max === 1);
};

const boolValue = (valueArg: unknown): boolean => {
  if (typeof valueArg === 'boolean') {
    return valueArg;
  }
  if (typeof valueArg === 'number') {
    return valueArg !== 0;
  }
  const value = stringValue(valueArg)?.toLowerCase();
  return value === 'true' || value === 'on' || value === '1';
};

const stringValue = (valueArg: unknown): string | undefined => {
  return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
};

const numberValue = (valueArg: unknown): number | undefined => {
  if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
    return valueArg;
  }
  if (typeof valueArg === 'string' && valueArg.trim() && Number.isFinite(Number(valueArg))) {
    return Number(valueArg);
  }
  return undefined;
};

const safeUrl = (valueArg: string | undefined): URL | undefined => {
  if (!valueArg) {
    return undefined;
  }
  try {
    return new URL(valueArg);
  } catch {
    return undefined;
  }
};

const cleanAttributes = (attributesArg: Record<string, unknown | undefined>): Record<string, unknown> | undefined => {
  const clean = Object.fromEntries(Object.entries(attributesArg).filter(([, valueArg]) => valueArg !== undefined));
  return Object.keys(clean).length ? clean : undefined;
};

const errorMessage = (errorArg: unknown): string => {
  return errorArg instanceof Error ? errorArg.message : String(errorArg);
};
