import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
import type {
  IDiscoveryCandidate,
  IDiscoveryContext,
  IDiscoveryMatch,
  IDiscoveryMatcher,
  IDiscoveryProbe,
  IDiscoveryProbeResult,
  IDiscoveryValidator,
} from '../../core/types.js';
import type { IPlexGdmRecord, IPlexManualEntry, IPlexMdnsRecord, IPlexSsdpRecord } from './plex.types.js';
import { plexDefaultPort } from './plex.types.js';

const plexMdnsType = '_plexmediasvr._tcp';
const plexServerContentType = 'plex/media-server';
const plexPlayerContentType = 'plex/media-player';

export class PlexGdmDiscoveryProbe implements IDiscoveryProbe {
  public id = 'plex-gdm-discovery-probe';
  public source = 'custom' as const;
  public description = 'Discover Plex Media Servers using the local GDM multicast protocol.';

  public async probe(contextArg: IDiscoveryContext): Promise<IDiscoveryProbeResult> {
    if (contextArg.abortSignal?.aborted) {
      return { candidates: [] };
    }
    return { candidates: await this.discover(1200) };
  }

  private async discover(timeoutMsArg: number): Promise<IDiscoveryCandidate[]> {
    const { createSocket } = await import('node:dgram');
    const message = Buffer.from('M-SEARCH * HTTP/1.0', 'ascii');
    const matcher = new PlexGdmMatcher();
    const candidates: IDiscoveryCandidate[] = [];

    return new Promise((resolve) => {
      const socket = createSocket({ type: 'udp4', reuseAddr: true });
      const timer = setTimeout(() => {
        closeSocket();
        resolve(candidates);
      }, timeoutMsArg);

      const closeSocket = () => {
        clearTimeout(timer);
        try {
          socket.close();
        } catch {
          // The discovery socket may already be closed after timeout or error.
        }
      };

      socket.on('message', async (dataArg, remoteArg) => {
        const record = parseGdmResponse(dataArg.toString('utf8'), remoteArg.address, remoteArg.port);
        if (!record) {
          return;
        }
        const match = await matcher.matches(record);
        if (match.matched && match.candidate && !candidates.some((candidateArg) => candidateArg.id === match.candidate?.id || candidateArg.host === match.candidate?.host)) {
          candidates.push(match.candidate);
        }
      });
      socket.on('error', () => {
        closeSocket();
        resolve(candidates);
      });
      socket.bind(() => {
        socket.setMulticastTTL(1);
        socket.send(message, 32414, '239.0.0.250');
      });
    });
  }
}

export class PlexGdmMatcher implements IDiscoveryMatcher<IPlexGdmRecord> {
  public id = 'plex-gdm-match';
  public source = 'custom' as const;
  public description = 'Recognize Plex GDM server and client responses.';

  public async matches(recordArg: IPlexGdmRecord): Promise<IDiscoveryMatch> {
    const data = normalizeKeys(recordArg.data || {});
    const contentType = lowerString(data['content-type']);
    const isServer = contentType.includes(plexServerContentType);
    const isPlayer = contentType.includes(plexPlayerContentType);
    if (!isServer && !isPlayer) {
      return { matched: false, confidence: 'low', reason: 'GDM response is not a Plex server or player.' };
    }

    const fromHost = Array.isArray(recordArg.from) ? recordArg.from[0] : recordArg.from?.address;
    const host = recordArg.host || fromHost || data.host;
    const port = numberValue(data.port) || recordArg.port || plexDefaultPort;
    const id = data['resource-identifier'];
    const name = data.name || (isServer ? 'Plex Media Server' : 'Plex Client');

    return {
      matched: true,
      confidence: id && host ? 'certain' : host ? 'high' : 'medium',
      reason: isServer ? 'GDM response advertises a Plex Media Server.' : 'GDM response advertises a Plex media player.',
      normalizedDeviceId: id,
      candidate: {
        source: 'custom',
        integrationDomain: 'plex',
        id,
        host,
        port,
        name,
        manufacturer: 'Plex',
        model: isServer ? 'Plex Media Server' : data.product || 'Plex Client',
        metadata: {
          discoveryProtocol: 'gdm',
          contentType,
          version: data.version,
          product: data.product,
          protocolCapabilities: splitList(data['protocol-capabilities']),
          raw: recordArg.data,
        },
      },
    };
  }
}

export class PlexMdnsMatcher implements IDiscoveryMatcher<IPlexMdnsRecord> {
  public id = 'plex-mdns-match';
  public source = 'mdns' as const;
  public description = 'Recognize Plex Media Server zeroconf advertisements.';

