import { SimpleLocalClient, type IServiceCallRequest, type IServiceCallResult, type ISimpleLocalSnapshot } from '../../core/index.js';
import { NzbgetMapper } from './nzbget.mapper.js';
import type { INzbgetConfig, INzbgetRawData } from './nzbget.types.js';
import { nzbgetDefaultPort, nzbgetDefaultTimeoutMs, nzbgetDefaultXmlRpcPath, nzbgetProfile } from './nzbget.types.js';

export class NzbgetApiError extends Error {}
export class NzbgetApiConnectionError extends NzbgetApiError {}
export class NzbgetApiResponseError extends NzbgetApiError {}
export class NzbgetUnsupportedCommandError extends NzbgetApiError {}

export class NzbgetClient extends SimpleLocalClient<INzbgetConfig> {
  private cachedSnapshot?: ISimpleLocalSnapshot;

  constructor(configArg: INzbgetConfig) {
    super(nzbgetProfile, configArg);
    this.nzbgetConfig = configArg;
  }

  private readonly nzbgetConfig: INzbgetConfig;

  public override async getSnapshot(forceRefreshArg = false): Promise<ISimpleLocalSnapshot> {
    if (!forceRefreshArg && this.cachedSnapshot) {
      return this.clone(this.cachedSnapshot);
    }

    if (!forceRefreshArg && this.nzbgetConfig.snapshot) {
      this.cachedSnapshot = NzbgetMapper.toSnapshot({ config: this.nzbgetConfig, source: 'snapshot', online: this.nzbgetConfig.snapshot.online });
      return this.clone(this.cachedSnapshot);
    }

    if (this.nzbgetConfig.client || this.nzbgetConfig.snapshotProvider) {
      this.cachedSnapshot = await super.getSnapshot(forceRefreshArg);
      return this.clone(this.cachedSnapshot);
    }

    if (!forceRefreshArg && this.hasManualData()) {
      this.cachedSnapshot = NzbgetMapper.toSnapshot({
        config: this.nzbgetConfig,
        rawData: this.nzbgetConfig.rawData ?? this.nzbgetConfig.rawText,
        online: this.nzbgetConfig.online ?? true,
        source: 'manual',
      });
      return this.clone(this.cachedSnapshot);
    }

    if (this.hasEndpoint()) {
      try {
        this.cachedSnapshot = await this.fetchSnapshot();
      } catch (errorArg) {
        this.cachedSnapshot = this.buildOfflineSnapshot(this.errorMessage(errorArg));
      }
      return this.clone(this.cachedSnapshot);
    }

    this.cachedSnapshot = this.buildOfflineSnapshot('No NZBGet XML-RPC endpoint, client, snapshotProvider, snapshot, entities, or rawData is configured.');
    return this.clone(this.cachedSnapshot);
  }

  public override async refresh(): Promise<IServiceCallResult> {
    this.cachedSnapshot = undefined;
    const snapshot = await this.getSnapshot(this.hasEndpoint());
    const success = snapshot.online && !snapshot.error;
    return { success, error: success ? undefined : snapshot.error, data: snapshot };
  }

  public async fetchSnapshot(): Promise<ISimpleLocalSnapshot> {
    const rawData: INzbgetRawData = {
      version: await this.callXmlRpc<string>('version'),
      status: await this.callXmlRpc<Record<string, unknown>>('status'),
      downloads: await this.callXmlRpc<Array<Record<string, unknown>>>('history', [false]),
      fetchedAt: new Date().toISOString(),
    };
    return NzbgetMapper.toSnapshot({ config: this.nzbgetConfig, rawData, online: true, source: 'http' });
  }

  public override async execute(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
    if (this.nzbgetConfig.client?.execute) {
      return this.normalizeServiceResult(await this.nzbgetConfig.client.execute(requestArg));
    }
    if (this.nzbgetConfig.commandExecutor) {
      return this.normalizeServiceResult(await this.nzbgetConfig.commandExecutor.execute(requestArg));
    }
    if (requestArg.service === 'refresh') {
      return this.refresh();
    }
    if (!this.hasEndpoint()) {
      return {
        success: false,
        error: 'NZBGet commands require a live XML-RPC endpoint, an injected client.execute(), or commandExecutor. Static snapshots/manual data are read-only.',
      };
    }

    try {
      const data = await this.executeLiveCommand(requestArg);
      this.cachedSnapshot = undefined;
      return { success: true, data };
    } catch (errorArg) {
      return { success: false, error: this.errorMessage(errorArg) };
    }
  }

  public override async destroy(): Promise<void> {
    await this.nzbgetConfig.client?.destroy?.();
  }

