import * as crypto from 'node:crypto';
import { DlinkMapper } from './dlink.mapper.js';
import { dlinkDefaultPort, dlinkDefaultTimeoutMs, dlinkDefaultUsername, dlinkDomain } from './dlink.constants.js';
import type { IDlinkCommand, IDlinkCommandResult, IDlinkConfig, IDlinkEvent, IDlinkNativeClient, IDlinkSnapshot, TDlinkPowerState } from './dlink.types.js';

type TDlinkEventHandler = (eventArg: IDlinkEvent) => void;
type TDlinkAuthSession = { privateKey: string; cookie: string };

export class DlinkClient {
  private snapshot?: IDlinkSnapshot;
  private readonly events: IDlinkEvent[] = [];
  private readonly eventHandlers = new Set<TDlinkEventHandler>();

  constructor(private readonly config: IDlinkConfig) {}

  public async getSnapshot(): Promise<IDlinkSnapshot> {
    if (!this.snapshot) {
      const result = await this.refresh();
      if (!result.success && result.data && typeof result.data === 'object' && 'snapshot' in result.data) {
        return this.cloneSnapshot((result.data as { snapshot: IDlinkSnapshot }).snapshot);
      }
    }
    return this.cloneSnapshot(this.snapshot || DlinkMapper.toSnapshot(this.config, {}, this.events));
  }

  public onEvent(handlerArg: TDlinkEventHandler): () => void {
    this.eventHandlers.add(handlerArg);
    return () => this.eventHandlers.delete(handlerArg);
  }

  public async refresh(): Promise<IDlinkCommandResult> {
    const base = DlinkMapper.toSnapshot(this.config, {}, this.events);
    try {
      const snapshot = await this.readSnapshot(base);
      this.snapshot = snapshot;
      this.emit({ type: 'snapshot_refreshed', data: this.cloneSnapshot(snapshot), timestamp: Date.now() });
      return { success: snapshot.connected, transmitted: false, data: { snapshot: this.cloneSnapshot(snapshot) } };
    } catch (errorArg) {
      const snapshot = DlinkMapper.mergeSnapshot(base, {
        connected: false,
        switch: { ...base.switch, state: 'unknown', available: false },
        device: { ...base.device, available: false },
        metadata: { ...base.metadata, lastError: errorArg instanceof Error ? errorArg.message : String(errorArg) },
      });
      this.snapshot = snapshot;
      const error = errorArg instanceof Error ? errorArg.message : String(errorArg);
      this.emit({ type: 'command_failed', data: { snapshot: this.cloneSnapshot(snapshot) }, error, timestamp: Date.now() });
      return { success: false, transmitted: false, error, data: { snapshot: this.cloneSnapshot(snapshot) } };
    }
  }

  public async sendCommand(commandArg: IDlinkCommand): Promise<IDlinkCommandResult> {
    this.emit({ type: 'command_mapped', command: commandArg, deviceId: commandArg.deviceId, entityId: commandArg.entityId, timestamp: Date.now() });
    try {
      const result = await this.executeCommand(commandArg);
      if (result.success) {
        this.patchSnapshot(commandArg, result);
      }
      this.emit({
        type: result.success ? 'command_succeeded' : 'command_failed',
        command: commandArg,
        deviceId: commandArg.deviceId,
        entityId: commandArg.entityId,
        data: result,
        error: result.error,
        timestamp: Date.now(),
      });
      return result;
    } catch (errorArg) {
      const result: IDlinkCommandResult = { success: false, transmitted: false, error: errorArg instanceof Error ? errorArg.message : String(errorArg), data: { command: commandArg } };
      this.emit({ type: 'command_failed', command: commandArg, deviceId: commandArg.deviceId, entityId: commandArg.entityId, data: result, error: result.error, timestamp: Date.now() });
      return result;
    }
  }

  public async destroy(): Promise<void> {
    this.eventHandlers.clear();
  }

