import type {
  IDlnaDmrConfig,
  IDlnaDmrDeviceDescription,
  IDlnaDmrMediaMetadata,
  IDlnaDmrPositionInfo,
  IDlnaDmrRendererState,
  IDlnaDmrServiceDescription,
  IDlnaDmrSnapshot,
  IDlnaDmrSoapCommand,
  TDlnaDmrServiceKey,
} from './dlna_dmr.types.js';

const dlnaDmrDeviceTypes = [
  'urn:schemas-upnp-org:device:MediaRenderer:1',
  'urn:schemas-upnp-org:device:MediaRenderer:2',
  'urn:schemas-upnp-org:device:MediaRenderer:3',
];

const serviceKeyById: Record<string, TDlnaDmrServiceKey> = {
  'urn:upnp-org:serviceId:AVTransport': 'AVTransport',
  'urn:upnp-org:serviceId:RenderingControl': 'RenderingControl',
  'urn:upnp-org:serviceId:ConnectionManager': 'ConnectionManager',
};

export class DlnaDmrClient {
  private deviceDescription?: IDlnaDmrDeviceDescription;

  constructor(private readonly config: IDlnaDmrConfig) {}

  public async getSnapshot(): Promise<IDlnaDmrSnapshot> {
    if (this.config.snapshot) {
      return this.withDefaults(this.config.snapshot);
    }

    const device = await this.getDeviceDescription();
    const state = await this.getRendererState(device);
    return { device, state };
  }

  public async play(): Promise<void> {
    await this.sendCommand({ service: 'AVTransport', action: 'Play', args: { InstanceID: 0, Speed: '1' } });
  }

  public async pause(): Promise<void> {
    await this.sendCommand({ service: 'AVTransport', action: 'Pause', args: { InstanceID: 0 } });
  }

  public async stop(): Promise<void> {
    await this.sendCommand({ service: 'AVTransport', action: 'Stop', args: { InstanceID: 0 } });
  }

  public async setVolume(volumeArg: number): Promise<void> {
    const volume = Math.max(0, Math.min(100, Math.round(volumeArg)));
    await this.sendCommand({ service: 'RenderingControl', action: 'SetVolume', args: { InstanceID: 0, Channel: 'Master', DesiredVolume: volume } });
  }

  public async setMute(mutedArg: boolean): Promise<void> {
    await this.sendCommand({ service: 'RenderingControl', action: 'SetMute', args: { InstanceID: 0, Channel: 'Master', DesiredMute: mutedArg ? 1 : 0 } });
  }

  public async selectSource(sourceArg: string): Promise<void> {
    await this.sendCommand({ service: 'RenderingControl', action: 'SelectPreset', args: { InstanceID: 0, PresetName: sourceArg } });
  }

  public async setUri(uriArg: string, metadataArg?: string, titleArg?: string, autoplayArg = false): Promise<void> {
    const metadata = metadataArg ?? this.constructPlayMediaMetadata(uriArg, titleArg || uriArg);
    await this.sendCommand({ service: 'AVTransport', action: 'SetAVTransportURI', args: { InstanceID: 0, CurrentURI: uriArg, CurrentURIMetaData: metadata } });
    if (autoplayArg) {
      await this.play();
    }
  }

  public async sendCommand(commandArg: IDlnaDmrSoapCommand): Promise<Record<string, string>> {
    const device = await this.getDeviceDescription();
    const service = device.services[commandArg.service];
    if (!service) {
      throw new Error(`DLNA DMR service is not available: ${commandArg.service}`);
    }
    return this.soap(service, commandArg.action, commandArg.args);
  }

  public constructPlayMediaMetadata(uriArg: string, titleArg: string): string {
    const mimeType = this.mimeType(uriArg);
    const upnpClass = mimeType.startsWith('audio/') ? 'object.item.audioItem.musicTrack' : mimeType.startsWith('video/') ? 'object.item.videoItem' : mimeType.startsWith('image/') ? 'object.item.imageItem' : 'object.item';
    return [
      '<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/">',
      '<item id="0" parentID="-1" restricted="false">',
      `<dc:title>${this.escapeXml(titleArg)}</dc:title>`,
      `<upnp:class>${upnpClass}</upnp:class>`,
      `<res protocolInfo="http-get:*:${mimeType}:*">${this.escapeXml(uriArg)}</res>`,
      '</item>',
      '</DIDL-Lite>',
    ].join('');
  }

