import * as plugins from '../../plugins.js';
import { SimpleLocalClient } from '../../core/index.js';
import { KioskerMapper } from './kiosker.mapper.js';
import type { IKioskerApiData, IKioskerBlackoutState, IKioskerConfig, IKioskerScreensaverState, IKioskerSnapshot } from './kiosker.types.js';
import { kioskerDefaultPort, kioskerDefaultTimeoutMs, kioskerProfile } from './kiosker.types.js';

export class KioskerClient extends SimpleLocalClient<IKioskerConfig> {
  constructor(private readonly configArg: IKioskerConfig) {
    super(kioskerProfile, configArg);
  }

  public async getSnapshot(forceRefreshArg = false): Promise<IKioskerSnapshot> {
    if (this.hasLiveTarget()) {
      try {
        return await this.fetchSnapshot();
      } catch (errorArg) {
        return KioskerMapper.toSnapshot({ config: this.configArg, online: false, source: 'runtime', error: errorMessage(errorArg) });
      }
    }

    if (this.host() && !this.token() && !this.hasFallbackSource()) {
      return KioskerMapper.toSnapshot({ config: this.configArg, online: false, source: 'runtime', error: 'Kiosker HTTP polling requires config.host plus config.token, config.apiToken, or config.api_token.' });
    }

    const snapshot = await super.getSnapshot(forceRefreshArg);
    if (snapshot.rawData === undefined && snapshot.entities.length) {
      return KioskerMapper.toSnapshot({ config: { ...this.configArg, snapshot }, online: snapshot.online, source: snapshot.source, error: snapshot.error });
    }

    return KioskerMapper.toSnapshot({ config: this.configArg, rawData: snapshot.rawData, online: snapshot.online, source: snapshot.source, error: snapshot.error });
  }

  private async fetchSnapshot(): Promise<IKioskerSnapshot> {
    const [statusBody, blackoutBody, screensaverBody] = await Promise.all([
      this.getJson<Record<string, unknown>>('/status'),
      this.getJson<Record<string, unknown>>('/blackout/state'),
      this.getJson<Record<string, unknown>>('/screensaver/state'),
    ]);
    const status = record(statusBody.status);
    if (!status) {
      throw new Error('Kiosker /api/v1/status response did not contain a status object.');
    }

    const apiData: IKioskerApiData = {
      status,
      blackout: record(blackoutBody.blackout) as IKioskerBlackoutState | undefined,
      screensaver: record(screensaverBody.screensaver) as IKioskerScreensaverState | undefined,
    };
    return KioskerMapper.toSnapshotFromApiData(this.configArg, apiData);
  }

  private async getJson<TValue>(pathArg: string): Promise<TValue> {
    const token = this.token();
    if (!token) {
      throw new Error('Kiosker HTTP requests require a Bearer token.');
    }

    const requestUrl = new URL(`${this.baseUrl()}/api/v1${pathArg}`);
    const response = requestUrl.protocol === 'https:' && !this.verifySsl()
      ? await this.getTextWithTls(requestUrl, token)
      : await this.getTextWithFetch(requestUrl, token);

    const text = response.text;
    if (response.status < 200 || response.status > 299) {
      throw new Error(this.statusError(response.status, text));
    }
    if (!text.trim()) {
      return {} as TValue;
    }
    try {
      return JSON.parse(text) as TValue;
    } catch (errorArg) {
      throw new Error(`Kiosker HTTP response was not valid JSON: ${errorMessage(errorArg)}`);
    }
  }

  private async getTextWithFetch(urlArg: URL, tokenArg: string): Promise<{ status: number; text: string }> {
    let response: Response;
    try {
      response = await globalThis.fetch(urlArg, {
        headers: {
          accept: 'application/json',
          authorization: `Bearer ${tokenArg}`,
        },
        signal: AbortSignal.timeout(this.configArg.timeoutMs || kioskerDefaultTimeoutMs),
      });
    } catch (errorArg) {
      throw new Error(`Kiosker HTTP request failed: ${errorMessage(errorArg)}`);
    }
    return { status: response.status, text: await response.text() };
  }

  private async getTextWithTls(urlArg: URL, tokenArg: string): Promise<{ status: number; text: string }> {
    const timeoutMs = this.configArg.timeoutMs || kioskerDefaultTimeoutMs;
    return new Promise<{ status: number; text: string }>((resolve, reject) => {
      const chunks: Buffer[] = [];
      let settled = false;
      const socket = plugins.tls.connect({
        host: urlArg.hostname,
        port: Number(urlArg.port) || 443,
        servername: urlArg.hostname,
        rejectUnauthorized: false,
      });

      const finish = (errorArg?: Error) => {
        if (settled) {
          return;
        }
        settled = true;
        socket.removeAllListeners();
        socket.destroy();
        if (errorArg) {
          reject(errorArg);
          return;
        }
        try {
          resolve(parseHttpResponse(Buffer.concat(chunks).toString('utf8')));
        } catch (parseErrorArg) {
          reject(parseErrorArg instanceof Error ? parseErrorArg : new Error(String(parseErrorArg)));
        }
      };

      socket.setTimeout(timeoutMs, () => finish(new Error(`Kiosker HTTPS request to ${urlArg.host} timed out after ${timeoutMs}ms.`)));
      socket.on('secureConnect', () => {
        socket.write([
          `GET ${urlArg.pathname}${urlArg.search} HTTP/1.1`,
          `Host: ${urlArg.host}`,
          'Accept: application/json',
          `Authorization: Bearer ${tokenArg}`,
          'Connection: close',
          '',
          '',
        ].join('\r\n'));
      });
      socket.on('data', (chunkArg) => chunks.push(Buffer.isBuffer(chunkArg) ? chunkArg : Buffer.from(chunkArg)));
      socket.on('end', () => finish());
      socket.on('close', () => finish());
      socket.on('error', (errorArg) => finish(errorArg));
    });
  }