  private async executeLiveCommand(requestArg: IServiceCallRequest): Promise<unknown> {
    switch (requestArg.service) {
      case 'pause':
      case 'turn_off':
        return this.callXmlRpc('pausedownload');
      case 'resume':
      case 'turn_on':
        return this.callXmlRpc('resumedownload');
      case 'toggle': {
        const snapshot = await this.getSnapshot(true);
        const download = snapshot.entities.find((entityArg) => entityArg.id === 'download');
        return this.callXmlRpc(download?.state === true ? 'pausedownload' : 'resumedownload');
      }
      case 'set_speed': {
        const speed = this.numberValue(requestArg.data?.speed);
        if (speed === undefined) {
          throw new NzbgetUnsupportedCommandError('NZBGet set_speed requires data.speed.');
        }
        return this.callXmlRpc('rate', [speed]);
      }
      default:
        throw new NzbgetUnsupportedCommandError(`Unsupported NZBGet service: ${requestArg.service}`);
    }
  }

  private async callXmlRpc<TValue = unknown>(methodArg: string, paramsArg: unknown[] = []): Promise<TValue> {
    const url = this.xmlRpcUrl();
    let response: Response;
    try {
      response = await globalThis.fetch(url, {
        method: 'POST',
        headers: this.headers(),
        body: xmlRpcRequest(methodArg, paramsArg),
        signal: AbortSignal.timeout(this.nzbgetConfig.timeoutMs || nzbgetDefaultTimeoutMs),
      });
    } catch (errorArg) {
      throw new NzbgetApiConnectionError(`Connection to ${url} failed: ${this.errorMessage(errorArg)}`);
    }

    const text = await response.text();
    if (!response.ok) {
      throw new NzbgetApiConnectionError(`NZBGet XML-RPC ${methodArg} failed with HTTP ${response.status}: ${text.slice(0, 160)}`);
    }
    return parseXmlRpcResponse(text) as TValue;
  }

  private xmlRpcUrl(): string {
    if (this.nzbgetConfig.url) {
      const parsed = new URL(this.nzbgetConfig.url);
      if (!parsed.pathname || parsed.pathname === '/') {
        parsed.pathname = nzbgetDefaultXmlRpcPath;
      }
      return parsed.toString();
    }
    const host = this.nzbgetConfig.host;
    if (!host) {
      throw new NzbgetApiConnectionError('NZBGet XML-RPC URL requires config.host or config.url.');
    }
    const useTls = Boolean(this.nzbgetConfig.useTls || this.nzbgetConfig.ssl);
    const port = this.nzbgetConfig.port || nzbgetDefaultPort;
    const path = this.nzbgetConfig.path || nzbgetDefaultXmlRpcPath;
    const pathPart = path.startsWith('/') ? path : `/${path}`;
    return `${useTls ? 'https' : 'http'}://${hostForUrl(host)}:${port}${pathPart}`;
  }

  private headers(): Record<string, string> {
    const headers: Record<string, string> = {
      accept: 'text/xml, application/xml, */*;q=0.8',
      'content-type': 'text/xml',
    };
    if (this.nzbgetConfig.username) {
      headers.authorization = `Basic ${Buffer.from(`${this.nzbgetConfig.username}:${this.nzbgetConfig.password || ''}`).toString('base64')}`;
    }
    return headers;
  }

  private hasEndpoint(): boolean {
    return Boolean(this.nzbgetConfig.url || this.nzbgetConfig.host);
  }

  private hasManualData(): boolean {
    return Boolean(this.nzbgetConfig.rawData !== undefined || this.nzbgetConfig.rawText !== undefined || this.nzbgetConfig.entities?.length);
  }

  private buildOfflineSnapshot(errorArg: string): ISimpleLocalSnapshot {
    return NzbgetMapper.toSnapshot({ config: this.nzbgetConfig, online: false, source: 'runtime', error: errorArg });
  }

  private normalizeServiceResult(valueArg: unknown): IServiceCallResult {
    if (valueArg && typeof valueArg === 'object' && 'success' in valueArg && typeof (valueArg as IServiceCallResult).success === 'boolean') {
      return valueArg as IServiceCallResult;
    }
    return { success: true, data: valueArg };
  }

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

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

  private clone<TValue>(valueArg: TValue): TValue {
    return JSON.parse(JSON.stringify(valueArg)) as TValue;
  }
}

const xmlRpcRequest = (methodArg: string, paramsArg: unknown[]): string => {
  const params = paramsArg.map((paramArg) => `<param>${xmlRpcValue(paramArg)}</param>`).join('');
  return `<?xml version="1.0"?><methodCall><methodName>${escapeXml(methodArg)}</methodName><params>${params}</params></methodCall>`;
};