  public async destroy(): Promise<void> {}

  private async getRendererState(deviceArg: IDlnaDmrDeviceDescription): Promise<IDlnaDmrRendererState> {
    const [transportInfo, mediaInfo, positionInfo, transportSettings, transportActions, volume, mute, presets, protocolInfo] = await Promise.all([
      this.safeSoap(deviceArg.services.AVTransport, 'GetTransportInfo', { InstanceID: 0 }),
      this.safeSoap(deviceArg.services.AVTransport, 'GetMediaInfo', { InstanceID: 0 }),
      this.safeSoap(deviceArg.services.AVTransport, 'GetPositionInfo', { InstanceID: 0 }),
      this.safeSoap(deviceArg.services.AVTransport, 'GetTransportSettings', { InstanceID: 0 }),
      this.safeSoap(deviceArg.services.AVTransport, 'GetCurrentTransportActions', { InstanceID: 0 }),
      this.safeSoap(deviceArg.services.RenderingControl, 'GetVolume', { InstanceID: 0, Channel: 'Master' }),
      this.safeSoap(deviceArg.services.RenderingControl, 'GetMute', { InstanceID: 0, Channel: 'Master' }),
      this.safeSoap(deviceArg.services.RenderingControl, 'ListPresets', { InstanceID: 0 }),
      this.safeSoap(deviceArg.services.ConnectionManager, 'GetProtocolInfo', {}),
    ]);
    const position = this.positionInfo(positionInfo);
    const metadata = this.parseMetadata(position.trackMetaData || mediaInfo.CurrentURIMetaData);

    return {
      online: true,
      transport: {
        currentTransportState: transportInfo.CurrentTransportState,
        currentTransportStatus: transportInfo.CurrentTransportStatus,
        currentSpeed: transportInfo.CurrentSpeed,
        currentTransportActions: this.splitList(transportActions.Actions),
        currentPlayMode: transportSettings.PlayMode,
      },
      rendering: {
        volume: this.numberValue(volume.CurrentVolume),
        muted: this.booleanValue(mute.CurrentMute),
        presets: this.splitList(presets.CurrentPresetNameList),
      },
      media: {
        currentUri: mediaInfo.CurrentURI,
        currentUriMetaData: mediaInfo.CurrentURIMetaData,
        currentTrackUri: position.trackUri,
        mediaClass: metadata.upnpClass,
        metadata,
        position,
      },
      sinkProtocolInfo: this.splitList(protocolInfo.Sink),
      updatedAt: new Date().toISOString(),
    };
  }

  private async getDeviceDescription(): Promise<IDlnaDmrDeviceDescription> {
    if (this.deviceDescription) {
      return this.deviceDescription;
    }
    if (this.config.services || this.config.udn || this.config.deviceId) {
      this.deviceDescription = this.deviceFromConfig();
      return this.deviceDescription;
    }

    const location = this.location();
    if (!location) {
      this.deviceDescription = this.deviceFromConfig();
      return this.deviceDescription;
    }

    const response = await fetch(location, { signal: AbortSignal.timeout(this.config.timeoutMs || 10000) });
    if (!response.ok) {
      throw new Error(`Failed to fetch DLNA DMR description ${location}: ${response.status}`);
    }
    const xml = await response.text();
    this.deviceDescription = this.parseDeviceDescription(xml, location);
    return this.deviceDescription;
  }

