import * as dgram from 'node:dgram';
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
import type { IDiscoveryCandidate, IDiscoveryContext, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryProbe, IDiscoveryProbeResult, IDiscoveryValidator } from '../../core/types.js';
import { airosDefaultHttpsPort, airosDefaultName, airosDefaultSsl, airosDefaultVerifySsl, airosDiscoveryPort, airosDiscoveryTimeoutMs, airosDisplayName, airosDomain, airosManufacturer, airosTextHints } from './airos.constants.js';
import type { IAirosCandidateMetadata, IAirosDhcpRecord, IAirosLocalDiscoveryRecord, IAirosManualEntry } from './airos.types.js';

export class AirosDiscoveryParseError extends Error {}

export class AirosUdpDiscoveryProbe implements IDiscoveryProbe {
  public id = 'airos-udp-broadcast-probe';
  public source = 'custom' as const;
  public description = 'Listen for native airOS UDP discovery broadcasts on port 10002.';

  constructor(private readonly timeoutMs = airosDiscoveryTimeoutMs) {}

  public async probe(contextArg: IDiscoveryContext): Promise<IDiscoveryProbeResult> {
    const candidates = new Map<string, IDiscoveryCandidate>();
    const matcher = new AirosLocalDiscoveryMatcher();
    const socket = dgram.createSocket({ type: 'udp4', reuseAddr: true });

    await new Promise<void>((resolve, reject) => {
      const cleanup = (): void => {
        socket.off('error', onError);
        socket.off('listening', onListening);
      };
      const onError = (errorArg: Error): void => {
        cleanup();
        reject(errorArg);
      };
      const onListening = (): void => {
        cleanup();
        try {
          socket.setBroadcast(true);
        } catch {
          // Some platforms do not allow toggling broadcast on a receive-only socket.
        }
        resolve();
      };
      socket.once('error', onError);
      socket.once('listening', onListening);
      socket.bind(airosDiscoveryPort, '0.0.0.0');
    });

    const onMessage = (messageArg: Buffer, remoteArg: dgram.RemoteInfo): void => {
      void (async () => {
        try {
          const parsed = parseAirosDiscoveryPacket(messageArg, remoteArg.address);
          const match = await matcher.matches(parsed);
          if (match.matched && match.candidate) {
            candidates.set(match.normalizedDeviceId || match.candidate.id || match.candidate.host || remoteArg.address, match.candidate);
          }
        } catch (errorArg) {
          contextArg.logger?.log('debug', 'Ignoring malformed airOS discovery packet.', { error: errorArg instanceof Error ? errorArg.message : String(errorArg) });
        }
      })();
    };

    socket.on('message', onMessage);
    try {
      await waitForDiscovery(this.timeoutMs, contextArg.abortSignal);
    } finally {
      socket.off('message', onMessage);
      socket.close();
    }

    return { candidates: [...candidates.values()] };
  }
}

export class AirosDhcpMatcher implements IDiscoveryMatcher<IAirosDhcpRecord> {
  public id = 'airos-dhcp-registered-device-match';
  public source = 'dhcp' as const;
  public description = 'Recognize Home Assistant registered-device DHCP updates and Ubiquiti airOS DHCP metadata.';

  public async matches(recordArg: IAirosDhcpRecord): Promise<IDiscoveryMatch> {
    const metadata = recordArg.metadata || {};
    const host = recordArg.host || recordArg.ipAddress || recordArg.address || recordArg.ip || stringValue(metadata.host);
    const hostname = recordArg.hostname || recordArg.hostName || stringValue(metadata.hostname);
    const macAddress = normalizeAirosMac(recordArg.macAddress || recordArg.macaddress || recordArg.mac || stringValue(metadata.macAddress));
    const model = recordArg.model || stringValue(metadata.model) || stringValue(metadata.modelName);
    const registeredDevice = recordArg.registeredDevice === true || metadata.registeredDevice === true || metadata.registered_devices === true || metadata.homeAssistantRegisteredDevice === true;
    const textMatched = airosText(recordArg.integrationDomain, recordArg.manufacturer, model, hostname, metadata.brand, metadata.manufacturer).some((textArg) => airosTextHints.some((hintArg) => textArg.includes(hintArg)));
    const matched = recordArg.integrationDomain === airosDomain || metadata.airos === true || registeredDevice && (metadata.integrationDomain === airosDomain || metadata.domain === airosDomain) || textMatched;

    if (!matched) {
      return { matched: false, confidence: 'low', reason: 'DHCP record is not a registered airOS device and has no Ubiquiti airOS metadata.' };
    }

    const id = macAddress || hostname || host;
    return {
      matched: true,
      confidence: registeredDevice && host ? 'certain' : host ? 'high' : 'medium',
      reason: registeredDevice ? 'DHCP record matches Home Assistant registered-device discovery for airOS.' : 'DHCP metadata identifies a Ubiquiti airOS device.',
      normalizedDeviceId: id,
      candidate: {
        source: 'dhcp',
        integrationDomain: airosDomain,
        id,
        host,
        port: airosDefaultHttpsPort,
        name: hostname || airosDefaultName,
        manufacturer: recordArg.manufacturer || airosManufacturer,
        model: model || airosDisplayName,
        macAddress,
        metadata: {
          ...metadata,
          airos: true,
          discoveryProtocol: 'dhcp-registered-device',
          hostname,
          registeredDevice,
          ssl: airosDefaultSsl,
          verifySsl: airosDefaultVerifySsl,
          nativeHttpImplemented: true,
        } satisfies IAirosCandidateMetadata,
      },
      metadata: { registeredDevice, textMatched },
    };
  }
}

