import type {
  IGo2rtcClientCommand,
  IGo2rtcCommandExecutorResult,
  IGo2rtcConfig,
  IGo2rtcRawCommandRequest,
  IGo2rtcRawStreamsResponse,
  IGo2rtcServerInfo,
  IGo2rtcSnapshot,
  IGo2rtcSnapshotImage,
} from './go2rtc.types.js';
import { go2rtcDefaultTimeoutMs } from './go2rtc.types.js';
import { Go2rtcMapper } from './go2rtc.mapper.js';

export class Go2rtcHttpError extends Error {
  constructor(public readonly status: number, messageArg: string) {
    super(messageArg);
    this.name = 'Go2rtcHttpError';
  }
}

export class Go2rtcClient {
  private snapshot?: IGo2rtcSnapshot;

  constructor(private readonly config: IGo2rtcConfig) {
    this.snapshot = config.snapshot ? this.cloneSnapshot(Go2rtcMapper.toSnapshot({ ...config, snapshot: config.snapshot }, config.snapshot.connected, config.snapshot.source)) : undefined;
  }

  public async getSnapshot(forceRefreshArg = false): Promise<IGo2rtcSnapshot> {
    if (!forceRefreshArg && this.snapshot) {
      return this.cloneSnapshot(this.snapshot);
    }

    if (!forceRefreshArg && this.config.snapshot) {
      this.snapshot = Go2rtcMapper.toSnapshot({ ...this.config, snapshot: this.config.snapshot }, this.config.snapshot.connected, this.config.snapshot.source);
      return this.cloneSnapshot(this.snapshot);
    }

    if (this.hasLiveTransport()) {
      try {
        this.snapshot = await this.fetchLiveSnapshot();
        return this.cloneSnapshot(this.snapshot);
      } catch (errorArg) {
        this.snapshot = Go2rtcMapper.toSnapshot(this.config, false, 'runtime', errorArg instanceof Error ? errorArg.message : String(errorArg));
        return this.cloneSnapshot(this.snapshot);
      }
    }

    this.snapshot = Go2rtcMapper.toSnapshot(this.config, this.config.connected ?? false, 'manual', this.config.connected ? undefined : 'go2rtc live HTTP refresh requires config.url, config.host, or commandExecutor.');
    return this.cloneSnapshot(this.snapshot);
  }

  public async validateConnection(): Promise<IGo2rtcSnapshot> {
    this.snapshot = await this.fetchLiveSnapshot();
    return this.cloneSnapshot(this.snapshot);
  }