  private async readSnapshot(baseArg: IDlinkSnapshot): Promise<IDlinkSnapshot> {
    if (this.config.transport?.readSnapshot) {
      return DlinkMapper.mergeSnapshot(baseArg, await this.config.transport.readSnapshot());
    }
    if (this.config.nativeClient) {
      return this.readNativeClientSnapshot(baseArg, this.config.nativeClient);
    }
    if (this.canUseNativeHttp()) {
      const snapshot = await this.hnapClient().readSnapshot();
      return DlinkMapper.mergeSnapshot(baseArg, snapshot);
    }
    return baseArg;
  }

  private async readNativeClientSnapshot(baseArg: IDlinkSnapshot, clientArg: IDlinkNativeClient): Promise<IDlinkSnapshot> {
    if (clientArg.getSnapshot) {
      return DlinkMapper.mergeSnapshot(baseArg, await clientArg.getSnapshot());
    }

    const authenticated = clientArg.authenticate ? await clientArg.authenticate() : undefined;
    const state = clientArg.getState ? DlinkMapper.normalizeState(await clientArg.getState()) : baseArg.switch.state;
    const [modelName, temperature, currentConsumption, totalConsumption] = await Promise.all([
      clientArg.getModelName?.(),
      clientArg.getTemperature?.(),
      clientArg.getCurrentConsumption?.(),
      clientArg.getTotalConsumption?.(),
    ]);
    const available = state !== 'unknown' || authenticated === true;
    return DlinkMapper.mergeSnapshot(baseArg, {
      connected: available,
      device: { modelName, authenticated, available },
      switch: {
        ...baseArg.switch,
        state,
        available,
        temperature: DlinkMapper.numberValue(temperature) ?? null,
        temperatureRaw: stringValue(temperature),
        currentConsumption: DlinkMapper.numberValue(currentConsumption) ?? null,
        currentConsumptionRaw: stringValue(currentConsumption),
        totalConsumption: DlinkMapper.numberValue(totalConsumption) ?? null,
        totalConsumptionRaw: stringValue(totalConsumption),
      },
      updatedAt: new Date().toISOString(),
    });
  }

  private async executeCommand(commandArg: IDlinkCommand): Promise<IDlinkCommandResult> {
    const executor = this.config.commandExecutor || this.config.transport?.execute.bind(this.config.transport);
    if (executor) {
      return this.commandResult(await executor({ ...commandArg, method: 'executor' }), commandArg, true);
    }
    if (this.config.nativeClient?.setState) {
      return this.commandResult(await this.config.nativeClient.setState(commandArg.state), { ...commandArg, method: 'native_client' }, true);
    }
    if (this.canUseNativeHttp()) {
      const response = await this.hnapClient().setState(commandArg.state);
      if (response === undefined) {
        return {
          success: false,
          transmitted: false,
          state: commandArg.state,
          error: 'D-Link HNAP SetSocketSettings did not return a result.',
          data: { command: commandArg },
        };
      }
      return this.commandResult(response, commandArg, true);
    }
    return {
      success: false,
      transmitted: false,
      error: 'D-Link switch commands require host/password native HNAP HTTP, nativeClient.setState, commandExecutor, or transport.execute.',
      data: { command: commandArg },
    };
  }

  private commandResult(resultArg: unknown, commandArg: IDlinkCommand, transmittedDefaultArg: boolean): IDlinkCommandResult {
    if (this.isCommandResult(resultArg)) {
      return { transmitted: transmittedDefaultArg, state: commandArg.state, ...resultArg };
    }
    if (resultArg === false) {
      return { success: false, transmitted: transmittedDefaultArg, state: commandArg.state, error: 'D-Link command executor reported failure.', data: { command: commandArg, response: resultArg } };
    }
    if (typeof resultArg === 'string' && resultArg.trim().toLowerCase() === 'error') {
      return { success: false, transmitted: transmittedDefaultArg, state: commandArg.state, error: 'D-Link HNAP SetSocketSettings returned error.', data: { command: commandArg, response: resultArg } };
    }
    return { success: true, transmitted: transmittedDefaultArg, state: commandArg.state, data: { command: commandArg, response: resultArg } };
  }