export class AirosLocalDiscoveryMatcher implements IDiscoveryMatcher<IAirosLocalDiscoveryRecord> {
  public id = 'airos-udp-broadcast-match';
  public source = 'custom' as const;
  public description = 'Recognize normalized airOS UDP broadcast records from the native discovery listener.';

  public async matches(recordArg: IAirosLocalDiscoveryRecord): Promise<IDiscoveryMatch> {
    const metadata = recordArg.metadata || {};
    const host = recordArg.host || recordArg.ipAddress || recordArg.ip_address || recordArg.ip || stringValue(metadata.ip_address) || stringValue(metadata.host);
    const macAddress = normalizeAirosMac(recordArg.macAddress || recordArg.mac_address || recordArg.mac || stringValue(metadata.macAddress));
    const hostname = nullToUndefined(recordArg.hostname) || stringValue(metadata.hostname);
    const firmwareVersion = nullToUndefined(recordArg.firmwareVersion) || nullToUndefined(recordArg.firmware_version) || stringValue(metadata.firmwareVersion);
    const fullModelName = nullToUndefined(recordArg.fullModelName) || nullToUndefined(recordArg.full_model_name) || stringValue(metadata.fullModelName);
    const model = nullToUndefined(recordArg.model) || fullModelName || stringValue(metadata.model);
    const matched = Boolean(host && (macAddress || firmwareVersion || model || metadata.airos === true || recordArg.integrationDomain === airosDomain));

    if (!matched) {
      return { matched: false, confidence: 'low', reason: 'Local discovery record is not an airOS UDP broadcast.' };
    }

    const id = macAddress || host;
    return {
      matched: true,
      confidence: host && macAddress ? 'certain' : host ? 'high' : 'medium',
      reason: 'Native airOS UDP broadcast contains local host and device metadata.',
      normalizedDeviceId: id,
      candidate: {
        source: 'custom',
        integrationDomain: airosDomain,
        id,
        host,
        port: airosDefaultHttpsPort,
        name: hostname || airosDefaultName,
        manufacturer: recordArg.manufacturer || airosManufacturer,
        model: model || airosDisplayName,
        macAddress,
        metadata: {
          ...metadata,
          airos: true,
          discoveryProtocol: 'airos-udp-broadcast',
          hostname,
          firmwareVersion,
          fullModelName,
          ssid: nullToUndefined(recordArg.ssid) || stringValue(metadata.ssid),
          uptimeSeconds: recordArg.uptimeSeconds ?? recordArg.uptime_seconds ?? metadata.uptimeSeconds,
          ssl: airosDefaultSsl,
          verifySsl: airosDefaultVerifySsl,
          nativeHttpImplemented: true,
        } satisfies IAirosCandidateMetadata,
      },
    };
  }
}

export class AirosManualMatcher implements IDiscoveryMatcher<IAirosManualEntry> {
  public id = 'airos-manual-match';
  public source = 'manual' as const;
  public description = 'Recognize manual airOS host, credential, raw status, and snapshot setup entries.';

