import * as plugins from '../../plugins.js';
import { SimpleLocalClient, type ISimpleLocalSnapshot, type IServiceCallRequest, type IServiceCallResult } from '../../core/index.js';
import { NoboHubMapper } from './nobo_hub.mapper.js';
import type { INoboHubApiData, INoboHubConfig } from './nobo_hub.types.js';
import { noboHubDefaultPort, noboHubDefaultTimeoutMs, noboHubProfile } from './nobo_hub.types.js';

export class NoboHubApiError extends Error {}
export class NoboHubApiConnectionError extends NoboHubApiError {}
export class NoboHubApiAuthenticationError extends NoboHubApiError {}
export class NoboHubApiUnsupportedError extends NoboHubApiError {}

const noboApiVersion = '1.1';
const noboCommandHello = 'HELLO';
const noboCommandHandshake = 'HANDSHAKE';
const noboCommandReject = 'REJECT';
const noboCommandGetAll = 'G00';
const noboResponseStartAll = 'H00';
const noboResponseZone = 'H01';
const noboResponseComponent = 'H02';
const noboResponseWeekProfile = 'H03';
const noboResponseOverride = 'H04';
const noboResponseHubInfo = 'H05';
const noboResponseTemperature = 'Y02';
const noboCommandAddOverride = 'A03';
const noboCommandUpdateZone = 'U00';

const zoneKeys = ['zone_id', 'name', 'week_profile_id', 'temp_comfort_c', 'temp_eco_c', 'override_allowed', 'deprecated_override_id'];
const componentKeys = ['serial', 'status', 'name', 'reverse_onoff', 'zone_id', 'override_id', 'tempsensor_for_zone_id'];
const weekProfileKeys = ['week_profile_id', 'name', 'profile'];
const overrideKeys = ['override_id', 'mode', 'type', 'end_time', 'start_time', 'target_type', 'target_id'];
const hubKeys = ['serial', 'name', 'default_away_override_length', 'override_id', 'software_version', 'hardware_version', 'production_date'];
const overrideModes: Record<string, string> = { normal: '0', none: '0', comfort: '1', eco: '2', away: '3', heat: '1', auto: '0' };

export class NoboHubClient extends SimpleLocalClient<INoboHubConfig> {
  private cachedSnapshot?: ISimpleLocalSnapshot;

  constructor(configArg: INoboHubConfig) {
    super(noboHubProfile, configArg);
    this.configArg = configArg;
  }

  public async getSnapshot(forceRefreshArg = false): Promise<ISimpleLocalSnapshot> {
    if (!forceRefreshArg && this.cachedSnapshot) {
      return this.clone(this.cachedSnapshot);
    }

    if (!forceRefreshArg && (this.configArg.client || this.configArg.snapshotProvider || this.hasManualData())) {
      this.cachedSnapshot = await super.getSnapshot(forceRefreshArg);
      return this.clone(this.cachedSnapshot);
    }

    if (this.hasEndpoint()) {
      try {
        this.cachedSnapshot = await this.fetchSnapshot();
      } catch (errorArg) {
        this.cachedSnapshot = this.offlineNoboSnapshot(this.errorMessage(errorArg));
      }
      return this.clone(this.cachedSnapshot);
    }

    this.cachedSnapshot = await super.getSnapshot(forceRefreshArg);
    return this.clone(this.cachedSnapshot);
  }

  public async refresh(): Promise<IServiceCallResult> {
    this.cachedSnapshot = undefined;
    const snapshot = await this.getSnapshot(Boolean(this.hasEndpoint()));
    const success = snapshot.online && !snapshot.error && snapshot.source !== 'runtime';
    return { success, error: success ? undefined : snapshot.error || 'Nob Ecohub refresh requires config.host plus full 12-digit config.serial, injected client, snapshotProvider, snapshot, or rawData.', data: snapshot };
  }

  public async fetchSnapshot(): Promise<ISimpleLocalSnapshot> {
    const rawData = await this.fetchRawData();
    return NoboHubMapper.toSnapshot({ config: this.configArg, rawData, online: true, source: 'tcp' });
  }

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

