import * as plugins from '../../plugins.js';
import { OnvifMapper } from './onvif.mapper.js';
import type {
  IOnvifCameraSnapshot,
  IOnvifClientCommand,
  IOnvifConfig,
  IOnvifDeviceInfo,
  IOnvifEvent,
  IOnvifProfile,
  IOnvifServiceDescriptor,
  IOnvifSnapshot,
  IOnvifStream,
} from './onvif.types.js';
import { onvifDefaultDeviceServicePath, onvifDefaultPort } from './onvif.types.js';

type TEventHandler = (eventArg: IOnvifEvent) => void;

const namespaces = {
  device: 'http://www.onvif.org/ver10/device/wsdl',
  media: 'http://www.onvif.org/ver10/media/wsdl',
};

export class OnvifClient {
  private readonly eventHandlers = new Set<TEventHandler>();
  private readonly events: IOnvifEvent[] = [];
  private snapshot?: IOnvifSnapshot;
  private liveProbeAttempted = false;

  constructor(private readonly config: IOnvifConfig) {
    this.snapshot = config.snapshot;
  }

  public async getSnapshot(forceRefreshArg = false): Promise<IOnvifSnapshot> {
    if (forceRefreshArg) {
      this.snapshot = undefined;
      this.liveProbeAttempted = false;
    }

    if (this.snapshot && !forceRefreshArg) {
      this.snapshot = OnvifMapper.toSnapshot({ ...this.config, snapshot: this.snapshot }, this.snapshot.connected, this.events);
      return this.snapshot;
    }

    if (this.config.host && this.config.soap?.liveProbe === true && !this.liveProbeAttempted) {
      this.liveProbeAttempted = true;
      try {
        this.snapshot = await this.fetchLiveSnapshot();
        return this.snapshot;
      } catch (error) {
        this.snapshot = OnvifMapper.toSnapshot({
          ...this.config,
          metadata: {
            ...this.config.metadata,
            lastLiveProbeError: error instanceof Error ? error.message : String(error),
          },
        }, false, this.events);
        return this.snapshot;
      }
    }

    this.snapshot = OnvifMapper.toSnapshot(this.config, false, this.events);
    return this.snapshot;
  }

  public async execute(commandArg: IOnvifClientCommand): Promise<unknown> {
    if (commandArg.type === 'snapshot' || commandArg.type === 'refresh') {
      return this.getSnapshot(commandArg.type === 'refresh');
    }
    if (commandArg.type === 'stream_metadata') {
      return this.streamMetadata(commandArg);
    }
    if (commandArg.type === 'snapshot_metadata') {
      return this.snapshotMetadata(commandArg);
    }
    if (commandArg.type === 'ptz') {
      this.throwUnsupportedLiveOperation('PTZ commands');
    }
    if (commandArg.type === 'subscribe_events') {
      this.throwUnsupportedLiveOperation('event subscriptions');
    }
    throw new Error(`Unsupported ONVIF command: ${commandArg.type}`);
  }

  public onEvent(handlerArg: TEventHandler): () => void {
    this.eventHandlers.add(handlerArg);
    for (const event of this.events) {
      handlerArg(event);
    }
    return () => this.eventHandlers.delete(handlerArg);
  }

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

  private async streamMetadata(commandArg: IOnvifClientCommand): Promise<unknown> {
    const snapshot = await this.getSnapshot();
    const target = this.findProfile(snapshot, commandArg.profileToken);
    if (!target) {
      throw new Error('ONVIF stream metadata requires a mapped profile token or a cached camera profile.');
    }
    const stream = this.streamForProfile(target.camera, target.profile);
    if (!stream?.uri && this.config.soap?.liveProbe !== true) {
      throw new Error('ONVIF stream URI is not cached. Enable soap.liveProbe for basic live GetStreamUri probing, or provide profiles/streams in the snapshot config.');
    }
    return {
      profileToken: target.profile.token,
      profileName: target.profile.name,
      uri: stream?.uri || target.profile.streamUri,
      protocol: stream?.protocol || this.protocolForUri(stream?.uri || target.profile.streamUri),
      encoding: stream?.encoding || target.profile.video?.encoding,
      resolution: stream?.resolution || target.profile.video?.resolution,
    };
  }