  private deviceFromConfig(): IDlnaDmrDeviceDescription {
    const location = this.location();
    return {
      location,
      udn: this.config.udn || this.config.deviceId || location || 'manual-dlna-dmr',
      deviceType: this.config.deviceType || 'urn:schemas-upnp-org:device:MediaRenderer:1',
      friendlyName: this.config.name || 'DLNA Digital Media Renderer',
      manufacturer: this.config.manufacturer,
      modelName: this.config.model,
      modelNumber: this.config.modelNumber,
      serialNumber: this.config.serialNumber,
      macAddress: this.config.macAddress,
      services: this.config.services || {},
    };
  }

  private parseDeviceDescription(xmlArg: string, locationArg: string): IDlnaDmrDeviceDescription {
    const deviceBlock = this.findRendererDeviceBlock(xmlArg) || this.extractBlocks(xmlArg, 'device')[0] || xmlArg;
    const services: Partial<Record<TDlnaDmrServiceKey, IDlnaDmrServiceDescription>> = {};
    for (const serviceBlock of this.extractBlocks(deviceBlock, 'service')) {
      const serviceId = this.firstTag(serviceBlock, 'serviceId') || '';
      const key = serviceKeyById[serviceId];
      if (!key) {
        continue;
      }
      services[key] = {
        serviceId,
        serviceType: this.firstTag(serviceBlock, 'serviceType') || '',
        controlUrl: this.absoluteUrl(locationArg, this.firstTag(serviceBlock, 'controlURL') || '') || '',
        eventSubUrl: this.absoluteUrl(locationArg, this.firstTag(serviceBlock, 'eventSubURL') || ''),
        scpdUrl: this.absoluteUrl(locationArg, this.firstTag(serviceBlock, 'SCPDURL') || ''),
      };
    }

    return {
      location: locationArg,
      udn: this.firstTag(deviceBlock, 'UDN') || this.config.udn || this.config.deviceId || locationArg,
      deviceType: this.firstTag(deviceBlock, 'deviceType') || this.config.deviceType || 'urn:schemas-upnp-org:device:MediaRenderer:1',
      friendlyName: this.firstTag(deviceBlock, 'friendlyName') || this.config.name || 'DLNA Digital Media Renderer',
      manufacturer: this.firstTag(deviceBlock, 'manufacturer') || this.config.manufacturer,
      modelName: this.firstTag(deviceBlock, 'modelName') || this.config.model,
      modelNumber: this.firstTag(deviceBlock, 'modelNumber') || this.config.modelNumber,
      serialNumber: this.firstTag(deviceBlock, 'serialNumber') || this.config.serialNumber,
      macAddress: this.config.macAddress,
      services,
    };
  }

  private async safeSoap(serviceArg: IDlnaDmrServiceDescription | undefined, actionArg: string, argsArg: Record<string, string | number | boolean>): Promise<Record<string, string>> {
    if (!serviceArg) {
      return {};
    }
    try {
      return await this.soap(serviceArg, actionArg, argsArg);
    } catch {
      return {};
    }
  }

  private async soap(serviceArg: IDlnaDmrServiceDescription, actionArg: string, argsArg: Record<string, string | number | boolean | undefined>): Promise<Record<string, string>> {
    const body = this.soapBody(serviceArg.serviceType, actionArg, argsArg);
    const response = await fetch(serviceArg.controlUrl, {
      method: 'POST',
      headers: {
        SOAPAction: `"${serviceArg.serviceType}#${actionArg}"`,
        'Content-Type': 'text/xml; charset="utf-8"',
      },
      body,
      signal: AbortSignal.timeout(this.config.timeoutMs || 10000),
    });
    const text = await response.text();
    if (!response.ok) {
      throw new Error(`DLNA DMR SOAP ${actionArg} failed: ${response.status} ${text}`);
    }
    return this.parseSoapResponse(text);
  }

  private soapBody(serviceTypeArg: string, actionArg: string, argsArg: Record<string, string | number | boolean | undefined>): string {
    const args = Object.entries(argsArg)
      .filter((entryArg): entryArg is [string, string | number | boolean] => entryArg[1] !== undefined)
      .map(([keyArg, valueArg]) => `<${keyArg}>${this.escapeXml(String(valueArg))}</${keyArg}>`)
      .join('');
    return `<?xml version="1.0"?><s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><s:Body><u:${actionArg} xmlns:u="${serviceTypeArg}">${args}</u:${actionArg}></s:Body></s:Envelope>`;
  }

