import * as plugins from '../../plugins.js';
import { SimpleLocalClient, SimpleLocalMapper, type IServiceCallResult } from '../../core/index.js';
import type { IKeeneticNdms2Config, IKeeneticNdms2Snapshot } from './keenetic_ndms2.types.js';
import { keeneticNdms2DefaultInterface, keeneticNdms2DefaultPort, keeneticNdms2DefaultTimeoutMs, keeneticNdms2Profile } from './keenetic_ndms2.types.js';

interface IKeeneticRouterInfo {
  name: string;
  fwVersion?: string;
  fwChannel?: string;
  model?: string;
  hwVersion?: string;
  manufacturer?: string;
  vendor?: string;
  region?: string;
}

interface IKeeneticDevice {
  mac: string;
  name?: string;
  ip?: string;
  interface?: string;
}

export class KeeneticNdms2Client extends SimpleLocalClient<IKeeneticNdms2Config> {
  private keeneticCurrentSnapshot?: IKeeneticNdms2Snapshot;

  constructor(private readonly configArg: IKeeneticNdms2Config) {
    super(keeneticNdms2Profile, configArg);
  }

  public async getSnapshot(forceRefreshArg = false): Promise<IKeeneticNdms2Snapshot> {
    if (!forceRefreshArg && this.keeneticCurrentSnapshot) {
      return SimpleLocalMapper.clone(this.keeneticCurrentSnapshot) as IKeeneticNdms2Snapshot;
    }

    if (!forceRefreshArg && this.hasStaticSnapshotSource()) {
      this.keeneticCurrentSnapshot = await super.getSnapshot(false) as IKeeneticNdms2Snapshot;
      return SimpleLocalMapper.clone(this.keeneticCurrentSnapshot) as IKeeneticNdms2Snapshot;
    }

    if (this.configArg.client || this.configArg.snapshotProvider) {
      this.keeneticCurrentSnapshot = await super.getSnapshot(forceRefreshArg) as IKeeneticNdms2Snapshot;
      return SimpleLocalMapper.clone(this.keeneticCurrentSnapshot) as IKeeneticNdms2Snapshot;
    }

    if (this.configArg.host && this.configArg.transport !== 'snapshot') {
      try {
        this.keeneticCurrentSnapshot = await this.readLiveSnapshot();
      } catch (errorArg) {
        this.keeneticCurrentSnapshot = this.offlineKeeneticSnapshot(this.errorMessage(errorArg));
      }
      return SimpleLocalMapper.clone(this.keeneticCurrentSnapshot) as IKeeneticNdms2Snapshot;
    }

    this.keeneticCurrentSnapshot = await super.getSnapshot(forceRefreshArg) as IKeeneticNdms2Snapshot;
    return SimpleLocalMapper.clone(this.keeneticCurrentSnapshot) as IKeeneticNdms2Snapshot;
  }

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

  private async readLiveSnapshot(): Promise<IKeeneticNdms2Snapshot> {
    const username = this.configArg.username;
    const password = this.configArg.password;
    if (!username || password === undefined) {
      throw new Error('Keenetic NDMS2 Telnet polling requires config.username and config.password.');
    }

    const session = await KeeneticTelnetSession.connect({
      host: this.host(),
      port: this.port(),
      username,
      password,
      timeoutMs: this.timeoutMs(),
    });
    try {
      const routerInfo = parseRouterInfo(await session.runCommand('show version'));
      const devices = await this.getDevices(session);
      return this.toSnapshot(routerInfo, devices);
    } finally {
      await session.close();
    }
  }

  private async getDevices(sessionArg: KeeneticTelnetSession): Promise<IKeeneticDevice[]> {
    let devices: IKeeneticDevice[] = [];
    if (this.configArg.tryHotspot ?? true) {
      devices = mergeDevices(devices, await this.getHotspotDevices(sessionArg));
      if (devices.length > 0) {
        return this.filterTrackedInterfaces(devices);
      }
    }
    if (this.configArg.includeArp ?? true) {
      devices = mergeDevices(devices, parseArpDevices(await sessionArg.runCommand('show ip arp')));
    }
    if (this.configArg.includeAssociated ?? true) {
      devices = mergeDevices(devices, await this.getAssociatedDevices(sessionArg));
    }
    return this.filterTrackedInterfaces(devices);
  }