  private async snapshotMetadata(commandArg: IOnvifClientCommand): Promise<unknown> {
    const snapshot = await this.getSnapshot();
    const target = this.findProfile(snapshot, commandArg.profileToken);
    if (!target) {
      throw new Error('ONVIF snapshot metadata requires a mapped profile token or a cached camera profile.');
    }
    if (commandArg.snapshot?.fetchImage) {
      if (!target.profile.snapshotUri) {
        throw new Error('ONVIF snapshot image fetch requires a cached snapshot URI. Live snapshot image authentication is not implemented in this native port.');
      }
      return this.fetchSnapshotImage(target.profile.snapshotUri);
    }
    return {
      profileToken: target.profile.token,
      profileName: target.profile.name,
      uri: target.profile.snapshotUri || null,
      available: Boolean(target.profile.snapshotUri || target.camera.capabilities?.snapshot),
      fetchImageImplemented: Boolean(target.profile.snapshotUri),
    };
  }

  private async fetchSnapshotImage(uriArg: string): Promise<unknown> {
    const response = await fetch(uriArg, {
      headers: this.config.soap?.basicAuth && this.config.username ? {
        Authorization: `Basic ${Buffer.from(`${this.config.username}:${this.config.password || ''}`).toString('base64')}`,
      } : undefined,
    });
    if (!response.ok) {
      throw new Error(`ONVIF snapshot image fetch failed with HTTP ${response.status}. Digest-authenticated snapshot fetches are not implemented.`);
    }
    const body = Buffer.from(await response.arrayBuffer());
    return {
      contentType: response.headers.get('content-type') || 'application/octet-stream',
      byteLength: body.byteLength,
      body,
    };
  }

  private throwUnsupportedLiveOperation(operationArg: string): never {
    throw new Error(`Live ONVIF ${operationArg} are not implemented in this native port. Cached PTZ/event metadata is mapped, but secure/live PTZ and PullPoint/webhook event operations require a full ONVIF service implementation.`);
  }