  private parseSoapResponse(xmlArg: string): Record<string, string> {
    const body = this.firstTagByLocalName(xmlArg, 'Body') || xmlArg;
    const responseBlock = this.extractFirstChildBlock(body) || body;
    const responseInner = this.stripOuterTag(responseBlock) || responseBlock;
    const result: Record<string, string> = {};
    const tagRegex = /<([A-Za-z0-9_:-]+)(?:\s[^>]*)?>([\s\S]*?)<\/\1>/g;
    for (const match of responseInner.matchAll(tagRegex)) {
      const localName = match[1].includes(':') ? match[1].split(':').pop() || match[1] : match[1];
      if (localName.endsWith('Response')) {
        continue;
      }
      result[localName] = this.decodeXml(match[2]);
    }
    return result;
  }

  private positionInfo(valueArg: Record<string, string>): IDlnaDmrPositionInfo {
    return {
      track: this.numberValue(valueArg.Track),
      trackDuration: valueArg.TrackDuration,
      trackDurationSeconds: this.seconds(valueArg.TrackDuration),
      trackMetaData: valueArg.TrackMetaData,
      trackUri: valueArg.TrackURI,
      relativeTime: valueArg.RelTime,
      relativeTimeSeconds: this.seconds(valueArg.RelTime),
    };
  }

  private parseMetadata(xmlArg: string | undefined): IDlnaDmrMediaMetadata {
    if (!xmlArg || xmlArg === 'NOT_IMPLEMENTED') {
      return {};
    }
    return {
      title: this.firstTagByLocalName(xmlArg, 'title'),
      artist: this.firstTagByLocalName(xmlArg, 'artist'),
      album: this.firstTagByLocalName(xmlArg, 'album'),
      albumArtist: this.firstTagByLocalName(xmlArg, 'albumArtist') || this.firstTagByLocalName(xmlArg, 'album_artist'),
      albumArtUri: this.firstTagByLocalName(xmlArg, 'albumArtURI'),
      upnpClass: this.firstTagByLocalName(xmlArg, 'class'),
      raw: xmlArg,
    };
  }

  private withDefaults(snapshotArg: IDlnaDmrSnapshot): IDlnaDmrSnapshot {
    return {
      device: snapshotArg.device,
      state: {
        ...snapshotArg.state,
        online: snapshotArg.state.online,
        transport: snapshotArg.state.transport || {},
        rendering: snapshotArg.state.rendering || {},
        media: snapshotArg.state.media || {},
        updatedAt: snapshotArg.state.updatedAt || new Date().toISOString(),
      },
    };
  }

  private location(): string | undefined {
    if (this.config.location || this.config.url) {
      return this.config.location || this.config.url;
    }
    if (!this.config.host) {
      return undefined;
    }
    const path = this.config.path || '/description.xml';
    return `http://${this.config.host}:${this.config.port || 80}${path.startsWith('/') ? path : `/${path}`}`;
  }

  private findRendererDeviceBlock(xmlArg: string): string | undefined {
    const blocks = this.extractBlocks(xmlArg, 'device');
    for (const block of blocks) {
      const deviceType = this.firstTag(block, 'deviceType');
      if (deviceType && dlnaDmrDeviceTypes.includes(deviceType)) {
        return block;
      }
    }
    for (const block of blocks) {
      const inner = this.innerBlock(block, 'device');
      const nested = inner ? this.findRendererDeviceBlock(inner) : undefined;
      if (nested) {
        return nested;
      }
    }
    return undefined;
  }

  private extractBlocks(xmlArg: string, tagArg: string): string[] {
    const regex = new RegExp(`<\\/?${tagArg}\\b[^>]*>`, 'gi');
    const blocks: string[] = [];
    let depth = 0;
    let start = -1;
    for (const match of xmlArg.matchAll(regex)) {
      const token = match[0];
      if (!token.startsWith('</')) {
        if (depth === 0) {
          start = match.index || 0;
        }
        depth += 1;
      } else {
        depth -= 1;
        if (depth === 0 && start >= 0) {
          blocks.push(xmlArg.slice(start, (match.index || 0) + token.length));
          start = -1;
        }
      }
    }
    return blocks;
  }