  public async execute(commandArg: IGo2rtcClientCommand): Promise<unknown> {
    if (commandArg.type === 'refresh') {
      const snapshot = await this.getSnapshot(true);
      if (!snapshot.connected && snapshot.error) {
        throw new Error(snapshot.error);
      }
      return snapshot;
    }

    if (commandArg.type === 'stream_source') {
      const snapshot = await this.getSnapshot();
      const camera = this.findCamera(snapshot, commandArg.streamSource?.streamId || commandArg.streamId);
      const endpoint = commandArg.streamSource?.endpoint;
      return {
        streamId: camera.streamId,
        streamName: camera.streamName,
        streamSource: endpoint ? camera.endpoints[endpoint] : camera.endpoints.rtspUrl || camera.endpoints.mp4Url || camera.endpoints.mjpegUrl,
        endpoints: camera.endpoints,
        sourceUrls: camera.sourceUrls,
        verified: false,
      };
    }

    if (commandArg.type === 'snapshot_image') {
      if (commandArg.snapshot?.filename) {
        throw new Error('go2rtc snapshot file writes are not implemented; request data as base64 without data.filename.');
      }
      const image = await this.getSnapshotImage(commandArg.snapshot?.streamId || commandArg.streamId, commandArg.snapshot?.width, commandArg.snapshot?.height);
      return {
        contentType: image.contentType,
        dataBase64: Buffer.from(image.data).toString('base64'),
      };
    }

    if (commandArg.type === 'restart') {
      const response = await this.executeRaw({ label: 'restart', method: 'POST', path: '/api/restart', expect: 'empty' });
      this.snapshot = undefined;
      return { ok: true, command: commandArg.type, response };
    }

    if (commandArg.type === 'add_stream') {
      const mutation = commandArg.streamMutation;
      if (!mutation?.name || !mutation.src) {
        throw new Error('go2rtc add_stream requires name and src.');
      }
      const response = await this.executeRaw({ label: 'add_stream', method: 'PUT', path: '/api/streams', query: { name: mutation.name, src: mutation.src }, expect: 'empty' });
      this.snapshot = undefined;
      return { ok: true, command: commandArg.type, streamId: mutation.name, response };
    }

    if (commandArg.type === 'update_stream') {
      const mutation = commandArg.streamMutation;
      if (!mutation?.name || !mutation.src) {
        throw new Error('go2rtc update_stream requires name and src.');
      }
      const response = await this.executeRaw({ label: 'update_stream', method: 'PATCH', path: '/api/streams', query: { name: mutation.name, src: mutation.src }, expect: 'empty' });
      this.snapshot = undefined;
      return { ok: true, command: commandArg.type, streamId: mutation.name, response };
    }

    if (commandArg.type === 'delete_stream') {
      const streamId = commandArg.streamMutation?.name || commandArg.streamId;
      if (!streamId) {
        throw new Error('go2rtc delete_stream requires a stream id.');
      }
      const response = await this.executeRaw({ label: 'delete_stream', method: 'DELETE', path: '/api/streams', query: { src: streamId }, expect: 'empty' });
      this.snapshot = undefined;
      return { ok: true, command: commandArg.type, streamId, response };
    }

    if (commandArg.type === 'stream_to_camera') {
      const mutation = commandArg.streamMutation;
      if (!mutation?.dst || mutation.src === undefined) {
        throw new Error('go2rtc stream_to_camera requires dst and src.');
      }
      const response = await this.executeRaw({ label: 'stream_to_camera', method: 'POST', path: '/api/streams', query: { dst: mutation.dst, src: mutation.src }, expect: 'empty' });
      return { ok: true, command: commandArg.type, dst: mutation.dst, src: mutation.src, response };
    }

    throw new Error(`Unsupported go2rtc command: ${commandArg.type}`);
  }

  public async getSnapshotImage(streamIdArg?: string, widthArg?: number, heightArg?: number): Promise<IGo2rtcSnapshotImage> {
    const snapshot = await this.getSnapshot();
    const camera = this.findCamera(snapshot, streamIdArg);
    const query: IGo2rtcRawCommandRequest['query'] = { src: camera.streamId };
    if (widthArg !== undefined) {
      query.width = widthArg;
    }
    if (heightArg !== undefined) {
      query.height = heightArg;
    }
    const result = await this.executeRaw<IGo2rtcSnapshotImage>({ label: 'snapshot', method: 'GET', path: '/api/frame.jpeg', query, expect: 'image' });
    if (!result || !(result instanceof Object) || !('data' in result)) {
      throw new Error('go2rtc snapshot command did not return image data.');
    }
    return result;
  }

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

  private async fetchLiveSnapshot(): Promise<IGo2rtcSnapshot> {
    const [apiInfo, schemes, rawStreams] = await Promise.all([
      this.requestJson<Record<string, unknown>>({ label: 'api_info', method: 'GET', path: '/api', expect: 'json' }),
      this.requestJson<unknown[]>({ label: 'schemes', method: 'GET', path: '/api/schemes', expect: 'json' }),
      this.requestJson<IGo2rtcRawStreamsResponse>({ label: 'streams', method: 'GET', path: '/api/streams', expect: 'json' }),
    ]);
    const serverInfo = this.serverInfo(apiInfo);
    const normalizedConfig: IGo2rtcConfig = {
      ...this.config,
      serverInfo,
      schemes: schemes.map((schemeArg) => String(schemeArg)).filter(Boolean),
    };
    const streams = Go2rtcMapper.streamsFromRaw(rawStreams, normalizedConfig);
    return Go2rtcMapper.toSnapshot({ ...normalizedConfig, streams }, true, this.config.commandExecutor ? 'executor' : 'http');
  }