  private isCommandResult(valueArg: unknown): valueArg is IDlinkCommandResult {
    return Boolean(valueArg && typeof valueArg === 'object' && 'success' in valueArg);
  }

  private patchSnapshot(commandArg: IDlinkCommand, resultArg: IDlinkCommandResult): void {
    const current = this.snapshot || DlinkMapper.toSnapshot(this.config, {}, this.events);
    const snapshot = DlinkMapper.mergeSnapshot(current, {
      connected: true,
      device: { available: true },
      switch: { ...current.switch, state: resultArg.state || commandArg.state, available: true },
      updatedAt: new Date().toISOString(),
    });
    this.snapshot = snapshot;
    this.emit({ type: 'state_changed', command: commandArg, deviceId: commandArg.deviceId, entityId: commandArg.entityId, data: this.cloneSnapshot(snapshot), timestamp: Date.now() });
  }

  private canUseNativeHttp(): boolean {
    return this.config.nativeHttpEnabled !== false && Boolean(this.config.host && this.config.password);
  }

  private hnapClient(): DlinkHnapSmartPlugClient {
    return new DlinkHnapSmartPlugClient({
      host: this.config.host as string,
      port: this.config.port || dlinkDefaultPort,
      username: this.config.username || dlinkDefaultUsername,
      password: this.config.password as string,
      useLegacyProtocol: this.config.useLegacyProtocol ?? this.config.use_legacy_protocol ?? false,
      timeoutMs: this.config.timeoutMs || dlinkDefaultTimeoutMs,
    });
  }

  private emit(eventArg: IDlinkEvent): void {
    this.events.push(eventArg);
    for (const handler of this.eventHandlers) {
      handler(eventArg);
    }
  }

  private cloneSnapshot(snapshotArg: IDlinkSnapshot): IDlinkSnapshot {
    return JSON.parse(JSON.stringify(snapshotArg)) as IDlinkSnapshot;
  }
}

export class DlinkHnapSmartPlugClient implements IDlinkNativeClient {
  private authenticated?: TDlinkAuthSession;

  constructor(private readonly options: {
    host: string;
    port?: number;
    username?: string;
    password: string;
    useLegacyProtocol?: boolean;
    timeoutMs?: number;
  }) {}

  public async authenticate(): Promise<boolean> {
    this.authenticated = await this.authenticateInternal();
    return Boolean(this.authenticated);
  }

  public async getSnapshot(): Promise<IDlinkSnapshot> {
    return this.readSnapshot();
  }

  public async readSnapshot(): Promise<IDlinkSnapshot> {
    const modelName = await this.getModelName().catch(() => undefined);
    const state = await this.getState();
    const available = state !== 'unknown';
    const [temperature, currentConsumption, totalConsumption] = available ? await Promise.all([
      this.getTemperature().catch(() => undefined),
      this.getCurrentConsumption().catch(() => undefined),
      this.getTotalConsumption().catch(() => undefined),
    ]) : [undefined, undefined, undefined];
    const updatedAt = new Date().toISOString();

    return {
      connected: available,
      device: {
        host: this.options.host,
        port: this.options.port || dlinkDefaultPort,
        name: modelName,
        modelName,
        useLegacyProtocol: this.options.useLegacyProtocol ?? false,
        authenticated: Boolean(this.authenticated),
        available,
      },
      switch: {
        state,
        available,
        temperature: DlinkMapper.numberValue(temperature) ?? null,
        temperatureRaw: stringValue(temperature),
        currentConsumption: DlinkMapper.numberValue(currentConsumption) ?? null,
        currentConsumptionRaw: stringValue(currentConsumption),
        totalConsumption: DlinkMapper.numberValue(totalConsumption) ?? null,
        totalConsumptionRaw: stringValue(totalConsumption),
      },
      updatedAt,
      events: [],
      metadata: {
        nativeHttpImplemented: true,
        source: 'hnap',
      },
    };
  }

  public async getModelName(): Promise<string | undefined> {
    return this.soapAction('GetDeviceSettings', 'ModelName');
  }