  private statusError(statusArg: number, bodyArg: string): string {
    if (statusArg === 401) {
      return 'Kiosker authentication failed: unauthorized Bearer token.';
    }
    if (statusArg === 403) {
      return 'Kiosker IP authentication failed: client IP is not allowed.';
    }
    if (statusArg === 400) {
      return `Kiosker bad request: ${bodyArg.slice(0, 160)}`;
    }
    return `Kiosker HTTP request failed with ${statusArg}: ${bodyArg.slice(0, 160)}`;
  }

  private hasLiveTarget(): boolean {
    return Boolean(this.host() && this.token());
  }

  private hasFallbackSource(): boolean {
    return Boolean(this.configArg.client || this.configArg.snapshotProvider || this.configArg.snapshot || this.configArg.rawData !== undefined || this.configArg.rawText !== undefined || this.configArg.entities?.length);
  }

  private baseUrl(): string {
    const url = urlValue(this.configArg.url || stringValue(this.configArg.metadata?.url));
    if (url) {
      return url.origin;
    }
    const host = this.host();
    if (!host) {
      throw new Error('Kiosker HTTP requests require config.host.');
    }
    const port = this.configArg.port || kioskerDefaultPort;
    const protocol = this.protocol();
    return `${protocol}://${host}${port ? `:${port}` : ''}`;
  }

  private host(): string | undefined {
    return urlValue(this.configArg.url || stringValue(this.configArg.metadata?.url))?.hostname || stringValue(this.configArg.host);
  }

  private protocol(): 'http' | 'https' {
    if (this.configArg.protocol === 'http' || this.configArg.protocol === 'https') {
      return this.configArg.protocol;
    }
    if (typeof this.configArg.ssl === 'boolean') {
      return this.configArg.ssl ? 'https' : 'http';
    }
    if (typeof this.configArg.useTls === 'boolean') {
      return this.configArg.useTls ? 'https' : 'http';
    }
    return urlValue(this.configArg.url || stringValue(this.configArg.metadata?.url))?.protocol === 'https:' ? 'https' : 'http';
  }

  private verifySsl(): boolean {
    if (typeof this.configArg.verifySsl === 'boolean') {
      return this.configArg.verifySsl;
    }
    if (typeof this.configArg.verify_ssl === 'boolean') {
      return this.configArg.verify_ssl;
    }
    return false;
  }

  private token(): string | undefined {
    const metadata = this.configArg.metadata;
    return stringValue(this.configArg.token) || stringValue(this.configArg.apiToken) || stringValue(this.configArg.api_token) || stringValue(metadata?.token) || stringValue(metadata?.apiToken) || stringValue(metadata?.api_token);
  }
}

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

const urlValue = (valueArg: unknown): URL | undefined => {
  const value = stringValue(valueArg);
  if (!value || !/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) {
    return undefined;
  }
  try {
    return new URL(value);
  } catch {
    return undefined;
  }
};

const parseHttpResponse = (rawResponseArg: string): { status: number; text: string } => {
  const separator = rawResponseArg.indexOf('\r\n\r\n');
  if (separator === -1) {
    throw new Error('Kiosker HTTPS response did not contain HTTP headers.');
  }
  const headerText = rawResponseArg.slice(0, separator);
  const bodyText = rawResponseArg.slice(separator + 4);
  const headerLines = headerText.split('\r\n');
  const status = Number(headerLines[0]?.match(/^HTTP\/\d(?:\.\d)?\s+(\d+)/)?.[1]);
  if (!Number.isFinite(status)) {
    throw new Error('Kiosker HTTPS response did not contain a valid HTTP status line.');
  }
  const headers = Object.fromEntries(headerLines.slice(1).map((lineArg) => {
    const splitAt = lineArg.indexOf(':');
    return splitAt === -1 ? [lineArg.toLowerCase(), ''] : [lineArg.slice(0, splitAt).trim().toLowerCase(), lineArg.slice(splitAt + 1).trim().toLowerCase()];
  }));
  return {
    status,
    text: headers['transfer-encoding'] === 'chunked' ? decodeChunkedBody(bodyText) : bodyText,
  };
};

const decodeChunkedBody = (bodyArg: string): string => {
  let offset = 0;
  let decoded = '';
  while (offset < bodyArg.length) {
    const lineEnd = bodyArg.indexOf('\r\n', offset);
    if (lineEnd === -1) {
      return decoded;
    }
    const size = Number.parseInt(bodyArg.slice(offset, lineEnd), 16);
    if (!Number.isFinite(size) || size <= 0) {
      return decoded;
    }
    const chunkStart = lineEnd + 2;
    decoded += bodyArg.slice(chunkStart, chunkStart + size);
    offset = chunkStart + size + 2;
  }
  return decoded;
};

const stringValue = (valueArg: unknown): string | undefined => typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;

const errorMessage = (errorArg: unknown): string => errorArg instanceof Error ? errorArg.message : String(errorArg);