  private async fetchLiveSnapshot(): Promise<IOnvifSnapshot> {
    const deviceServiceUrl = this.deviceServiceUrl();
    const servicesXml = await this.soapRequest(deviceServiceUrl, namespaces.device, 'GetServices', '<tds:GetServices><tds:IncludeCapability>false</tds:IncludeCapability></tds:GetServices>');
    const services = this.parseServices(servicesXml);
    const deviceInfoXml = await this.soapRequest(deviceServiceUrl, namespaces.device, 'GetDeviceInformation', '<tds:GetDeviceInformation/>');
    const capabilitiesXml = await this.soapRequest(deviceServiceUrl, namespaces.device, 'GetCapabilities', '<tds:GetCapabilities><tds:Category>All</tds:Category></tds:GetCapabilities>');
    const mediaServiceUrl = this.serviceUrl(services, namespaces.media) || this.firstCapabilityXaddr(capabilitiesXml, 'Media');
    const deviceInfo = this.parseDeviceInfo(deviceInfoXml);
    const capabilities = {
      media: Boolean(mediaServiceUrl),
      stream: Boolean(mediaServiceUrl),
      snapshot: /SnapshotUri\s*=\s*['"]true['"]/i.test(capabilitiesXml),
      ptz: /<[^>]*PTZ\b/i.test(capabilitiesXml) || services.some((serviceArg) => /ptz/i.test(serviceArg.namespace)),
      events: /<[^>]*Events\b/i.test(capabilitiesXml) || services.some((serviceArg) => /event/i.test(serviceArg.namespace)),
      pullPointEvents: /WSPullPointSupport\s*=\s*['"]true['"]/i.test(capabilitiesXml),
      raw: { capabilitiesXmlLength: capabilitiesXml.length },
    };
    const profiles = mediaServiceUrl ? await this.fetchProfiles(mediaServiceUrl) : [];
    const streams: IOnvifStream[] = [];

    if (mediaServiceUrl && this.config.soap?.fetchStreamUris !== false) {
      for (const profile of profiles) {
        try {
          const uri = await this.fetchStreamUri(mediaServiceUrl, profile.token);
          profile.streamUri = uri;
          streams.push({ profileToken: profile.token, uri, protocol: this.protocolForUri(uri), encoding: profile.video?.encoding, resolution: profile.video?.resolution });
        } catch {
          continue;
        }
      }
    }

    if (mediaServiceUrl && this.config.soap?.fetchSnapshotUris !== false) {
      for (const profile of profiles) {
        try {
          profile.snapshotUri = await this.fetchSnapshotUri(mediaServiceUrl, profile.token);
        } catch {
          continue;
        }
      }
    }

    const camera: IOnvifCameraSnapshot = {
      id: this.config.id || deviceInfo.macAddress || deviceInfo.serialNumber || this.config.host,
      name: this.config.name || deviceInfo.name || this.config.host,
      host: this.config.host,
      port: this.config.port || onvifDefaultPort,
      transport: this.config.transport || 'http',
      online: true,
      deviceInfo,
      capabilities,
      profiles,
      streams,
      events: this.config.events || [],
      services,
      metadata: {
        deviceServiceUrl,
        mediaServiceUrl,
        liveProbeImplemented: true,
        livePtzImplemented: false,
        liveEventsImplemented: false,
      },
    };

    return {
      id: camera.id,
      name: camera.name,
      host: this.config.host,
      port: this.config.port || onvifDefaultPort,
      transport: this.config.transport || 'http',
      connected: true,
      configured: true,
      deviceInfo,
      capabilities,
      profiles,
      streams,
      events: this.config.events || [],
      services,
      cameras: [camera],
      discovery: this.config.discovery,
      metadata: {
        liveProbeImplemented: true,
        livePtzImplemented: false,
        liveEventsImplemented: false,
      },
    };
  }

  private async fetchProfiles(mediaServiceUrlArg: string): Promise<IOnvifProfile[]> {
    const xml = await this.soapRequest(mediaServiceUrlArg, namespaces.media, 'GetProfiles', '<trt:GetProfiles/>');
    return this.parseProfiles(xml);
  }

  private async fetchStreamUri(mediaServiceUrlArg: string, profileTokenArg: string): Promise<string> {
    const xml = await this.soapRequest(mediaServiceUrlArg, namespaces.media, 'GetStreamUri', `<trt:GetStreamUri><trt:StreamSetup><tt:Stream>RTP-Unicast</tt:Stream><tt:Transport><tt:Protocol>RTSP</tt:Protocol></tt:Transport></trt:StreamSetup><trt:ProfileToken>${this.escapeXml(profileTokenArg)}</trt:ProfileToken></trt:GetStreamUri>`);
    const uri = this.firstText(xml, 'Uri');
    if (!uri) {
      throw new Error('ONVIF GetStreamUri response did not contain Uri.');
    }
    return uri;
  }

  private async fetchSnapshotUri(mediaServiceUrlArg: string, profileTokenArg: string): Promise<string> {
    const xml = await this.soapRequest(mediaServiceUrlArg, namespaces.media, 'GetSnapshotUri', `<trt:GetSnapshotUri><trt:ProfileToken>${this.escapeXml(profileTokenArg)}</trt:ProfileToken></trt:GetSnapshotUri>`);
    const uri = this.firstText(xml, 'Uri');
    if (!uri) {
      throw new Error('ONVIF GetSnapshotUri response did not contain Uri.');
    }
    return uri;
  }

  private async soapRequest(urlArg: string, namespaceArg: string, actionArg: string, bodyArg: string): Promise<string> {
    const actionUri = `${namespaceArg}/${actionArg}`;
    const controller = new AbortController();
    const timeout = setTimeout(() => controller.abort(), this.config.soap?.timeoutMs || 8000);
    const headers: Record<string, string> = {
      'Content-Type': `application/soap+xml; charset=utf-8; action="${actionUri}"`,
      SOAPAction: `"${actionUri}"`,
    };
    if (this.config.soap?.basicAuth && this.config.username) {
      headers.Authorization = `Basic ${Buffer.from(`${this.config.username}:${this.config.password || ''}`).toString('base64')}`;
    }
    try {
      const response = await fetch(urlArg, {
        method: 'POST',
        headers,
        body: this.soapEnvelope(bodyArg),
        signal: controller.signal,
      });
      const text = await response.text();
      if (!response.ok) {
        throw new Error(`ONVIF SOAP ${actionArg} failed with HTTP ${response.status}: ${this.compactXml(text)}`);
      }
      if (/<(?:\w+:)?Fault\b/i.test(text)) {
        throw new Error(`ONVIF SOAP ${actionArg} returned fault: ${this.compactXml(text)}`);
      }
      return text;
    } finally {
      clearTimeout(timeout);
    }
  }

  private soapEnvelope(bodyArg: string): string {
    return `<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope" xmlns:tds="${namespaces.device}" xmlns:trt="${namespaces.media}" xmlns:tt="http://www.onvif.org/ver10/schema">${this.securityHeader()}<s:Body>${bodyArg}</s:Body></s:Envelope>`;
  }

  private securityHeader(): string {
    if (!this.config.username || this.config.soap?.wsSecurityUsernameToken === false) {
      return '';
    }
    const nonce = plugins.crypto.randomBytes(16);
    const created = new Date().toISOString();
    const password = this.config.password || '';
    const digest = plugins.crypto.createHash('sha1').update(Buffer.concat([nonce, Buffer.from(created + password)])).digest('base64');
    return `<s:Header><wsse:Security s:mustUnderstand="1" xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd" xmlns:wsu="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd"><wsse:UsernameToken><wsse:Username>${this.escapeXml(this.config.username)}</wsse:Username><wsse:Password Type="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordDigest">${digest}</wsse:Password><wsse:Nonce EncodingType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary">${nonce.toString('base64')}</wsse:Nonce><wsu:Created>${created}</wsu:Created></wsse:UsernameToken></wsse:Security></s:Header>`;
  }

  private deviceServiceUrl(): string {
    const transport = this.config.transport || 'http';
    const host = this.config.host || 'localhost';
    const port = this.config.port || onvifDefaultPort;
    const path = this.config.deviceServicePath || onvifDefaultDeviceServicePath;
    if (/^https?:\/\//i.test(path)) {
      return path;
    }
    return `${transport}://${host}:${port}${path.startsWith('/') ? path : `/${path}`}`;
  }

  private parseDeviceInfo(xmlArg: string): IOnvifDeviceInfo {
    return {
      name: this.config.name,
      manufacturer: this.firstText(xmlArg, 'Manufacturer'),
      model: this.firstText(xmlArg, 'Model'),
      firmwareVersion: this.firstText(xmlArg, 'FirmwareVersion'),
      serialNumber: this.firstText(xmlArg, 'SerialNumber'),
      hardwareId: this.firstText(xmlArg, 'HardwareId'),
      host: this.config.host,
      port: this.config.port || onvifDefaultPort,
      configurationUrl: this.config.host ? `${this.config.transport || 'http'}://${this.config.host}:${this.config.port || onvifDefaultPort}` : undefined,
    };
  }

  private parseServices(xmlArg: string): IOnvifServiceDescriptor[] {
    const services: IOnvifServiceDescriptor[] = [];
    for (const block of this.blocks(xmlArg, 'Service')) {
      const namespace = this.firstText(block, 'Namespace');
      const xaddr = this.firstText(block, 'XAddr');
      if (!namespace || !xaddr) {
        continue;
      }
      services.push({
        namespace,
        xaddr,
        version: this.compactXml(this.block(block, 'Version') || ''),
      });
    }
    return services;
  }

  private parseProfiles(xmlArg: string): IOnvifProfile[] {
    const profiles: IOnvifProfile[] = [];
    let index = 0;
    for (const block of this.blocks(xmlArg, 'Profiles')) {
      const token = this.attribute(block, 'token') || this.firstText(block, 'token') || `profile_${index}`;
      const videoEncoder = this.block(block, 'VideoEncoderConfiguration') || '';
      const resolution = this.block(videoEncoder, 'Resolution') || '';
      const ptzBlock = this.block(block, 'PTZConfiguration') || '';
      const videoSource = this.block(block, 'VideoSourceConfiguration') || '';
      profiles.push({
        index,
        token,
        name: this.firstText(block, 'Name') || `Profile ${index}`,
        video: {
          encoding: this.firstText(videoEncoder, 'Encoding'),
          resolution: {
            width: this.numberValue(this.firstText(resolution, 'Width')) || 0,
            height: this.numberValue(this.firstText(resolution, 'Height')) || 0,
          },
          frameRate: this.numberValue(this.firstText(videoEncoder, 'FrameRateLimit')),
          bitrate: this.numberValue(this.firstText(videoEncoder, 'BitrateLimit')),
        },
        ptz: ptzBlock ? {
          continuous: /DefaultContinuousPanTiltVelocitySpace/i.test(ptzBlock),
          relative: /DefaultRelativePanTiltTranslationSpace/i.test(ptzBlock),
          absolute: /DefaultAbsolutePanTiltPositionSpace|DefaultAbsolutePantTiltPositionSpace/i.test(ptzBlock),
        } : undefined,
        videoSourceToken: this.firstText(videoSource, 'SourceToken'),
      });
      index += 1;
    }
    return profiles;
  }

  private serviceUrl(servicesArg: IOnvifServiceDescriptor[], namespaceArg: string): string | undefined {
    return servicesArg.find((serviceArg) => serviceArg.namespace === namespaceArg || serviceArg.namespace.includes(namespaceArg))?.xaddr;
  }

  private firstCapabilityXaddr(xmlArg: string, capabilityArg: string): string | undefined {
    const block = this.block(xmlArg, capabilityArg);
    return block ? this.firstText(block, 'XAddr') : undefined;
  }

  private findProfile(snapshotArg: IOnvifSnapshot, profileTokenArg?: string): { camera: IOnvifCameraSnapshot; profile: IOnvifProfile } | undefined {
    for (const camera of snapshotArg.cameras) {
      const profile = profileTokenArg
        ? camera.profiles.find((profileArg) => profileArg.token === profileTokenArg)
        : camera.profiles[0];
      if (profile) {
        return { camera, profile };
      }
    }
    return undefined;
  }

  private streamForProfile(cameraArg: IOnvifCameraSnapshot, profileArg: IOnvifProfile): IOnvifStream | undefined {
    return cameraArg.streams?.find((streamArg) => streamArg.profileToken === profileArg.token)
      || (profileArg.streamUri ? { profileToken: profileArg.token, uri: profileArg.streamUri, protocol: this.protocolForUri(profileArg.streamUri), encoding: profileArg.video?.encoding, resolution: profileArg.video?.resolution } : undefined);
  }

  private protocolForUri(uriArg?: string): 'rtsp' | 'rtsps' | 'http' | 'https' | 'unknown' {
    if (!uriArg) {
      return 'unknown';
    }
    const protocol = uriArg.split(':', 1)[0]?.toLowerCase();
    return protocol === 'rtsp' || protocol === 'rtsps' || protocol === 'http' || protocol === 'https' ? protocol : 'unknown';
  }

  private blocks(xmlArg: string, localNameArg: string): string[] {
    const blocks: string[] = [];
    const pattern = new RegExp(`<([\\w.-]+:)?${localNameArg}\\b[^>]*>[\\s\\S]*?<\\/([\\w.-]+:)?${localNameArg}>`, 'gi');
    for (const match of xmlArg.matchAll(pattern)) {
      blocks.push(match[0]);
    }
    return blocks;
  }

  private block(xmlArg: string, localNameArg: string): string | undefined {
    return this.blocks(xmlArg, localNameArg)[0];
  }

  private firstText(xmlArg: string, localNameArg: string): string | undefined {
    const pattern = new RegExp(`<([\\w.-]+:)?${localNameArg}\\b[^>]*>([\\s\\S]*?)<\\/([\\w.-]+:)?${localNameArg}>`, 'i');
    const match = xmlArg.match(pattern);
    if (!match) {
      return undefined;
    }
    return this.decodeXml(match[2].replace(/<[^>]+>/g, '').trim()) || undefined;
  }

  private attribute(xmlArg: string, attributeArg: string): string | undefined {
    const match = xmlArg.match(new RegExp(`${attributeArg}=["']([^"']+)["']`, 'i'));
    return match ? this.decodeXml(match[1]) : undefined;
  }

  private compactXml(xmlArg: string): string {
    return xmlArg.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 500);
  }

  private numberValue(valueArg: unknown): number | undefined {
    if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
      return valueArg;
    }
    if (typeof valueArg === 'string' && valueArg.trim()) {
      const parsed = Number(valueArg);
      return Number.isFinite(parsed) ? parsed : undefined;
    }
    return undefined;
  }

  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, '&');
  }
}