  public async matches(inputArg: IAirosManualEntry): Promise<IDiscoveryMatch> {
    const metadata = inputArg.metadata || {};
    const snapshot = inputArg.snapshot || metadata.snapshot as IAirosManualEntry['snapshot'];
    const rawStatus = inputArg.rawStatus || metadata.rawStatus as IAirosManualEntry['rawStatus'];
    const host = inputArg.host || inputArg.ipAddress || inputArg.address || inputArg.ip || snapshot?.device.host || stringValue(metadata.host);
    const macAddress = normalizeAirosMac(inputArg.macAddress || inputArg.mac || snapshot?.device.macAddress || stringValue(metadata.macAddress));
    const model = inputArg.modelName || inputArg.model || snapshot?.device.modelName || stringValue(rawStatus?.host?.devmodel) || stringValue(metadata.modelName) || stringValue(metadata.model);
    const textMatched = airosText(inputArg.integrationDomain, inputArg.manufacturer, model, inputArg.name, metadata.brand, metadata.manufacturer).some((textArg) => airosTextHints.some((hintArg) => textArg.includes(hintArg)));
    const hasCredentials = Boolean(inputArg.password || metadata.password);
    const matched = inputArg.integrationDomain === airosDomain
      || metadata.airos === true
      || Boolean(snapshot)
      || Boolean(rawStatus)
      || textMatched
      || Boolean(host && hasCredentials);

    if (!matched) {
      return { matched: false, confidence: 'low', reason: 'Manual entry does not contain airOS setup data.' };
    }

    const id = inputArg.id || macAddress || snapshot?.device.id || host;
    return {
      matched: true,
      confidence: snapshot || rawStatus ? 'certain' : host && hasCredentials ? 'high' : host ? 'medium' : 'low',
      reason: snapshot ? 'Manual entry includes an airOS snapshot.' : rawStatus ? 'Manual entry includes raw airOS status data.' : 'Manual entry can start airOS setup.',
      normalizedDeviceId: id,
      candidate: {
        source: 'manual',
        integrationDomain: airosDomain,
        id,
        host,
        port: inputArg.port || snapshot?.device.port || (resolveSsl(inputArg) ? airosDefaultHttpsPort : 80),
        name: inputArg.name || snapshot?.device.name || stringValue(rawStatus?.host?.hostname) || airosDefaultName,
        manufacturer: inputArg.manufacturer || snapshot?.device.manufacturer || airosManufacturer,
        model: model || airosDisplayName,
        macAddress,
        metadata: {
          ...metadata,
          airos: true,
          discoveryProtocol: 'manual',
          username: inputArg.username || stringValue(metadata.username),
          ssl: resolveSsl(inputArg),
          verifySsl: resolveVerifySsl(inputArg),
          snapshot,
          rawStatus,
          nativeHttpImplemented: true,
        } satisfies IAirosCandidateMetadata,
      },
      metadata: { snapshotConfigured: Boolean(snapshot), rawStatusConfigured: Boolean(rawStatus), credentialsConfigured: hasCredentials },
    };
  }
}

export class AirosCandidateValidator implements IDiscoveryValidator {
  public id = 'airos-candidate-validator';
  public description = 'Validate airOS candidates from DHCP, UDP discovery, and manual setup.';

  public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
    const metadata = candidateArg.metadata || {};
    const snapshot = metadata.snapshot as IAirosManualEntry['snapshot'];
    const rawStatus = metadata.rawStatus as IAirosManualEntry['rawStatus'];
    const textMatched = airosText(candidateArg.integrationDomain, candidateArg.manufacturer, candidateArg.model, candidateArg.name, metadata.brand, metadata.manufacturer).some((textArg) => airosTextHints.some((hintArg) => textArg.includes(hintArg)));
    const matched = candidateArg.integrationDomain === airosDomain || metadata.airos === true || Boolean(snapshot) || Boolean(rawStatus) || textMatched;
    const usable = Boolean(candidateArg.host || snapshot || rawStatus);

    if (!matched || !usable) {
      return {
        matched: false,
        confidence: matched ? 'medium' : 'low',
        reason: matched ? 'airOS candidate lacks a host, snapshot, or raw status data.' : 'Candidate is not airOS.',
      };
    }

    const ssl = booleanValue(metadata.ssl) ?? airosDefaultSsl;
    return {
      matched: true,
      confidence: snapshot || rawStatus || candidateArg.macAddress ? 'certain' : candidateArg.host ? 'high' : 'medium',
      reason: 'Candidate has airOS metadata and a usable local source.',
      candidate: { ...candidateArg, port: candidateArg.port || (ssl ? airosDefaultHttpsPort : 80) },
      normalizedDeviceId: candidateArg.macAddress || candidateArg.id || candidateArg.host || snapshot?.device.id,
      metadata: { snapshotConfigured: Boolean(snapshot), rawStatusConfigured: Boolean(rawStatus), ssl },
    };
  }
}

export const createAirosDiscoveryDescriptor = (): DiscoveryDescriptor => {
  return new DiscoveryDescriptor({ integrationDomain: airosDomain, displayName: airosDisplayName })
    .addProbe(new AirosUdpDiscoveryProbe())
    .addMatcher(new AirosDhcpMatcher())
    .addMatcher(new AirosLocalDiscoveryMatcher())
    .addMatcher(new AirosManualMatcher())
    .addValidator(new AirosCandidateValidator());
};