    try {
      const data = await this.executeNative(requestArg);
      this.cachedSnapshot = undefined;
      return { success: true, data };
    } catch (errorArg) {
      return { success: false, error: this.errorMessage(errorArg) };
    }
  }

  private readonly configArg: INoboHubConfig;

  private async fetchRawData(): Promise<INoboHubApiData> {
    const session = new NoboHubTcpSession(this.configArg);
    try {
      await session.connect();
      return await session.getAllInfo();
    } finally {
      session.close();
    }
  }

  private async executeNative(requestArg: IServiceCallRequest): Promise<unknown> {
    const session = new NoboHubTcpSession(this.configArg);
    try {
      await session.connect();
      const rawData = await session.getAllInfo();
      const command = this.commandForService(rawData, requestArg);
      const response = await session.sendCommand(command, true);
      return { command, response };
    } finally {
      session.close();
    }
  }

  private commandForService(rawDataArg: INoboHubApiData, requestArg: IServiceCallRequest): string[] {
    if (requestArg.service === 'set_temperature') {
      const zoneId = this.zoneIdForRequest(requestArg);
      const temperature = this.numberValue(requestArg.data?.temperature ?? requestArg.data?.value);
      if (!zoneId || temperature === undefined) {
        throw new NoboHubApiUnsupportedError('Nob Ecohub set_temperature requires data.zoneId and data.temperature.');
      }
      return this.updateZoneCommand(rawDataArg, zoneId, { temp_comfort_c: String(Math.round(temperature)) });
    }

    if (requestArg.service === 'set_hvac_mode') {
      const mode = this.stringValue(requestArg.data?.hvac_mode ?? requestArg.data?.hvacMode ?? requestArg.data?.mode)?.toLowerCase();
      const overrideMode = mode ? overrideModes[mode] : undefined;
      if (!overrideMode) {
        throw new NoboHubApiUnsupportedError('Nob Ecohub set_hvac_mode supports auto or heat.');
      }
      return this.overrideCommand(requestArg, overrideMode);
    }

    if (requestArg.service === 'select_option') {
      const option = this.stringValue(requestArg.data?.option ?? requestArg.data?.value)?.toLowerCase();
      if (!option) {
        throw new NoboHubApiUnsupportedError('Nob Ecohub select_option requires data.option.');
      }
      const overrideMode = overrideModes[option];
      if (overrideMode !== undefined) {
        return this.overrideCommand(requestArg, overrideMode);
      }
      const zoneId = this.zoneIdForRequest(requestArg);
      if (!zoneId) {
        throw new NoboHubApiUnsupportedError('Nob Ecohub week profile selection requires data.zoneId.');
      }
      const profileId = this.weekProfileId(rawDataArg, option);
      if (!profileId) {
        throw new NoboHubApiUnsupportedError(`Unknown Nob Ecohub week profile option: ${option}.`);
      }
      return this.updateZoneCommand(rawDataArg, zoneId, { week_profile_id: profileId });
    }

    throw new NoboHubApiUnsupportedError(`Unsupported Nob Ecohub service: ${requestArg.domain}.${requestArg.service}`);
  }

  private overrideCommand(requestArg: IServiceCallRequest, modeArg: string): string[] {
    const zoneId = this.zoneIdForRequest(requestArg);
    const targetType = zoneId ? '1' : '0';
    const targetId = zoneId || '-1';
    return [noboCommandAddOverride, '1', modeArg, this.overrideType(), '-1', '-1', targetType, targetId];
  }

  private updateZoneCommand(rawDataArg: INoboHubApiData, zoneIdArg: string, updatesArg: Record<string, string>): string[] {
    const zones = this.recordMap(rawDataArg.zones);
    const zone = zones[zoneIdArg];
    if (!zone) {
      throw new NoboHubApiUnsupportedError(`Unknown Nob Ecohub zone id: ${zoneIdArg}.`);
    }
    const updated = { ...zone, ...updatesArg };
    return [noboCommandUpdateZone, ...zoneKeys.map((keyArg) => this.commandValue(updated[keyArg]))];
  }

  private weekProfileId(rawDataArg: INoboHubApiData, optionArg: string): string | undefined {
    const profiles = this.recordMap(rawDataArg.weekProfiles);
    for (const profile of Object.values(profiles)) {
      const id = this.stringValue(profile.week_profile_id);
      const name = this.stringValue(profile.name)?.toLowerCase();
      if (id === optionArg || name === optionArg) {
        return id;
      }
    }
    return undefined;
  }

  private zoneIdForRequest(requestArg: IServiceCallRequest): string | undefined {
    const explicit = this.stringValue(requestArg.data?.zoneId ?? requestArg.data?.zone_id);
    if (explicit) {
      return explicit;
    }
    const target = `${requestArg.target.entityId || ''} ${requestArg.target.deviceId || ''}`;
    return target.match(/zone_([^._\s]+)/)?.[1];
  }

  private overrideType(): string {
    const configured = this.stringValue(this.configArg.overrideType)?.toLowerCase();
    if (configured === 'now') {
      return '0';
    }
    if (configured === 'constant' || !configured) {
      return '3';
    }
    return configured;
  }

  private hasEndpoint(): boolean {
    return Boolean(this.configArg.host && this.configArg.serial && this.configArg.serial.length === 12);
  }

  private hasManualData(): boolean {
    return Boolean(this.configArg.snapshot || this.configArg.rawData || this.configArg.rawText || this.configArg.entities);
  }

  private commandValue(valueArg: unknown): string {
    return String(valueArg ?? '-1').replace(/ /g, '\u00a0');
  }

  private recordMap(valueArg: unknown): Record<string, Record<string, unknown>> {
    const map = valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg) ? valueArg as Record<string, unknown> : {};
    return Object.fromEntries(Object.entries(map).filter((entryArg): entryArg is [string, Record<string, unknown>] => Boolean(entryArg[1] && typeof entryArg[1] === 'object' && !Array.isArray(entryArg[1]))));
  }

  private stringValue(valueArg: unknown): string | undefined {
    return typeof valueArg === 'string' && valueArg ? valueArg : typeof valueArg === 'number' && Number.isFinite(valueArg) ? String(valueArg) : undefined;
  }

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

  private offlineNoboSnapshot(errorArg: string): ISimpleLocalSnapshot {
    return NoboHubMapper.toSnapshot({ config: this.configArg, online: false, source: 'runtime', error: errorArg });
  }

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

  private clone<TValue>(valueArg: TValue): TValue {
    return JSON.parse(JSON.stringify(valueArg)) as TValue;
  }
}