  public async getState(): Promise<TDlinkPowerState> {
    const response = await this.soapAction('GetSocketSettings', 'OPStatus', this.moduleParameters('1'));
    return DlinkMapper.normalizeState(response);
  }

  public async setState(stateArg: Exclude<TDlinkPowerState, 'unknown'>): Promise<string | undefined> {
    return this.soapAction('SetSocketSettings', 'SetSocketSettingsResult', this.controlParameters('1', stateArg === 'ON' ? 'true' : 'false'));
  }

  public async getTemperature(): Promise<string | undefined> {
    return this.soapAction('GetCurrentTemperature', 'CurrentTemperature', this.moduleParameters('3'));
  }

  public async getCurrentConsumption(): Promise<string | undefined> {
    if (this.options.useLegacyProtocol) {
      const legacyValues = await this.fetchLegacyCgi();
      return legacyValues['Meter Watt'] || 'N/A';
    }
    return this.soapAction('GetCurrentPowerConsumption', 'CurrentConsumption', this.moduleParameters('2'));
  }

  public async getTotalConsumption(): Promise<string | undefined> {
    if (this.options.useLegacyProtocol) {
      return 'N/A';
    }
    return this.soapAction('GetPMWarningThreshold', 'TotalConsumption', this.moduleParameters('2'));
  }

  private moduleParameters(moduleArg: string): string {
    return `<ModuleID>${escapeXml(moduleArg)}</ModuleID>`;
  }

  private controlParameters(moduleArg: string, statusArg: 'true' | 'false'): string {
    const base = `${this.moduleParameters(moduleArg)}<NickName>Socket 1</NickName><Description>Socket 1</Description><OPStatus>${statusArg}</OPStatus>`;
    return this.options.useLegacyProtocol ? `${base}<Controller>1</Controller>` : base;
  }

  private async soapAction(actionArg: string, responseElementArg: string, paramsArg = '', recursiveArg = false): Promise<string | undefined> {
    if (!this.authenticated) {
      this.authenticated = await this.authenticateInternal();
    }
    const auth = this.authenticated;
    if (!this.options.useLegacyProtocol) {
      this.authenticated = undefined;
    }
    if (!auth) {
      return undefined;
    }

    const timestamp = hnapTimestamp();
    const actionUrl = `"http://purenetworks.com/HNAP1/${actionArg}"`;
    const authKey = `${hmacMd5(auth.privateKey, `${timestamp}${actionUrl}`).toUpperCase()} ${timestamp}`;
    try {
      const xml = await this.post('/HNAP1/', requestBody(actionArg, paramsArg), {
        'Content-Type': 'text/xml; charset=utf-8',
        SOAPAction: actionUrl,
        HNAP_AUTH: authKey,
        Cookie: `uid=${auth.cookie}`,
      });
      return extractXmlElement(xml, responseElementArg);
    } catch (errorArg) {
      if (recursiveArg) {
        throw errorArg;
      }
      this.authenticated = undefined;
      return this.soapAction(actionArg, responseElementArg, paramsArg, true);
    }
  }

  private async authenticateInternal(): Promise<TDlinkAuthSession | undefined> {
    const initialXml = await this.post('/HNAP1/', initialAuthPayload(dlinkDefaultUsername), {
      'Content-Type': 'text/xml; charset=utf-8',
      SOAPAction: '"http://purenetworks.com/HNAP1/Login"',
    });
    const challenge = extractXmlElement(initialXml, 'Challenge');
    const cookie = extractXmlElement(initialXml, 'Cookie');
    const publicKey = extractXmlElement(initialXml, 'PublicKey');
    if (!challenge || !cookie || !publicKey) {
      return undefined;
    }

    const privateKey = hmacMd5(`${publicKey}${this.options.password}`, challenge).toUpperCase();
    const loginPassword = hmacMd5(privateKey, challenge).toUpperCase();
    const loginXml = await this.post('/HNAP1/', loginPayload(this.options.username || dlinkDefaultUsername, loginPassword), {
      'Content-Type': 'text/xml; charset=utf-8',
      SOAPAction: '"http://purenetworks.com/HNAP1/Login"',
      HNAP_AUTH: `"${privateKey}"`,
      Cookie: `uid=${cookie}`,
    });
    const loginStatus = extractXmlElement(loginXml, 'LoginResult');
    return loginStatus?.toLowerCase() === 'success' ? { privateKey, cookie } : undefined;
  }