  private async getHotspotDevices(sessionArg: KeeneticTelnetSession): Promise<IKeeneticDevice[]> {
    const hotspotInfo = await this.getHotspotInfo(sessionArg);
    return Object.values(hotspotInfo)
      .filter((infoArg) => Boolean(infoArg.interface && infoArg.link === 'up' && infoArg.mac))
      .map((infoArg) => ({
        mac: String(infoArg.mac).toUpperCase(),
        name: stringValue(infoArg.name),
        ip: stringValue(infoArg.ip),
        interface: objectValue(infoArg.interface) ? stringValue(objectValue(infoArg.interface)?.name) || '' : '',
      }));
  }

  private async getHotspotInfo(sessionArg: KeeneticTelnetSession): Promise<Record<string, Record<string, unknown>>> {
    const info = parseDictLines(await sessionArg.runCommand('show ip hotspot'));
    return asArray(info.host).reduce<Record<string, Record<string, unknown>>>((resultArg, itemArg) => {
      const item = objectValue(itemArg);
      const mac = stringValue(item?.mac);
      if (item && mac) {
        resultArg[mac] = item;
      }
      return resultArg;
    }, {});
  }

  private async getAssociatedDevices(sessionArg: KeeneticTelnetSession): Promise<IKeeneticDevice[]> {
    const associations = parseDictLines(await sessionArg.runCommand('show associations'));
    const stations = asArray(associations.station).map(objectValue).filter((valueArg): valueArg is Record<string, unknown> => Boolean(valueArg));
    const aps = [...new Set(stations.map((stationArg) => stringValue(stationArg.ap)).filter((valueArg): valueArg is string => Boolean(valueArg)))];
    const apToBridge: Record<string, string | undefined> = {};
    for (const ap of aps) {
      const apInfo = parseDictLines(await sessionArg.runCommand(`show interface ${ap}`));
      apToBridge[ap] = stringValue(apInfo.group) || stringValue(apInfo['interface-name']) || ap;
    }
    const hotspotInfo: Record<string, Record<string, unknown>> = await this.getHotspotInfo(sessionArg).catch(() => ({}));
    return stations
      .filter((stationArg) => {
        const authenticated = stringValue(stationArg.authenticated);
        return Boolean(stationArg.mac && (authenticated === '1' || authenticated === 'yes'));
      })
      .map((stationArg) => {
        const mac = String(stationArg.mac).toUpperCase();
        const hostInfo = hotspotInfo[String(stationArg.mac)] || hotspotInfo[mac];
        return {
          mac,
          name: stringValue(hostInfo?.name),
          ip: stringValue(hostInfo?.ip),
          interface: apToBridge[stringValue(stationArg.ap) || ''] || stringValue(stationArg.ap),
        };
      });
  }

  private filterTrackedInterfaces(devicesArg: IKeeneticDevice[]): IKeeneticDevice[] {
    const tracked = new Set(this.trackedInterfaces());
    return devicesArg.filter((deviceArg) => deviceArg.interface && tracked.has(deviceArg.interface));
  }

