import * as plugins from '../../plugins.js';
import { PrusaLinkMapper } from './prusalink.mapper.js';
import type {
  IPrusaLinkClientLike,
  IPrusaLinkCommandRequest,
  IPrusaLinkConfig,
  IPrusaLinkJobInfo,
  IPrusaLinkLegacyPrinterStatus,
  IPrusaLinkPrinterInfo,
  IPrusaLinkPrinterStatus,
  IPrusaLinkRawData,
  IPrusaLinkRefreshResult,
  IPrusaLinkSnapshot,
  IPrusaLinkVersionInfo,
} from './prusalink.types.js';
import { prusaLinkDefaultPort, prusaLinkDefaultTimeoutMs, prusaLinkDefaultUsername } from './prusalink.types.js';

export class PrusaLinkApiError extends Error {}
export class PrusaLinkApiConnectionError extends PrusaLinkApiError {}
export class PrusaLinkApiAuthorizationError extends PrusaLinkApiError {}
export class PrusaLinkApiConflictError extends PrusaLinkApiError {}
export class PrusaLinkApiNotFoundError extends PrusaLinkApiError {}

interface IDigestChallenge {
  realm?: string;
  nonce?: string;
  qop?: string;
  opaque?: string;
  algorithm?: string;
}

export class PrusaLinkClient {
  private currentSnapshot?: IPrusaLinkSnapshot;
  private nonceCounter = 0;

  constructor(private readonly config: IPrusaLinkConfig) {}

  public async getSnapshot(): Promise<IPrusaLinkSnapshot> {
    if (this.config.snapshot) {
      this.currentSnapshot = PrusaLinkMapper.toSnapshot({ config: this.config, source: 'snapshot', online: this.config.snapshot.online });
      return this.cloneSnapshot(this.currentSnapshot);
    }

    if (this.config.client) {
      try {
        this.currentSnapshot = await this.snapshotFromClient(this.config.client);
      } catch (errorArg) {
        this.currentSnapshot = this.offlineSnapshot(this.errorMessage(errorArg));
      }
      return this.cloneSnapshot(this.currentSnapshot);
    }

    if (this.hasManualData()) {
      this.currentSnapshot = PrusaLinkMapper.toSnapshot({ config: this.config, online: this.config.online ?? true, source: 'manual' });
      return this.cloneSnapshot(this.currentSnapshot);
    }

    if (this.config.host) {
      try {
        this.currentSnapshot = await this.fetchSnapshot();
      } catch (errorArg) {
        this.currentSnapshot = this.offlineSnapshot(this.errorMessage(errorArg));
      }
      return this.cloneSnapshot(this.currentSnapshot);
    }

    this.currentSnapshot = this.offlineSnapshot('No PrusaLink HTTP endpoint, client, or snapshot/manual data is configured.');
    return this.cloneSnapshot(this.currentSnapshot);
  }

  public async refresh(): Promise<IPrusaLinkRefreshResult> {
    if (this.config.snapshot || this.config.client || this.hasManualData()) {
      const snapshot = await this.getSnapshot();
      return { success: snapshot.online && !snapshot.error, snapshot, data: { source: snapshot.source } };
    }

    if (!this.config.host) {
      const snapshot = await this.getSnapshot();
      return { success: false, snapshot, error: 'PrusaLink refresh requires a configured HTTP endpoint, client, or snapshot/manual data.' };
    }

    try {
      const snapshot = await this.fetchSnapshot();
      this.currentSnapshot = snapshot;
      return { success: true, snapshot: this.cloneSnapshot(snapshot), data: { source: 'http' } };
    } catch (errorArg) {
      const error = this.errorMessage(errorArg);
      const snapshot = this.offlineSnapshot(error);
      this.currentSnapshot = snapshot;
      return { success: false, snapshot: this.cloneSnapshot(snapshot), error };
    }
  }

  public async ping(): Promise<boolean> {
    const snapshot = await this.getSnapshot();
    return snapshot.online && snapshot.source !== 'runtime' && !snapshot.error;
  }

  public hasUsableSource(): boolean {
    return Boolean(this.config.host || this.config.snapshot || this.config.client || this.hasManualData());
  }

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