  private innerBlock(xmlArg: string, tagArg: string): string | undefined {
    const start = xmlArg.match(new RegExp(`<${tagArg}\\b[^>]*>`, 'i'));
    const end = xmlArg.match(new RegExp(`</${tagArg}>`, 'i'));
    if (!start || !end || start.index === undefined || end.index === undefined) {
      return undefined;
    }
    return xmlArg.slice(start.index + start[0].length, end.index);
  }

  private firstTag(xmlArg: string, tagArg: string): string | undefined {
    const escaped = tagArg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    const match = xmlArg.match(new RegExp(`<${escaped}(?:\\s[^>]*)?>([\\s\\S]*?)</${escaped}>`, 'i'));
    return match ? this.decodeXml(match[1].trim()) : undefined;
  }

  private firstTagByLocalName(xmlArg: string, localNameArg: string): string | undefined {
    const escaped = localNameArg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    const match = xmlArg.match(new RegExp(`<(?:[A-Za-z0-9_]+:)?${escaped}(?:\\s[^>]*)?>([\\s\\S]*?)</(?:[A-Za-z0-9_]+:)?${escaped}>`, 'i'));
    return match ? this.decodeXml(match[1].trim()) : undefined;
  }

  private extractFirstChildBlock(xmlArg: string): string | undefined {
    const match = xmlArg.match(/<([A-Za-z0-9_:-]+)(?:\s[^>]*)?>[\s\S]*<\/\1>/);
    return match?.[0];
  }

  private stripOuterTag(xmlArg: string): string | undefined {
    const match = xmlArg.match(/^<([A-Za-z0-9_:-]+)(?:\s[^>]*)?>([\s\S]*)<\/\1>$/);
    return match?.[2];
  }

  private absoluteUrl(baseArg: string, valueArg: string): string | undefined {
    if (!valueArg) {
      return undefined;
    }
    return new URL(valueArg, baseArg).toString();
  }

  private splitList(valueArg: string | undefined): string[] {
    if (!valueArg) {
      return [];
    }
    return valueArg.split(',').map((itemArg) => itemArg.trim()).filter(Boolean);
  }

  private numberValue(valueArg: string | undefined): number | undefined {
    if (valueArg === undefined || valueArg === '') {
      return undefined;
    }
    const value = Number(valueArg);
    return Number.isFinite(value) ? value : undefined;
  }

  private booleanValue(valueArg: string | undefined): boolean | undefined {
    if (valueArg === undefined || valueArg === '') {
      return undefined;
    }
    return valueArg === '1' || valueArg.toLowerCase() === 'true' || valueArg.toLowerCase() === 'yes';
  }

  private seconds(valueArg: string | undefined): number | undefined {
    if (!valueArg || valueArg === 'NOT_IMPLEMENTED') {
      return undefined;
    }
    const match = valueArg.match(/^(\d+):(\d{2}):(\d{2})(?:\.(\d+))?$/);
    if (!match) {
      return undefined;
    }
    return Number(match[1]) * 3600 + Number(match[2]) * 60 + Number(match[3]);
  }

  private mimeType(uriArg: string): string {
    const path = uriArg.split('?')[0].toLowerCase();
    if (path.endsWith('.mp3')) return 'audio/mpeg';
    if (path.endsWith('.flac')) return 'audio/flac';
    if (path.endsWith('.wav')) return 'audio/wav';
    if (path.endsWith('.mp4') || path.endsWith('.m4v')) return 'video/mp4';
    if (path.endsWith('.mkv')) return 'video/x-matroska';
    if (path.endsWith('.jpg') || path.endsWith('.jpeg')) return 'image/jpeg';
    if (path.endsWith('.png')) return 'image/png';
    return 'application/octet-stream';
  }

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

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