  private toSnapshot(routerInfoArg: IKeeneticRouterInfo, devicesArg: IKeeneticDevice[]): IKeeneticNdms2Snapshot {
    const name = this.configArg.name || routerInfoArg.name || this.host();
    const uniqueBase = this.configArg.uniqueId || this.configArg.deviceId || `${this.host()}:${this.port()}`;
    return {
      device: {
        id: uniqueBase,
        name,
        manufacturer: routerInfoArg.manufacturer || 'Keenetic',
        model: routerInfoArg.model || keeneticNdms2Profile.model,
        host: this.host(),
        port: this.port(),
        protocol: 'telnet',
        configurationUrl: `telnet://${this.host()}:${this.port()}`,
        attributes: {
          firmware: routerInfoArg.fwVersion,
          firmwareChannel: routerInfoArg.fwChannel,
          hardwareVersion: routerInfoArg.hwVersion,
          vendor: routerInfoArg.vendor,
          region: routerInfoArg.region,
          trackedInterfaces: this.trackedInterfaces(),
        },
      },
      entities: [
        {
          id: 'online',
          uniqueId: `${keeneticNdms2Profile.domain}_${SimpleLocalMapper.slug(uniqueBase)}_online`,
          name: 'Online',
          platform: 'binary_sensor',
          state: true,
          available: true,
          deviceClass: 'connectivity',
          attributes: { source: 'telnet' },
        },
        {
          id: 'tracked_clients',
          uniqueId: `${keeneticNdms2Profile.domain}_${SimpleLocalMapper.slug(uniqueBase)}_tracked_clients`,
          name: 'Tracked Clients',
          platform: 'sensor',
          state: devicesArg.length,
          available: true,
          attributes: { interfaces: this.trackedInterfaces() },
        },
        ...devicesArg.map((deviceArg) => ({
          id: `client_${SimpleLocalMapper.slug(deviceArg.mac)}`,
          uniqueId: `${keeneticNdms2Profile.domain}_${SimpleLocalMapper.slug(uniqueBase)}_${SimpleLocalMapper.slug(deviceArg.mac)}`,
          name: deviceArg.name || deviceArg.mac,
          platform: 'binary_sensor' as const,
          state: true,
          available: true,
          deviceClass: 'connectivity',
          attributes: {
            macAddress: deviceArg.mac,
            ipAddress: deviceArg.ip,
            interface: deviceArg.interface,
            homeAssistantPlatform: 'device_tracker',
          },
        })),
      ],
      online: true,
      updatedAt: new Date().toISOString(),
      source: 'tcp',
      rawData: {
        routerInfo: routerInfoArg,
        devices: devicesArg,
        trackedInterfaces: this.trackedInterfaces(),
      },
      capabilities: {
        localRead: true,
        localControl: false,
        liveControlRequiresExecutor: false,
      },
    };
  }

  private trackedInterfaces(): string[] {
    return this.configArg.interfaces?.length ? this.configArg.interfaces : [keeneticNdms2DefaultInterface];
  }

  private host(): string {
    const host = this.configArg.host;
    if (!host) {
      throw new Error('Keenetic NDMS2 Telnet client requires config.host.');
    }
    return host;
  }

  private port(): number {
    return this.configArg.port || keeneticNdms2DefaultPort;
  }

  private timeoutMs(): number {
    return this.configArg.timeoutMs || keeneticNdms2DefaultTimeoutMs;
  }

  private hasStaticSnapshotSource(): boolean {
    return Boolean(this.configArg.snapshot || this.configArg.rawData !== undefined || this.configArg.rawText !== undefined || this.configArg.entities?.length);
  }

  private offlineKeeneticSnapshot(errorArg: string): IKeeneticNdms2Snapshot {
    return SimpleLocalMapper.toSnapshot({ profile: keeneticNdms2Profile, config: this.configArg, online: false, source: 'runtime', error: errorArg }) as IKeeneticNdms2Snapshot;
  }

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

class KeeneticTelnetSession {
  private textBuffer = '';
  private prompt = '';
  private pending?: {
    matcher: string | RegExp;
    resolve: (valueArg: string) => void;
    reject: (errorArg: Error) => void;
    timer: ReturnType<typeof setTimeout>;
  };

  private constructor(
    private readonly socket: plugins.net.Socket,
    private readonly options: { host: string; port: number; username: string; password: string; timeoutMs: number },
  ) {}

  public static async connect(optionsArg: { host: string; port: number; username: string; password: string; timeoutMs: number }): Promise<KeeneticTelnetSession> {
    const socket = plugins.net.createConnection({ host: optionsArg.host, port: optionsArg.port });
    const session = new KeeneticTelnetSession(socket, optionsArg);
    await session.waitForConnect();
    socket.on('data', (chunkArg) => session.onData(Buffer.isBuffer(chunkArg) ? chunkArg : Buffer.from(chunkArg)));
    socket.on('error', (errorArg) => session.rejectPending(errorArg));
    socket.on('close', () => session.rejectPending(new Error(`Keenetic NDMS2 Telnet connection to ${optionsArg.host}:${optionsArg.port} closed.`)));
    await session.login();
    return session;
  }