class NoboHubTcpSession {
  private socket?: plugins.net.Socket;
  private buffer = '';
  private closed = false;

  constructor(private readonly config: INoboHubConfig) {}

  public async connect(): Promise<void> {
    const host = this.config.host;
    const serial = this.config.serial;
    if (!host || !serial || serial.length !== 12 || !/^\d+$/.test(serial)) {
      throw new NoboHubApiConnectionError('Nob Ecohub TCP API requires config.host and a full 12-digit config.serial.');
    }
    await this.openSocket(host, this.config.port || noboHubDefaultPort);
    await this.sendCommand([noboCommandHello, noboApiVersion, serial, this.timestamp()]);
    const hello = await this.readResponse();
    if (hello[0] === noboCommandReject) {
      throw new NoboHubApiAuthenticationError(`Nob Ecohub rejected HELLO: ${hello.join(' ')}`);
    }
    if (hello[0] !== noboCommandHello) {
      throw new NoboHubApiConnectionError(`Nob Ecohub unexpected HELLO response: ${hello.join(' ')}`);
    }
    await this.sendCommand([noboCommandHandshake]);
    const handshake = await this.readResponse();
    if (handshake[0] !== noboCommandHandshake) {
      throw new NoboHubApiConnectionError(`Nob Ecohub unexpected HANDSHAKE response: ${handshake.join(' ')}`);
    }
  }

  public async getAllInfo(): Promise<INoboHubApiData> {
    const rawData = emptyNoboData();
    await this.sendCommand([noboCommandGetAll]);
    while (true) {
      const response = await this.readResponse();
      this.applyResponse(rawData, response);
      if (response[0] === noboResponseHubInfo) {
        rawData.fetchedAt = new Date().toISOString();
        return rawData;
      }
    }
  }

  public async sendCommand(commandArg: string[], waitForResponseArg = false): Promise<string[] | undefined> {
    const socket = this.socket;
    if (!socket || this.closed) {
      throw new NoboHubApiConnectionError('Nob Ecohub TCP socket is not connected.');
    }
    socket.write(`${commandArg.join(' ')}\r`, 'utf8');
    if (!waitForResponseArg) {
      return undefined;
    }
    return this.readResponse();
  }

  public close(): void {
    this.closed = true;
    this.socket?.destroy();
    this.socket = undefined;
  }

  private async openSocket(hostArg: string, portArg: number): Promise<void> {
    await new Promise<void>((resolve, reject) => {
      const socket = plugins.net.createConnection({ host: this.hostForSocket(hostArg), port: portArg });
      this.socket = socket;
      socket.setNoDelay(true);
      const timeout = setTimeout(() => {
        cleanup();
        socket.destroy();
        reject(new NoboHubApiConnectionError(`Nob Ecohub TCP connection to ${hostArg}:${portArg} timed out.`));
      }, this.config.timeoutMs || noboHubDefaultTimeoutMs);
      const cleanup = () => {
        clearTimeout(timeout);
        socket.off('connect', onConnect);
        socket.off('error', onError);
      };
      const onConnect = () => {
        cleanup();
        resolve();
      };
      const onError = (errorArg: Error) => {
        cleanup();
        reject(new NoboHubApiConnectionError(`Nob Ecohub TCP connection failed: ${errorArg.message}`));
      };
      socket.once('connect', onConnect);
      socket.once('error', onError);
      socket.on('data', (chunkArg) => {
        this.buffer += Buffer.isBuffer(chunkArg) ? chunkArg.toString('utf8') : String(chunkArg);
      });
      socket.on('close', () => {
        this.closed = true;
      });
    });
  }