  public async fetchSnapshot(): Promise<IPrusaLinkSnapshot> {
    const version = await this.requestJson<IPrusaLinkVersionInfo>('GET', '/api/version');
    const status = await this.requestJson<IPrusaLinkPrinterStatus>('GET', '/api/v1/status');
    const job = await this.requestJson<IPrusaLinkJobInfo>('GET', '/api/v1/job', undefined, true);
    const info = await this.requestJson<IPrusaLinkPrinterInfo>('GET', '/api/v1/info');
    const legacyStatus = await this.requestLegacyStatus();
    return PrusaLinkMapper.toSnapshot({
      config: this.config,
      rawData: { version, status, job, info, legacyStatus },
      online: true,
      source: 'http',
    });
  }

  public async getFile(pathArg: string): Promise<Uint8Array> {
    if (this.config.client?.getFile) {
      const result = await this.config.client.getFile(pathArg);
      return result instanceof Uint8Array ? result : new Uint8Array(result);
    }
    if (!this.config.host) {
      throw new PrusaLinkApiConnectionError('PrusaLink file fetch requires config.host or config.client.getFile.');
    }
    const response = await this.fetchWithAuth('GET', pathArg, { headers: { accept: '*/*' } });
    await this.assertOk(response, pathArg, true);
    return new Uint8Array(await response.arrayBuffer());
  }

  public async execute(commandArg: IPrusaLinkCommandRequest): Promise<unknown> {
    if (this.config.commandExecutor) {
      return this.config.commandExecutor.execute(commandArg);
    }
    if (!commandArg.path || !commandArg.method) {
      throw new PrusaLinkApiError(`PrusaLink ${commandArg.action} is not represented by the native HTTP API.`);
    }
    if (!this.config.host) {
      throw new PrusaLinkApiConnectionError('PrusaLink commands require config.host or commandExecutor.');
    }
    return this.requestJson(commandArg.method, commandArg.path, undefined, true);
  }

  private async snapshotFromClient(clientArg: IPrusaLinkClientLike): Promise<IPrusaLinkSnapshot> {
    if (clientArg.getSnapshot) {
      const result = await clientArg.getSnapshot();
      if (isPrusaLinkSnapshot(result)) {
        return PrusaLinkMapper.toSnapshot({ config: { ...this.config, snapshot: result }, source: 'client', online: result.online });
      }
      return PrusaLinkMapper.toSnapshot({ config: this.config, rawData: result, online: true, source: 'client' });
    }

    const version = clientArg.getVersion ? await clientArg.getVersion() : undefined;
    const status = clientArg.getStatus ? await clientArg.getStatus() : undefined;
    const job = clientArg.getJob ? await clientArg.getJob() : undefined;
    const info = clientArg.getInfo ? await clientArg.getInfo() : undefined;
    const legacyStatus = clientArg.getLegacyPrinter ? await clientArg.getLegacyPrinter() : undefined;
    if (!version && !status && !job && !info && !legacyStatus) {
      throw new PrusaLinkApiConnectionError('PrusaLink client must expose getSnapshot() or at least one raw API getter.');
    }
    return PrusaLinkMapper.toSnapshot({ config: this.config, rawData: { version, status, job, info, legacyStatus }, online: true, source: 'client' });
  }

  private async requestLegacyStatus(): Promise<IPrusaLinkLegacyPrinterStatus | undefined> {
    try {
      return await this.requestJson<IPrusaLinkLegacyPrinterStatus>('GET', '/api/printer');
    } catch (errorArg) {
      if (errorArg instanceof PrusaLinkApiNotFoundError) {
        return undefined;
      }
      throw errorArg;
    }
  }

  private async requestJson<TValue>(methodArg: string, pathArg: string, jsonDataArg?: Record<string, unknown>, allowEmptyArg = false): Promise<TValue> {
    const headers: Record<string, string> = { accept: 'application/json' };
    let body: BodyInit | undefined;
    if (jsonDataArg) {
      headers['content-type'] = 'application/json';
      body = JSON.stringify(jsonDataArg);
    }

    const response = await this.fetchWithAuth(methodArg, pathArg, { method: methodArg, headers, body });
    await this.assertOk(response, pathArg, allowEmptyArg);
    if (response.status === 204) {
      return {} as TValue;
    }
    const text = await response.text();
    if (!text.trim()) {
      if (allowEmptyArg) {
        return {} as TValue;
      }
      throw new PrusaLinkApiConnectionError(`PrusaLink endpoint ${pathArg} returned an empty response.`);
    }
    try {
      return JSON.parse(text) as TValue;
    } catch (errorArg) {
      throw new PrusaLinkApiConnectionError(`Unable to parse PrusaLink data from ${pathArg}: ${this.errorMessage(errorArg)}`);
    }
  }