  public async runCommand(commandArg: string): Promise<string[]> {
    this.textBuffer = '';
    this.socket.write(`${commandArg}\n`, 'utf8');
    const text = await this.waitFor(this.prompt || /\(\w[-\w]*\)>/);
    return this.responseLines(text, commandArg);
  }

  public async close(): Promise<void> {
    if (!this.socket.destroyed) {
      this.socket.write('exit\n', 'utf8');
      this.socket.destroy();
    }
  }

  private async waitForConnect(): Promise<void> {
    await new Promise<void>((resolve, reject) => {
      let settled = false;
      const finish = (errorArg?: Error) => {
        if (settled) {
          return;
        }
        settled = true;
        clearTimeout(timer);
        this.socket.removeListener('connect', onConnect);
        this.socket.removeListener('error', onError);
        if (errorArg) {
          this.socket.destroy();
          reject(errorArg);
          return;
        }
        resolve();
      };
      const onConnect = () => finish();
      const onError = (errorArg: Error) => finish(errorArg);
      const timer = setTimeout(() => finish(new Error(`Timed out connecting to Keenetic NDMS2 Telnet at ${this.options.host}:${this.options.port}.`)), this.options.timeoutMs);
      this.socket.once('connect', onConnect);
      this.socket.once('error', onError);
    });
  }

  private async login(): Promise<void> {
    await this.waitFor('Login: ');
    this.socket.write(`${this.options.username}\n`, 'utf8');
    await this.waitFor('Password: ');
    this.socket.write(`${this.options.password}\n`, 'utf8');
    const text = await this.waitFor(/\(\w[-\w]*\)>/);
    const match = /\(\w[-\w]*\)>/.exec(text);
    this.prompt = match?.[0] || '';
    if (!this.prompt) {
      throw new Error('Keenetic NDMS2 Telnet login did not return a CLI prompt.');
    }
  }

  private waitFor(matcherArg: string | RegExp): Promise<string> {
    if (this.pending) {
      throw new Error('Keenetic NDMS2 Telnet session already has a pending read.');
    }
    return new Promise<string>((resolve, reject) => {
      this.pending = {
        matcher: matcherArg,
        resolve,
        reject,
        timer: setTimeout(() => {
          this.rejectPending(new Error(`Keenetic NDMS2 Telnet read timed out after ${this.options.timeoutMs}ms.`));
        }, this.options.timeoutMs),
      };
      this.resolvePendingIfMatched();
    });
  }

  private onData(chunkArg: Buffer): void {
    const data = this.stripTelnetNegotiation(chunkArg);
    if (data.length) {
      this.textBuffer += data.toString('utf8');
      this.resolvePendingIfMatched();
    }
  }

  private resolvePendingIfMatched(): void {
    const pending = this.pending;
    if (!pending) {
      return;
    }
    const match = this.findMatch(pending.matcher);
    if (!match) {
      return;
    }
    const text = this.textBuffer.slice(0, match.end);
    this.textBuffer = this.textBuffer.slice(match.end);
    this.pending = undefined;
    clearTimeout(pending.timer);
    pending.resolve(text);
  }

  private findMatch(matcherArg: string | RegExp): { end: number } | undefined {
    if (typeof matcherArg === 'string') {
      const index = this.textBuffer.indexOf(matcherArg);
      return index >= 0 ? { end: index + matcherArg.length } : undefined;
    }
    const match = matcherArg.exec(this.textBuffer);
    return match ? { end: match.index + match[0].length } : undefined;
  }

  private rejectPending(errorArg: Error): void {
    const pending = this.pending;
    if (!pending) {
      return;
    }
    this.pending = undefined;
    clearTimeout(pending.timer);
    pending.reject(errorArg);
  }

  private responseLines(textArg: string, commandArg: string): string[] {
    const withoutPrompt = this.prompt && textArg.endsWith(this.prompt) ? textArg.slice(0, -this.prompt.length) : textArg;
    const lines = withoutPrompt.replace(/\r/g, '').split('\n');
    while (lines.length && !lines[0].trim()) {
      lines.shift();
    }
    if (lines[0]?.trim() === commandArg) {
      lines.shift();
    }
    while (lines.length && !lines[lines.length - 1].trim()) {
      lines.pop();
    }
    return lines;
  }