  private async fetchLegacyCgi(): Promise<Record<string, string>> {
    const text = await this.post('/my_cgi.cgi', 'request=create_chklst', { 'Content-Type': 'application/x-www-form-urlencoded' });
    const result: Record<string, string> = {};
    for (const line of text.split(/\r?\n/)) {
      const separatorIndex = line.indexOf(':');
      if (separatorIndex === -1) {
        continue;
      }
      result[line.slice(0, separatorIndex).trim()] = line.slice(separatorIndex + 1).trim();
    }
    return result;
  }

  private async post(pathArg: string, bodyArg: string, headersArg: Record<string, string>): Promise<string> {
    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), this.options.timeoutMs || dlinkDefaultTimeoutMs);
    try {
      const response = await fetch(this.url(pathArg), {
        method: 'POST',
        headers: headersArg,
        body: bodyArg,
        signal: controller.signal,
      });
      if (!response.ok) {
        throw new Error(`D-Link HTTP ${response.status} for ${pathArg}`);
      }
      return await response.text();
    } finally {
      clearTimeout(timer);
    }
  }

  private url(pathArg: string): string {
    const base = /^https?:\/\//i.test(this.options.host) ? new URL(this.options.host) : new URL(`http://${this.options.host}`);
    if (!/^https?:\/\//i.test(this.options.host) && this.options.port && this.options.port !== dlinkDefaultPort) {
      base.port = String(this.options.port);
    }
    base.pathname = pathArg;
    base.search = '';
    return base.toString();
  }
}

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

const hnapTimestamp = (): string => String(Math.round(Date.now() / 1000 / 1_000_000));

const hmacMd5 = (keyArg: string, valueArg: string): string => {
  return crypto.createHmac('md5', keyArg).update(valueArg).digest('hex');
};

const requestBody = (actionArg: string, paramsArg: string): string => `<?xml version="1.0" encoding="UTF-8"?>
<soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Body><${actionArg} xmlns="http://purenetworks.com/HNAP1/">${paramsArg}</${actionArg}></soap:Body>
</soap:Envelope>`;

const initialAuthPayload = (usernameArg: string): string => `<?xml version="1.0" encoding="utf-8"?>
<soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Body><Login xmlns="http://purenetworks.com/HNAP1/"><Action>request</Action><Username>${escapeXml(usernameArg)}</Username><LoginPassword/><Captcha/></Login></soap:Body>
</soap:Envelope>`;

const loginPayload = (usernameArg: string, loginPasswordArg: string): string => `<?xml version="1.0" encoding="utf-8"?>
<soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Body><Login xmlns="http://purenetworks.com/HNAP1/"><Action>login</Action><Username>${escapeXml(usernameArg)}</Username><LoginPassword>${escapeXml(loginPasswordArg)}</LoginPassword><Captcha/></Login></soap:Body>
</soap:Envelope>`;

const extractXmlElement = (xmlArg: string, elementArg: string): string | undefined => {
  const escapedElement = elementArg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  const regex = new RegExp(`<(?:[\\w.-]+:)?${escapedElement}(?:\\s[^>]*)?>([\\s\\S]*?)</(?:[\\w.-]+:)?${escapedElement}>`, 'i');
  const match = regex.exec(xmlArg);
  return match ? unescapeXml(match[1].trim()) : undefined;
};

const escapeXml = (valueArg: string): string => {
  return valueArg.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&apos;');
};

const unescapeXml = (valueArg: string): string => {
  return valueArg.replace(/&apos;/g, "'").replace(/&quot;/g, '"').replace(/&gt;/g, '>').replace(/&lt;/g, '<').replace(/&amp;/g, '&');
};

export const dlinkTestInternals = {
  domain: dlinkDomain,
  extractXmlElement,
  hmacMd5,
  hnapTimestamp,
};