export const parseAirosDiscoveryPacket = (dataArg: Uint8Array, hostIpArg: string): IAirosLocalDiscoveryRecord => {
  const data = Buffer.from(dataArg);
  if (data.length < 6) {
    throw new AirosDiscoveryParseError(`Packet too short for airOS header: ${data.length} bytes.`);
  }
  if (data[0] !== 0x01 || data[1] !== 0x06) {
    throw new AirosDiscoveryParseError(`Packet does not start with airOS header 0x01 0x06: ${data.subarray(0, 2).toString('hex')}.`);
  }

  const parsed: IAirosLocalDiscoveryRecord = { ip_address: hostIpArg };
  let offset = 6;
  while (offset < data.length) {
    const tlvType = data[offset];
    offset += 1;
    if (tlvType === 0x06) {
      if (data.length - offset < 6) {
        throw new AirosDiscoveryParseError('Truncated airOS MAC address TLV.');
      }
      parsed.mac_address = [...data.subarray(offset, offset + 6)].map((byteArg) => byteArg.toString(16).padStart(2, '0')).join(':');
      offset += 6;
      continue;
    }

    if (!twoByteLengthTlvTypes.has(tlvType)) {
      throw new AirosDiscoveryParseError(`Unhandled airOS discovery TLV type 0x${tlvType.toString(16)}.`);
    }
    if (data.length - offset < 2) {
      throw new AirosDiscoveryParseError(`Truncated airOS discovery TLV length for type 0x${tlvType.toString(16)}.`);
    }
    const length = data.readUInt16BE(offset);
    offset += 2;
    if (length > data.length - offset) {
      throw new AirosDiscoveryParseError(`airOS discovery TLV type 0x${tlvType.toString(16)} length ${length} exceeds remaining packet data.`);
    }
    const value = data.subarray(offset, offset + length);
    switch (tlvType) {
      case 0x02:
        if (length === 10) {
          parsed.ip_address = [...value.subarray(6, 10)].join('.');
        }
        break;
      case 0x03:
        parsed.firmware_version = value.toString('ascii');
        break;
      case 0x0a:
        if (length === 4) {
          parsed.uptime_seconds = value.readUInt32BE(0);
        }
        break;
      case 0x0b:
        parsed.hostname = value.toString('utf8');
        break;
      case 0x0c:
        parsed.model = value.toString('ascii');
        break;
      case 0x0d:
        parsed.ssid = value.toString('utf8');
        break;
      case 0x14:
        parsed.full_model_name = value.toString('utf8');
        break;
      default:
        break;
    }
    offset += length;
  }
  return parsed;
};

export const normalizeAirosMac = (valueArg?: string | null): string | undefined => {
  if (!valueArg) {
    return undefined;
  }
  const compact = valueArg.replace(/[^0-9a-f]/gi, '').toLowerCase();
  return compact.length === 12 ? compact.match(/.{1,2}/g)?.join(':') : undefined;
};

const twoByteLengthTlvTypes = new Set([0x02, 0x03, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x10, 0x14, 0x18]);

const waitForDiscovery = async (timeoutMsArg: number, abortSignalArg?: AbortSignal): Promise<void> => {
  if (abortSignalArg?.aborted) {
    return;
  }
  await new Promise<void>((resolve) => {
    const timer = setTimeout(resolve, timeoutMsArg);
    const onAbort = (): void => {
      clearTimeout(timer);
      resolve();
    };
    abortSignalArg?.addEventListener('abort', onAbort, { once: true });
  });
};

const airosText = (...valuesArg: unknown[]): string[] => {
  return [valuesArg.filter((valueArg): valueArg is string => typeof valueArg === 'string').join(' ').toLowerCase()];
};

const resolveSsl = (inputArg: IAirosManualEntry): boolean => {
  return inputArg.ssl ?? inputArg.useSsl ?? inputArg.advanced_settings?.ssl ?? airosDefaultSsl;
};

const resolveVerifySsl = (inputArg: IAirosManualEntry): boolean => {
  return inputArg.verifySsl ?? inputArg.verify_ssl ?? inputArg.advanced_settings?.verifySsl ?? inputArg.advanced_settings?.verify_ssl ?? airosDefaultVerifySsl;
};

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

const nullToUndefined = (valueArg: string | null | undefined): string | undefined => valueArg || undefined;

const booleanValue = (valueArg: unknown): boolean | undefined => {
  if (typeof valueArg === 'boolean') {
    return valueArg;
  }
  if (typeof valueArg === 'string') {
    const normalized = valueArg.trim().toLowerCase();
    if (normalized === 'true' || normalized === 'yes' || normalized === '1') {
      return true;
    }
    if (normalized === 'false' || normalized === 'no' || normalized === '0') {
      return false;
    }
  }
  return undefined;
};