  private stripTelnetNegotiation(bufferArg: Buffer): Buffer {
    const output: number[] = [];
    for (let index = 0; index < bufferArg.length; index++) {
      const byte = bufferArg[index];
      if (byte !== 255) {
        output.push(byte);
        continue;
      }

      const command = bufferArg[++index];
      if (command === 255) {
        output.push(255);
        continue;
      }
      if (command === 250) {
        while (index < bufferArg.length - 1 && !(bufferArg[index] === 255 && bufferArg[index + 1] === 240)) {
          index++;
        }
        index++;
        continue;
      }
      const option = bufferArg[++index];
      this.respondToTelnetOption(command, option);
    }
    return Buffer.from(output);
  }

  private respondToTelnetOption(commandArg: number, optionArg: number): void {
    const IAC = 255;
    const DO = 253;
    const WILL = 251;
    const WONT = 252;
    const DONT = 254;
    const NAWS = 31;
    if (commandArg === DO && optionArg === NAWS) {
      this.socket.write(Buffer.from([IAC, WILL, NAWS]));
      return;
    }
    if (commandArg === DO) {
      this.socket.write(Buffer.from([IAC, WONT, optionArg]));
      return;
    }
    if (commandArg === WILL) {
      this.socket.write(Buffer.from([IAC, DONT, optionArg]));
    }
  }
}

function parseRouterInfo(linesArg: string[]): IKeeneticRouterInfo {
  const info = parseDictLines(linesArg);
  const flatInfo = parseFlatKeyValueLines(linesArg);
  const value = (...keysArg: string[]) => keysArg.map((keyArg) => textValue(info[keyArg]) || flatInfo[keyArg]).find((valueArg): valueArg is string => Boolean(valueArg));
  return {
    name: value('description', 'model') || 'NDMS2 Router',
    fwVersion: value('title', 'release'),
    fwChannel: value('sandbox') || 'unknown',
    model: value('model', 'hw_id'),
    hwVersion: value('hw_version') || 'N/A',
    manufacturer: value('manufacturer'),
    vendor: value('vendor'),
    region: value('region') || 'N/A',
  };
}

function parseFlatKeyValueLines(linesArg: string[]): Record<string, string> {
  return linesArg.reduce<Record<string, string>>((resultArg, lineArg) => {
    const match = /^\s*([^:,]+?)(?:\s*,.*?)?\s*:\s*(.*?)\s*$/.exec(lineArg);
    if (match && match[1] && match[2]) {
      resultArg[match[1].trim()] = match[2].trim();
    }
    return resultArg;
  }, {});
}

function parseArpDevices(linesArg: string[]): IKeeneticDevice[] {
  const arpRegex = /(?<name>.*?)\s+(?<ip>(?:[0-9]{1,3}[.]){3}[0-9]{1,3})?\s+(?<mac>(?:[0-9a-f]{2}[:-]){5}[0-9a-f]{2})\s+(?<interface>[^ ]+)\s+/i;
  return linesArg.map((lineArg) => arpRegex.exec(lineArg)?.groups)
    .filter((groupsArg): groupsArg is Record<string, string> => Boolean(groupsArg?.mac))
    .map((groupsArg) => ({
      mac: groupsArg.mac.toUpperCase(),
      name: groupsArg.name?.trim() || undefined,
      ip: groupsArg.ip,
      interface: groupsArg.interface,
    }));
}

function mergeDevices(...listsArg: IKeeneticDevice[][]): IKeeneticDevice[] {
  const devices = new Map<string, IKeeneticDevice>();
  for (const list of listsArg) {
    for (const device of list) {
      const key = `${device.interface || ''}_${device.mac}`;
      const oldDevice = devices.get(key);
      devices.set(key, oldDevice ? {
        mac: oldDevice.mac,
        name: oldDevice.name || device.name,
        ip: oldDevice.ip || device.ip,
        interface: oldDevice.interface || device.interface,
      } : device);
    }
  }
  return [...devices.values()];
}

function parseDictLines(linesArg: string[]): Record<string, unknown> {
  const response: Record<string, unknown> = {};
  const stack: Array<{ key?: string; indent: number; value: unknown }> = [{ indent: 0, value: response }];
  let indent = 0;
  let stackLevel = 0;

  for (const line of fixContinuationLines(linesArg)) {
    if (!line.trim()) {
      continue;
    }
    const colonPos = line.indexOf(':');
    if (colonPos < 0) {
      continue;
    }
    const commaPos = line.slice(0, colonPos).includes(',') ? line.indexOf(',') : -1;
    let key = line.slice(0, colonPos).trim();
    let value: unknown = line.slice(colonPos + 1).trim();
    const newIndent = commaPos >= 0 ? commaPos : colonPos;

    if (commaPos >= 0) {
      key = line.slice(0, commaPos).trim();
      const item: Record<string, unknown> = value !== '' ? { [key]: value } : {};
      for (const argument of line.slice(commaPos + 1, colonPos).split(',')) {
        const [subKey, subValue] = argument.split('=', 2).map((partArg) => partArg.trim());
        if (subKey) {
          item[subKey] = subValue;
        }
      }
      value = item;
    }

    if (newIndent > indent) {
      stackLevel++;
      indent = newIndent;
      stack[stackLevel] = { indent, value: undefined };
    } else {
      while (newIndent < indent && stackLevel > 0) {
        stack.pop();
        stackLevel--;
        indent = stack[stackLevel].indent;
      }
    }
    if (stackLevel < 1) {
      stackLevel = 1;
      indent = newIndent;
      stack[stackLevel] = { indent, value: undefined };
    }

    stack[stackLevel] = { key, indent: newIndent, value };
    const parentEntry = stack[stackLevel - 1];
    let parent = parentEntry.value;
    if (!objectValue(parent)) {
      const grandParentEntry = stack[stackLevel - 2];
      const grandParent = objectValue(grandParentEntry?.value);
      if (!grandParent || !parentEntry.key) {
        continue;
      }
      parent = {};
      if (Array.isArray(grandParent[parentEntry.key])) {
        const list = grandParent[parentEntry.key] as unknown[];
        list.pop();
        list.push(parent);
      } else {
        grandParent[parentEntry.key] = parent;
      }
      parentEntry.value = parent;
    }

    const parentObject = parent as Record<string, unknown>;
    if (key in parentObject) {
      parentObject[key] = Array.isArray(parentObject[key]) ? [...parentObject[key] as unknown[], value] : [parentObject[key], value];
    } else {
      parentObject[key] = value;
    }
  }
  return response;
}

function fixContinuationLines(linesArg: string[]): string[] {
  let indent = 0;
  let continuationPossible = false;
  const fixedLines: string[] = [];
  for (let line of linesArg) {
    if (!line.trim()) {
      continue;
    }
    if (continuationPossible && !line.slice(0, indent).trim()) {
      const previousLine = fixedLines.pop() || '';
      line = previousLine.trimEnd() + line.slice(indent + 1).trimStart();
    } else {
      const colonPos = line.indexOf(':');
      if (colonPos < 0) {
        continue;
      }
      const commaPos = line.slice(0, colonPos).includes(',') ? line.indexOf(',') : -1;
      indent = commaPos >= 0 ? commaPos : colonPos;
      continuationPossible = line.slice(indent + 1).trim().length > 0;
    }
    fixedLines.push(line);
  }
  return fixedLines;
}

function asArray(valueArg: unknown): unknown[] {
  if (valueArg === undefined || valueArg === null) {
    return [];
  }
  return Array.isArray(valueArg) ? valueArg : [valueArg];
}

function objectValue(valueArg: unknown): Record<string, unknown> | undefined {
  return valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg) ? valueArg as Record<string, unknown> : undefined;
}

function stringValue(valueArg: unknown): string | undefined {
  return valueArg === undefined || valueArg === null || valueArg === '' ? undefined : String(valueArg);
}

function textValue(valueArg: unknown): string | undefined {
  const object = objectValue(valueArg);
  if (object) {
    for (const key of ['value', 'description', 'model', 'name', 'id', 'title', 'release', 'manufacturer', 'vendor', 'region', 'hw_version', 'hw_id', 'sandbox']) {
      const value = object[key];
      if (value !== undefined && value !== null && typeof value !== 'object') {
        return String(value);
      }
    }
    return undefined;
  }
  return stringValue(valueArg);
}