  public async matches(recordArg: IPlexMdnsRecord): Promise<IDiscoveryMatch> {
    const txt = normalizeKeys({ ...recordArg.txt, ...recordArg.properties });
    const type = normalizeMdnsType(recordArg.type || recordArg.serviceType || '');
    const name = cleanServiceName(recordArg.name) || txt.name || 'Plex Media Server';
    const text = [type, name, recordArg.host, recordArg.hostname, txt.product, txt.model].filter(Boolean).join(' ').toLowerCase();
    const matched = type === plexMdnsType || text.includes('plexmediasvr') || text.includes('plex media server');
    if (!matched) {
      return { matched: false, confidence: 'low', reason: 'mDNS record is not a Plex Media Server advertisement.' };
    }

    const host = recordArg.host || recordArg.hostname || recordArg.addresses?.[0];
    const id = txt['machineidentifier'] || txt['resource-identifier'] || txt.identifier || recordArg.name;
    return {
      matched: true,
      confidence: id && host ? 'certain' : host ? 'high' : 'medium',
      reason: 'mDNS service matches _plexmediasvr._tcp.',
      normalizedDeviceId: id,
      candidate: {
        source: 'mdns',
        integrationDomain: 'plex',
        id,
        host,
        port: recordArg.port || plexDefaultPort,
        name,
        manufacturer: 'Plex',
        model: 'Plex Media Server',
        metadata: {
          discoveryProtocol: 'mdns',
          mdnsName: recordArg.name,
          mdnsType: type,
          txt,
        },
      },
    };
  }
}

export class PlexSsdpMatcher implements IDiscoveryMatcher<IPlexSsdpRecord> {
  public id = 'plex-ssdp-match';
  public source = 'ssdp' as const;
  public description = 'Recognize Plex SSDP/DLNA advertisements when present.';

  public async matches(recordArg: IPlexSsdpRecord): Promise<IDiscoveryMatch> {
    const headers = normalizeKeys(recordArg.headers || {});
    const location = recordArg.location || headers.location;
    const parsedLocation = parseLocation(location);
    const text = [
      recordArg.st,
      headers.st,
      recordArg.usn,
      headers.usn,
      recordArg.server,
      headers.server,
      recordArg.friendlyName,
      recordArg.manufacturer,
      recordArg.modelName,
      location,
    ].filter(Boolean).join(' ').toLowerCase();
    const matched = text.includes('plex') || recordArg.metadata?.plex === true;
    if (!matched) {
      return { matched: false, confidence: 'low', reason: 'SSDP record is not Plex-related.' };
    }

    const id = uuidFromUsn(recordArg.usn || headers.usn) || recordArg.host || parsedLocation?.host;
    return {
      matched: true,
      confidence: location ? 'high' : 'medium',
      reason: 'SSDP metadata contains Plex identifiers.',
      normalizedDeviceId: id,
      candidate: {
        source: 'ssdp',
        integrationDomain: 'plex',
        id,
        host: recordArg.host || parsedLocation?.hostname,
        port: recordArg.port || parsedLocation?.port || plexDefaultPort,
        name: recordArg.friendlyName || 'Plex Media Server',
        manufacturer: recordArg.manufacturer || 'Plex',
        model: recordArg.modelName || 'Plex Media Server',
        metadata: {
          ...recordArg.metadata,
          discoveryProtocol: 'ssdp',
          location,
          st: recordArg.st || headers.st,
          usn: recordArg.usn || headers.usn,
          server: recordArg.server || headers.server,
        },
      },
    };
  }
}

export class PlexManualMatcher implements IDiscoveryMatcher<IPlexManualEntry> {
  public id = 'plex-manual-match';
  public source = 'manual' as const;
  public description = 'Recognize manual Plex setup entries.';

  public async matches(inputArg: IPlexManualEntry): Promise<IDiscoveryMatch> {
    const text = [inputArg.name, inputArg.manufacturer, inputArg.model].filter(Boolean).join(' ').toLowerCase();
    const matched = Boolean(inputArg.host || inputArg.url || inputArg.token || inputArg.snapshot || inputArg.metadata?.plex || text.includes('plex'));
    if (!matched) {
      return { matched: false, confidence: 'low', reason: 'Manual entry does not contain Plex setup hints.' };
    }
    const host = inputArg.host || parseLocation(inputArg.url)?.hostname;
    const port = inputArg.port || parseLocation(inputArg.url)?.port || plexDefaultPort;
    const id = inputArg.serverIdentifier || inputArg.id || inputArg.snapshot?.server.machineIdentifier || (host ? `${host}:${port}` : undefined);
    return {
      matched: true,
      confidence: inputArg.snapshot?.server.machineIdentifier || host ? 'high' : 'medium',
      reason: 'Manual entry can start Plex setup.',
      normalizedDeviceId: id,
      candidate: {
        source: 'manual',
        integrationDomain: 'plex',
        id,
        host,
        port,
        name: inputArg.name || inputArg.snapshot?.server.friendlyName,
        manufacturer: 'Plex',
        model: inputArg.model || 'Plex Media Server',
        metadata: {
          ...inputArg.metadata,
          discoveryProtocol: 'manual',
          ssl: inputArg.ssl,
          verifySsl: inputArg.verifySsl,
          url: inputArg.url,
          hasToken: Boolean(inputArg.token),
          snapshot: inputArg.snapshot,
        },
      },
    };
  }
}