  private async fetchWithAuth(methodArg: string, pathArg: string, initArg: RequestInit): Promise<Response> {
    const url = this.url(pathArg);
    const requestInit = this.requestInit(initArg);
    let response: Response;
    try {
      response = await globalThis.fetch(url, requestInit);
    } catch (errorArg) {
      throw new PrusaLinkApiConnectionError(`Connection to ${url} failed: ${this.errorMessage(errorArg)}`);
    }

    if (response.status !== 401 || !this.config.username && !this.config.password) {
      return response;
    }

    const challenge = response.headers.get('www-authenticate') || '';
    await response.arrayBuffer().catch(() => undefined);
    const authorization = this.authorizationHeader(methodArg, url, challenge);
    if (!authorization) {
      return response;
    }

    try {
      return await globalThis.fetch(url, this.requestInit({
        ...initArg,
        headers: {
          ...this.headers(initArg.headers),
          authorization,
        },
      }));
    } catch (errorArg) {
      throw new PrusaLinkApiConnectionError(`Connection to ${url} failed: ${this.errorMessage(errorArg)}`);
    }
  }

  private async assertOk(responseArg: Response, pathArg: string, allowEmptyArg = false): Promise<void> {
    void allowEmptyArg;
    if (responseArg.status === 401) {
      throw new PrusaLinkApiAuthorizationError('Please check your PrusaLink credentials.');
    }
    if (responseArg.status === 409) {
      throw new PrusaLinkApiConflictError('Action conflicts with current printer state.');
    }
    if (responseArg.status === 404) {
      throw new PrusaLinkApiNotFoundError(`PrusaLink endpoint ${pathArg} was not found.`);
    }
    if (!responseArg.ok) {
      const text = await responseArg.text().catch(() => '');
      throw new PrusaLinkApiConnectionError(`PrusaLink endpoint ${pathArg} failed with HTTP ${responseArg.status}${text ? `: ${text}` : ''}`);
    }
  }

  private requestInit(initArg: RequestInit): RequestInit {
    return {
      ...initArg,
      headers: this.headers(initArg.headers),
      signal: initArg.signal || AbortSignal.timeout(this.config.timeoutMs || prusaLinkDefaultTimeoutMs),
    };
  }

  private headers(headersArg: RequestInit['headers']): Record<string, string> {
    const headers: Record<string, string> = {};
    if (headersArg instanceof Headers) {
      headersArg.forEach((valueArg, keyArg) => {
        headers[keyArg.toLowerCase()] = valueArg;
      });
      return headers;
    }
    if (Array.isArray(headersArg)) {
      for (const [key, value] of headersArg) {
        headers[key.toLowerCase()] = value;
      }
      return headers;
    }
    for (const [key, value] of Object.entries(headersArg || {})) {
      if (value !== undefined) {
        headers[key.toLowerCase()] = String(value);
      }
    }
    return headers;
  }