  private async requestJson<TValue>(requestArg: IGo2rtcRawCommandRequest): Promise<TValue> {
    return this.executeRaw<TValue>({ ...requestArg, expect: 'json' });
  }

  private async executeRaw<TValue = unknown>(requestArg: IGo2rtcRawCommandRequest): Promise<TValue> {
    const request = this.withRequestUrl(requestArg);
    if (this.config.commandExecutor) {
      const result = await this.config.commandExecutor.execute(request);
      return this.executorResult<TValue>(request, result);
    }
    if (!request.url) {
      throw new Error('go2rtc live HTTP command requires config.url, config.host, or commandExecutor. Static snapshots are read-only.');
    }
    const response = await this.fetchWithTimeout(request.url, {
      method: request.method,
      headers: this.headers(request),
      body: request.body as BodyInit | undefined,
    });
    return this.responseResult<TValue>(request, await this.checkedResponse(response, request));
  }

  private async executorResult<TValue>(requestArg: IGo2rtcRawCommandRequest, resultArg: unknown): Promise<TValue> {
    if (resultArg instanceof Response) {
      return this.responseResult<TValue>(requestArg, await this.checkedResponse(resultArg, requestArg));
    }
    if (resultArg instanceof ArrayBuffer) {
      return this.binaryResult<TValue>(requestArg, new Uint8Array(resultArg), 'application/octet-stream');
    }
    if (resultArg instanceof Uint8Array) {
      return this.binaryResult<TValue>(requestArg, resultArg, 'application/octet-stream');
    }
    if (typeof resultArg === 'string') {
      if (requestArg.expect === 'json') {
        return JSON.parse(resultArg) as TValue;
      }
      if (requestArg.expect === 'image') {
        return this.binaryResult<TValue>(requestArg, new TextEncoder().encode(resultArg), 'application/octet-stream');
      }
      return resultArg as TValue;
    }
    if (record(resultArg)) {
      const result = resultArg as IGo2rtcCommandExecutorResult;
      if (result.ok === false || result.success === false || result.status && result.status >= 400) {
        throw new Error(`go2rtc executor command ${requestArg.label} failed: ${JSON.stringify(resultArg)}`);
      }
      if (requestArg.expect === 'json') {
        if (result.data !== undefined) {
          return result.data as TValue;
        }
        if (typeof result.responseText === 'string') {
          return JSON.parse(result.responseText) as TValue;
        }
        if (typeof result.body === 'string') {
          return JSON.parse(result.body) as TValue;
        }
      }
      if (requestArg.expect === 'image') {
        const body = result.body instanceof ArrayBuffer
          ? new Uint8Array(result.body)
          : result.body instanceof Uint8Array
            ? result.body
            : typeof result.body === 'string'
              ? new TextEncoder().encode(result.body)
              : undefined;
        if (!body) {
          throw new Error(`go2rtc executor command ${requestArg.label} did not return image body.`);
        }
        return this.binaryResult<TValue>(requestArg, body, result.contentType || 'image/jpeg');
      }
      if (result.data !== undefined) {
        return result.data as TValue;
      }
      if (result.response !== undefined) {
        return result.response as TValue;
      }
    }
    return resultArg as TValue;
  }

  private async responseResult<TValue>(requestArg: IGo2rtcRawCommandRequest, responseArg: Response): Promise<TValue> {
    if (requestArg.expect === 'image') {
      return this.binaryResult<TValue>(requestArg, new Uint8Array(await responseArg.arrayBuffer()), responseArg.headers.get('content-type') || 'image/jpeg');
    }
    if (requestArg.expect === 'json') {
      const text = await responseArg.text();
      return (text.trim() ? JSON.parse(text) : {}) as TValue;
    }
    if (requestArg.expect === 'text') {
      return await responseArg.text() as TValue;
    }
    return undefined as TValue;
  }