export class PlexCandidateValidator implements IDiscoveryValidator {
  public id = 'plex-candidate-validator';
  public description = 'Validate Plex candidates from GDM, zeroconf, SSDP, and manual setup.';

  public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
    const metadata = candidateArg.metadata || {};
    const protocol = typeof metadata.discoveryProtocol === 'string' ? metadata.discoveryProtocol : undefined;
    const name = (candidateArg.name || '').toLowerCase();
    const manufacturer = (candidateArg.manufacturer || '').toLowerCase();
    const model = (candidateArg.model || '').toLowerCase();
    const matched = candidateArg.integrationDomain === 'plex'
      || protocol === 'gdm'
      || protocol === 'mdns'
      || protocol === 'zeroconf'
      || protocol === 'ssdp'
      || manufacturer.includes('plex')
      || model.includes('plex')
      || name.includes('plex')
      || candidateArg.port === plexDefaultPort
      || metadata.plex === true;

    if (!matched) {
      return { matched: false, confidence: 'low', reason: 'Candidate is not Plex.' };
    }
    return {
      matched: true,
      confidence: candidateArg.id && candidateArg.host ? 'certain' : candidateArg.host ? 'high' : 'medium',
      reason: candidateArg.host ? 'Candidate has Plex metadata and host information.' : 'Candidate has Plex metadata but no host information.',
      candidate: {
        ...candidateArg,
        port: candidateArg.port || plexDefaultPort,
      },
      normalizedDeviceId: candidateArg.id || (candidateArg.host ? `${candidateArg.host}:${candidateArg.port || plexDefaultPort}` : undefined),
      metadata: { discoveryProtocol: protocol },
    };
  }
}

export const createPlexDiscoveryDescriptor = (): DiscoveryDescriptor => {
  return new DiscoveryDescriptor({ integrationDomain: 'plex', displayName: 'Plex Media Server' })
    .addProbe(new PlexGdmDiscoveryProbe())
    .addMatcher(new PlexGdmMatcher())
    .addMatcher(new PlexMdnsMatcher())
    .addMatcher(new PlexSsdpMatcher())
    .addMatcher(new PlexManualMatcher())
    .addValidator(new PlexCandidateValidator());
};

const parseGdmResponse = (valueArg: string, hostArg: string, portArg: number): IPlexGdmRecord | undefined => {
  const lines = valueArg.split(/\r?\n/).filter(Boolean);
  if (!lines[0]?.includes('200 OK')) {
    return undefined;
  }
  const data: Record<string, string> = {};
  for (const line of lines.slice(1)) {
    const delimiter = line.indexOf(':');
    if (delimiter === -1) {
      continue;
    }
    data[line.slice(0, delimiter).trim()] = line.slice(delimiter + 1).trim();
  }
  return { data, from: [hostArg, portArg] };
};

const normalizeKeys = (recordArg: Record<string, string | undefined>): Record<string, string | undefined> => {
  const normalized: Record<string, string | undefined> = {};
  for (const [key, value] of Object.entries(recordArg)) {
    normalized[key.toLowerCase()] = value;
  }
  return normalized;
};

const normalizeMdnsType = (valueArg: string): string => valueArg.toLowerCase().replace(/\.local\.?$/, '').replace(/\.$/, '');

const cleanServiceName = (valueArg?: string): string | undefined => {
  return valueArg?.replace(/\._plexmediasvr\._tcp\.local\.?$/i, '').replace(/\.local\.?$/i, '').trim() || undefined;
};

const lowerString = (valueArg?: string): string => (valueArg || '').toLowerCase();

const splitList = (valueArg?: string): string[] => valueArg ? valueArg.split(',').map((itemArg) => itemArg.trim()).filter(Boolean) : [];

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 parseLocation = (valueArg?: string): { host: string; hostname: string; port?: number } | undefined => {
  if (!valueArg) {
    return undefined;
  }
  try {
    const url = new URL(valueArg);
    return { host: url.host, hostname: url.hostname, port: url.port ? Number(url.port) : undefined };
  } catch {
    return undefined;
  }
};

const uuidFromUsn = (valueArg?: string): string | undefined => {
  const match = valueArg?.match(/uuid:([^:\s]+)/i);
  return match?.[1];
};