const xmlRpcValue = (valueArg: unknown): string => {
  if (typeof valueArg === 'boolean') {
    return `<value><boolean>${valueArg ? '1' : '0'}</boolean></value>`;
  }
  if (typeof valueArg === 'number') {
    return Number.isInteger(valueArg) ? `<value><int>${valueArg}</int></value>` : `<value><double>${valueArg}</double></value>`;
  }
  if (typeof valueArg === 'string') {
    return `<value><string>${escapeXml(valueArg)}</string></value>`;
  }
  if (Array.isArray(valueArg)) {
    return `<value><array><data>${valueArg.map(xmlRpcValue).join('')}</data></array></value>`;
  }
  if (valueArg && typeof valueArg === 'object') {
    const members = Object.entries(valueArg as Record<string, unknown>)
      .map(([keyArg, memberValueArg]) => `<member><name>${escapeXml(keyArg)}</name>${xmlRpcValue(memberValueArg)}</member>`)
      .join('');
    return `<value><struct>${members}</struct></value>`;
  }
  return '<value><nil/></value>';
};

const parseXmlRpcResponse = (textArg: string): unknown => {
  const fault = findFirstTagContent(textArg, 'fault');
  if (fault !== undefined) {
    const faultValue = parseXmlRpcValue(findFirstTagContent(fault, 'value') || fault) as Record<string, unknown>;
    throw new NzbgetApiResponseError(`NZBGet XML-RPC fault ${String(faultValue.faultCode || '')}: ${String(faultValue.faultString || 'unknown fault')}`.trim());
  }
  const params = findFirstTagContent(textArg, 'params') || textArg;
  const param = findFirstTagContent(params, 'param') || params;
  const value = findFirstTagContent(param, 'value');
  if (value === undefined) {
    throw new NzbgetApiResponseError('NZBGet XML-RPC response did not contain a value.');
  }
  return parseXmlRpcValue(value);
};

const parseXmlRpcValue = (valueArg: string): unknown => {
  const value = valueArg.trim();
  const struct = findFirstTagContent(value, 'struct');
  if (struct !== undefined) {
    const result: Record<string, unknown> = {};
    for (const member of findTagContents(struct, 'member')) {
      const name = decodeXml(findFirstTagContent(member, 'name') || '');
      const memberValue = findFirstTagContent(member, 'value');
      result[name] = memberValue === undefined ? undefined : parseXmlRpcValue(memberValue);
    }
    return result;
  }
  const array = findFirstTagContent(value, 'array');
  if (array !== undefined) {
    const data = findFirstTagContent(array, 'data') || array;
    return findTagContents(data, 'value').map((itemArg) => parseXmlRpcValue(itemArg));
  }
  if (/<nil\s*\/?>(?:<\/nil>)?/i.test(value)) {
    return null;
  }
  const intValue = findFirstTagContent(value, 'int') ?? findFirstTagContent(value, 'i4');
  if (intValue !== undefined) {
    return Number(intValue.trim());
  }
  const doubleValue = findFirstTagContent(value, 'double');
  if (doubleValue !== undefined) {
    return Number(doubleValue.trim());
  }
  const booleanValue = findFirstTagContent(value, 'boolean');
  if (booleanValue !== undefined) {
    return booleanValue.trim() === '1' || booleanValue.trim().toLowerCase() === 'true';
  }
  const stringValue = findFirstTagContent(value, 'string');
  if (stringValue !== undefined) {
    return decodeXml(stringValue);
  }
  const dateValue = findFirstTagContent(value, 'dateTime.iso8601');
  if (dateValue !== undefined) {
    return decodeXml(dateValue);
  }
  return decodeXml(value.replace(/<[^>]+>/g, '').trim());
};

const findFirstTagContent = (xmlArg: string, tagArg: string): string | undefined => findTagContents(xmlArg, tagArg)[0];

const findTagContents = (xmlArg: string, tagArg: string): string[] => {
  const escapedTag = tagArg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  const tagRe = new RegExp(`<${escapedTag}(?:\\s[^>]*)?>|<\\/${escapedTag}>`, 'gi');
  const contents: string[] = [];
  let depth = 0;
  let contentStart = 0;
  let match: RegExpExecArray | null;
  while ((match = tagRe.exec(xmlArg))) {
    const token = match[0];
    if (!token.startsWith('</')) {
      if (depth === 0) {
        contentStart = match.index + token.length;
      }
      depth++;
      continue;
    }
    depth--;
    if (depth === 0) {
      contents.push(xmlArg.slice(contentStart, match.index));
    }
  }
  return contents;
};

const escapeXml = (valueArg: string): string => valueArg
  .replace(/&/g, '&amp;')
  .replace(/</g, '&lt;')
  .replace(/>/g, '&gt;')
  .replace(/"/g, '&quot;')
  .replace(/'/g, '&apos;');

const decodeXml = (valueArg: string): string => valueArg
  .replace(/&quot;/g, '"')
  .replace(/&apos;/g, "'")
  .replace(/&lt;/g, '<')
  .replace(/&gt;/g, '>')
  .replace(/&amp;/g, '&');

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