  private authorizationHeader(methodArg: string, urlArg: string, challengeHeaderArg: string): string | undefined {
    if (/^basic\s+/i.test(challengeHeaderArg)) {
      return `Basic ${Buffer.from(`${this.username()}:${this.config.password || ''}`).toString('base64')}`;
    }
    if (!/^digest\s+/i.test(challengeHeaderArg)) {
      return undefined;
    }

    const challenge = this.digestChallenge(challengeHeaderArg);
    if (!challenge.realm || !challenge.nonce) {
      return undefined;
    }

    const url = new URL(urlArg);
    const uri = `${url.pathname}${url.search}`;
    const username = this.username();
    const password = this.config.password || '';
    const algorithm = (challenge.algorithm || 'MD5').toUpperCase();
    if (algorithm !== 'MD5') {
      return undefined;
    }

    const qop = this.digestQop(challenge.qop);
    const ha1 = this.md5(`${username}:${challenge.realm}:${password}`);
    const ha2 = this.md5(`${methodArg.toUpperCase()}:${uri}`);
    const values: Record<string, string> = {
      username,
      realm: challenge.realm,
      nonce: challenge.nonce,
      uri,
    };

    if (qop) {
      const nc = this.nextNonceCount();
      const cnonce = this.md5(`${Date.now()}:${Math.random()}`).slice(0, 16);
      values.response = this.md5(`${ha1}:${challenge.nonce}:${nc}:${cnonce}:${qop}:${ha2}`);
      values.qop = qop;
      values.nc = nc;
      values.cnonce = cnonce;
      values.algorithm = 'MD5';
    } else {
      values.response = this.md5(`${ha1}:${challenge.nonce}:${ha2}`);
    }
    if (challenge.opaque) {
      values.opaque = challenge.opaque;
    }

    return `Digest ${Object.entries(values).map(([key, value]) => {
      if (key === 'qop' || key === 'nc' || key === 'algorithm') {
        return `${key}=${value}`;
      }
      return `${key}="${value.replace(/"/g, '\\"')}"`;
    }).join(', ')}`;
  }

  private digestChallenge(headerArg: string): IDigestChallenge {
    const source = headerArg.replace(/^digest\s+/i, '');
    const challenge: Record<string, string> = {};
    const pattern = /([a-zA-Z0-9_-]+)=(?:"([^"]*)"|([^,]*))(?:,\s*|$)/g;
    let match: RegExpExecArray | null;
    while ((match = pattern.exec(source))) {
      challenge[match[1].toLowerCase()] = match[2] ?? match[3] ?? '';
    }
    return challenge;
  }

  private digestQop(valueArg: string | undefined): string | undefined {
    if (!valueArg) {
      return undefined;
    }
    return valueArg.split(',').map((itemArg) => itemArg.trim()).includes('auth') ? 'auth' : undefined;
  }

  private nextNonceCount(): string {
    this.nonceCounter += 1;
    return this.nonceCounter.toString(16).padStart(8, '0');
  }

  private md5(valueArg: string): string {
    return plugins.crypto.createHash('md5').update(valueArg).digest('hex');
  }

  private username(): string {
    return this.config.username || prusaLinkDefaultUsername;
  }

  private url(pathArg: string): string {
    if (/^https?:\/\//i.test(pathArg)) {
      return pathArg;
    }
    const baseUrl = this.baseUrl();
    const path = pathArg.startsWith('/') ? pathArg : `/${pathArg}`;
    return `${baseUrl}${path}`;
  }

  private baseUrl(): string {
    const host = this.config.host || 'localhost';
    if (/^https?:\/\//i.test(host)) {
      const url = new URL(host);
      return `${url.protocol}//${url.host}`;
    }
    const ssl = this.config.ssl ?? this.config.tls ?? false;
    const protocol = ssl ? 'https' : 'http';
    const defaultPort = ssl ? 443 : prusaLinkDefaultPort;
    const port = this.config.port && this.config.port !== defaultPort ? `:${this.config.port}` : '';
    return `${protocol}://${this.hostForUrl(host)}${port}`;
  }

  private hostForUrl(hostArg: string): string {
    return hostArg.includes(':') && !hostArg.startsWith('[') ? `[${hostArg}]` : hostArg;
  }

  private offlineSnapshot(errorArg: string): IPrusaLinkSnapshot {
    return PrusaLinkMapper.toSnapshot({
      config: this.config,
      online: false,
      source: 'runtime',
      error: errorArg,
    });
  }

  private hasManualData(): boolean {
    return Boolean(this.config.rawData || this.config.version || this.config.status || this.config.legacyStatus || this.config.job || this.config.info);
  }

  private errorMessage(errorArg: unknown): string {
    return errorArg instanceof Error ? errorArg.message : String(errorArg);
  }

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

const isPrusaLinkSnapshot = (valueArg: unknown): valueArg is IPrusaLinkSnapshot => {
  return Boolean(valueArg && typeof valueArg === 'object' && 'printer' in valueArg && 'telemetry' in valueArg && 'job' in valueArg);
};