  private binaryResult<TValue>(requestArg: IGo2rtcRawCommandRequest, dataArg: Uint8Array, contentTypeArg: string): TValue {
    if (requestArg.expect !== 'image') {
      return dataArg as TValue;
    }
    return { contentType: contentTypeArg, data: dataArg } as TValue;
  }

  private async checkedResponse(responseArg: Response, requestArg: IGo2rtcRawCommandRequest): Promise<Response> {
    if (!responseArg.ok) {
      const text = await responseArg.text().catch(() => '');
      if (responseArg.status === 401) {
        throw new Go2rtcHttpError(responseArg.status, 'go2rtc authentication failed.');
      }
      throw new Go2rtcHttpError(responseArg.status, `go2rtc request ${requestArg.path} failed with HTTP ${responseArg.status}${text ? `: ${text}` : ''}`);
    }
    return responseArg;
  }

  private async fetchWithTimeout(urlArg: string, initArg: RequestInit): Promise<Response> {
    const abortController = new AbortController();
    const timeout = setTimeout(() => abortController.abort(), this.config.timeoutMs || go2rtcDefaultTimeoutMs);
    try {
      return await globalThis.fetch(urlArg, { ...initArg, signal: abortController.signal });
    } finally {
      clearTimeout(timeout);
    }
  }

  private headers(requestArg: IGo2rtcRawCommandRequest): Headers {
    const headers = new Headers(requestArg.headers);
    if (this.config.username) {
      headers.set('authorization', `Basic ${Buffer.from(`${this.config.username}:${this.config.password || ''}`, 'utf8').toString('base64')}`);
    }
    return headers;
  }

  private withRequestUrl(requestArg: IGo2rtcRawCommandRequest): IGo2rtcRawCommandRequest {
    return {
      ...requestArg,
      url: requestArg.url || this.requestUrl(requestArg),
    };
  }

  private requestUrl(requestArg: IGo2rtcRawCommandRequest): string | undefined {
    const endpoint = Go2rtcMapper.endpointFromConfig(this.config);
    if (!endpoint.url) {
      return undefined;
    }
    const url = new URL(requestArg.path, endpoint.url.endsWith('/') ? endpoint.url : `${endpoint.url}/`);
    for (const [key, value] of Object.entries(requestArg.query || {})) {
      if (value !== undefined) {
        url.searchParams.set(key, String(value));
      }
    }
    return url.toString();
  }

  private serverInfo(rawArg: Record<string, unknown>): IGo2rtcServerInfo {
    const rtsp = record(rawArg.rtsp);
    return {
      version: stringValue(rawArg.version),
      configPath: stringValue(rawArg.config_path ?? rawArg.configPath),
      host: stringValue(rawArg.host),
      rtsp: rtsp ? {
        listen: stringValue(rtsp.listen),
        defaultQuery: stringValue(rtsp.default_query ?? rtsp.defaultQuery),
      } : undefined,
      raw: rawArg,
    };
  }

  private findCamera(snapshotArg: IGo2rtcSnapshot, streamIdArg?: string) {
    const camera = streamIdArg
      ? snapshotArg.cameras.find((cameraArg) => cameraArg.streamId === streamIdArg || cameraArg.streamName === streamIdArg || cameraArg.id === streamIdArg)
      : snapshotArg.cameras[0];
    if (!camera) {
      throw new Error('go2rtc command requires a configured or discovered stream.');
    }
    return camera;
  }

  private hasLiveTransport(): boolean {
    return Boolean(this.config.commandExecutor || Go2rtcMapper.endpointFromConfig(this.config).url);
  }

  private cloneSnapshot(snapshotArg: IGo2rtcSnapshot): IGo2rtcSnapshot {
    return JSON.parse(JSON.stringify(snapshotArg)) as IGo2rtcSnapshot;
  }
}

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

const stringValue = (valueArg: unknown): string | undefined => {
  if (typeof valueArg === 'string' && valueArg.trim()) {
    return valueArg.trim();
  }
  if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
    return String(valueArg);
  }
  return undefined;
};