  private async readResponse(): Promise<string[]> {
    const existing = this.takeLine();
    if (existing) {
      return this.parseResponse(existing);
    }
    const socket = this.socket;
    if (!socket || this.closed) {
      throw new NoboHubApiConnectionError('Nob Ecohub TCP socket closed before response.');
    }
    return new Promise<string[]>((resolve, reject) => {
      const timeout = setTimeout(() => {
        cleanup();
        reject(new NoboHubApiConnectionError('Nob Ecohub TCP response timed out.'));
      }, this.config.timeoutMs || noboHubDefaultTimeoutMs);
      const cleanup = () => {
        clearTimeout(timeout);
        socket.off('data', onData);
        socket.off('error', onError);
        socket.off('close', onClose);
      };
      const finishIfLine = (): boolean => {
        const line = this.takeLine();
        if (!line) {
          return false;
        }
        cleanup();
        resolve(this.parseResponse(line));
        return true;
      };
      const onData = () => {
        finishIfLine();
      };
      const onError = (errorArg: Error) => {
        cleanup();
        reject(errorArg);
      };
      const onClose = () => {
        cleanup();
        reject(new NoboHubApiConnectionError('Nob Ecohub TCP socket closed before response.'));
      };
      socket.on('data', onData);
      socket.once('error', onError);
      socket.once('close', onClose);
      finishIfLine();
    });
  }

  private takeLine(): string | undefined {
    const index = this.buffer.indexOf('\r');
    if (index < 0) {
      return undefined;
    }
    const line = this.buffer.slice(0, index);
    this.buffer = this.buffer.slice(index + 1);
    return line;
  }

  private parseResponse(lineArg: string): string[] {
    return lineArg.split(' ').filter((partArg) => partArg.length > 0).map((partArg) => partArg.replace(/\u00a0/g, ' '));
  }

  private applyResponse(rawDataArg: INoboHubApiData, responseArg: string[]): void {
    const code = responseArg[0];
    if (code === noboResponseStartAll) {
      rawDataArg.hubInfo = {};
      rawDataArg.zones = {};
      rawDataArg.components = {};
      rawDataArg.weekProfiles = {};
      rawDataArg.overrides = {};
      rawDataArg.temperatures = rawDataArg.temperatures || {};
      return;
    }
    if (code === noboResponseZone) {
      const zone = this.objectFromKeys(zoneKeys, responseArg.slice(1));
      const id = String(zone.zone_id || '');
      if (id) {
        rawDataArg.zones![id] = zone;
      }
      return;
    }
    if (code === noboResponseComponent) {
      const component = this.objectFromKeys(componentKeys, responseArg.slice(1));
      const serial = String(component.serial || '');
      if (serial) {
        if (component.zone_id === '-1' && component.tempsensor_for_zone_id !== '-1') {
          component.zone_id = component.tempsensor_for_zone_id;
        }
        rawDataArg.components![serial] = component;
      }
      return;
    }
    if (code === noboResponseWeekProfile) {
      const profile = this.objectFromKeys(weekProfileKeys, responseArg.slice(1));
      const id = String(profile.week_profile_id || '');
      if (id) {
        profile.profile = String(profile.profile || '').split(',');
        rawDataArg.weekProfiles![id] = profile;
      }
      return;
    }
    if (code === noboResponseOverride) {
      const override = this.objectFromKeys(overrideKeys, responseArg.slice(1));
      const id = String(override.override_id || '');
      if (id) {
        rawDataArg.overrides![id] = override;
      }
      return;
    }
    if (code === noboResponseHubInfo) {
      rawDataArg.hubInfo = this.objectFromKeys(hubKeys, responseArg.slice(1));
      return;
    }
    if (code === noboResponseTemperature && responseArg[1]) {
      rawDataArg.temperatures![responseArg[1]] = responseArg[2];
    }
  }

  private objectFromKeys(keysArg: string[], valuesArg: string[]): Record<string, unknown> {
    return Object.fromEntries(keysArg.map((keyArg, indexArg) => [keyArg, valuesArg[indexArg]]));
  }

  private timestamp(): string {
    const now = new Date();
    const pad = (valueArg: number): string => String(valueArg).padStart(2, '0');
    return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
  }

  private hostForSocket(hostArg: string): string {
    if (!hostArg.includes('://')) {
      return hostArg;
    }
    return new URL(hostArg).hostname;
  }
}

const emptyNoboData = (): INoboHubApiData => ({
  hubInfo: {},
  zones: {},
  components: {},
  weekProfiles: {},
  overrides: {},
  temperatures: {},
});
