import * as plugins from '../../plugins.js';
import { BondMapper } from './bond.mapper.js';
import type { IBondCommandRequest, IBondConfig, IBondDeviceRaw, IBondHttpRefreshResult, IBondRawData, IBondSnapshot } from './bond.types.js';
import { bondActions, bondDefaultPort, bondDefaultTimeoutMs } from './bond.types.js';

export class BondApiError extends Error {}
export class BondApiConnectionError extends BondApiError {}
export class BondApiAuthorizationError extends BondApiError {}
export class BondUnsupportedCommandError extends BondApiError {}

interface IBondEndpoint {
  protocol: 'http' | 'https';
  host: string;
  port?: number;
}

export class BondClient {
  private currentSnapshot?: IBondSnapshot;

  constructor(private readonly config: IBondConfig) {}

  public static async fetchToken(hostArg: string, portArg = bondDefaultPort, timeoutMsArg = bondDefaultTimeoutMs): Promise<string | undefined> {
    const client = new BondClient({ host: hostArg, port: portArg, timeoutMs: timeoutMsArg });
    const response = await client.requestJson<Record<string, unknown>>('/v2/token', { allowMissingToken: true });
    return typeof response.token === 'string' && response.token ? response.token : undefined;
  }

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

    if (!forceRefreshArg && this.config.snapshot) {
      this.currentSnapshot = BondMapper.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();
      } catch (errorArg) {
        this.currentSnapshot = BondMapper.offlineSnapshot(this.config, this.errorMessage(errorArg));
      }
      return this.cloneSnapshot(this.currentSnapshot);
    }

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

    if (this.hasEndpoint()) {
      if (!this.accessToken()) {
        this.currentSnapshot = BondMapper.offlineSnapshot(this.config, 'Bond local HTTP snapshots require config.accessToken or config.token.');
        return this.cloneSnapshot(this.currentSnapshot);
      }
      try {
        this.currentSnapshot = await this.fetchSnapshot();
      } catch (errorArg) {
        this.currentSnapshot = BondMapper.offlineSnapshot(this.config, this.errorMessage(errorArg));
      }
      return this.cloneSnapshot(this.currentSnapshot);
    }

    this.currentSnapshot = BondMapper.offlineSnapshot(this.config, 'No Bond local HTTP endpoint, injected client, snapshot, or manual data is configured.');
    return this.cloneSnapshot(this.currentSnapshot);
  }

  public async refresh(): Promise<IBondHttpRefreshResult> {
    try {
      this.currentSnapshot = undefined;
      const liveCapable = Boolean(this.config.client || (this.hasEndpoint() && this.accessToken()));
      const snapshot = await this.getSnapshot(liveCapable);
      const success = liveCapable && snapshot.online && !snapshot.error;
      return { success, snapshot, error: success ? undefined : snapshot.error || 'Bond refresh requires a live host/token or injected client.', data: { source: snapshot.source } };
    } catch (errorArg) {
      const error = this.errorMessage(errorArg);
      const snapshot = BondMapper.offlineSnapshot(this.config, error);
      this.currentSnapshot = snapshot;
      return { success: false, snapshot: this.cloneSnapshot(snapshot), error };
    }
  }

  public async fetchSnapshot(): Promise<IBondSnapshot> {
    if (!this.hasEndpoint()) {
      throw new BondApiConnectionError('Bond local HTTP snapshot requires config.host or config.url.');
    }
    if (!this.accessToken()) {
      throw new BondApiAuthorizationError('Bond local HTTP snapshot requires config.accessToken or config.token.');
    }

    const version = await this.requestJson<Record<string, unknown>>('/v2/sys/version');
    const deviceIndex = await this.requestJson<Record<string, unknown>>('/v2/devices');
    const devices: IBondDeviceRaw[] = [];
    for (const deviceId of this.deviceIds(deviceIndex)) {
      const [attrs, props, state] = await Promise.all([
        this.requestJson<Record<string, unknown>>(`/v2/devices/${encodeURIComponent(deviceId)}`),
        this.requestJson<Record<string, unknown>>(`/v2/devices/${encodeURIComponent(deviceId)}/properties`),
        this.requestJson<Record<string, unknown>>(`/v2/devices/${encodeURIComponent(deviceId)}/state`),
      ]);
      devices.push({ deviceId, attrs, props, state });
    }

    const bridge = await this.requestJson<Record<string, unknown>>('/v2/bridge').catch(() => undefined);
    const rawData: IBondRawData = {
      version,
      bridge,
      deviceIndex,
      devices,
      fetchedAt: new Date().toISOString(),
    };
    return BondMapper.toSnapshot({ config: this.config, rawData, online: true, source: 'http' });
  }

  public async execute(commandArg: IBondCommandRequest): Promise<unknown> {
    if (this.config.commandExecutor) {
      return this.config.commandExecutor.execute(commandArg);
    }
    if (this.config.client?.execute) {
      return this.config.client.execute(commandArg);
    }
    if (!this.hasEndpoint() || !this.accessToken()) {
      throw new BondApiConnectionError('Bond commands require config.host/config.url and accessToken/token, an injected client.execute, or commandExecutor. Static snapshots/manual data are read-only.');
    }

    if (commandArg.action === bondActions.setStateBelief || commandArg.statePatch) {
      const data = commandArg.statePatch || objectArgument(commandArg.argument) || {};
      const result = await this.requestJson(`/v2/devices/${encodeURIComponent(commandArg.deviceId)}/state`, { method: 'PATCH', body: data });
      this.currentSnapshot = undefined;
      return result;
    }

    const body = commandArg.argument === undefined ? {} : { argument: commandArg.argument };
    const result = await this.requestJson(`/v2/devices/${encodeURIComponent(commandArg.deviceId)}/actions/${encodeURIComponent(commandArg.action)}`, { method: 'PUT', body });
    this.currentSnapshot = undefined;
    return result;
  }

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

  private async snapshotFromClient(): Promise<IBondSnapshot> {
    const client = this.config.client;
    if (!client) {
      throw new BondApiConnectionError('No Bond client configured.');
    }
    if (client.getSnapshot) {
      const result = await client.getSnapshot();
      if (this.isSnapshot(result)) {
        return BondMapper.toSnapshot({ config: { ...this.config, snapshot: result }, source: 'client', online: result.online });
      }
      return BondMapper.toSnapshot({ config: this.config, rawData: result, online: true, source: 'client' });
    }
    if (client.getRawData) {
      return BondMapper.toSnapshot({ config: this.config, rawData: await client.getRawData(), online: true, source: 'client' });
    }
    throw new BondApiConnectionError('Bond client must expose getSnapshot() or getRawData().');
  }

  private async requestJson<TValue = unknown>(pathArg: string, optionsArg: { method?: 'GET' | 'PUT' | 'PATCH'; body?: unknown; allowMissingToken?: boolean } = {}): Promise<TValue> {
    const endpoint = this.endpoint();
    const token = this.accessToken();
    if (!endpoint) {
      throw new BondApiConnectionError('Bond request requires config.host or config.url.');
    }
    if (!token && !optionsArg.allowMissingToken) {
      throw new BondApiAuthorizationError('Bond request requires config.accessToken or config.token.');
    }

    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), this.config.timeoutMs || bondDefaultTimeoutMs);
    const headers: Record<string, string> = {
      'BOND-UUID': this.messageId(),
    };
    if (token) {
      headers['BOND-Token'] = token;
    }
    if (optionsArg.body !== undefined) {
      headers['Content-Type'] = 'application/json';
    }

    try {
      const response = await fetch(`${this.baseUrl(endpoint)}${pathArg}`, {
        method: optionsArg.method || 'GET',
        headers,
        body: optionsArg.body === undefined ? undefined : JSON.stringify(optionsArg.body),
        signal: controller.signal,
      });
      if (response.status === 401 || response.status === 403) {
        throw new BondApiAuthorizationError(`Bond API authorization failed with HTTP ${response.status}.`);
      }
      if (!response.ok) {
        throw new BondApiError(`Bond API request failed with HTTP ${response.status}: ${response.statusText}`);
      }
      const text = await response.text();
      return (text ? JSON.parse(text) : {}) as TValue;
    } catch (errorArg) {
      if (errorArg instanceof BondApiError) {
        throw errorArg;
      }
      if (errorArg instanceof Error && errorArg.name === 'AbortError') {
        throw new BondApiConnectionError(`Bond API request timed out after ${this.config.timeoutMs || bondDefaultTimeoutMs}ms.`);
      }
      throw new BondApiConnectionError(this.errorMessage(errorArg));
    } finally {
      clearTimeout(timer);
    }
  }

  private endpoint(): IBondEndpoint | undefined {
    const value = this.config.url || this.config.host;
    if (!value) {
      return undefined;
    }
    try {
      const url = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(value) ? value : `http://${value}`);
      return {
        protocol: url.protocol === 'https:' ? 'https' : 'http',
        host: url.hostname,
        port: this.config.port || (url.port ? Number(url.port) : undefined),
      };
    } catch {
      return { protocol: 'http', host: value, port: this.config.port };
    }
  }

  private baseUrl(endpointArg: IBondEndpoint): string {
    const port = endpointArg.port && !(endpointArg.protocol === 'http' && endpointArg.port === 80) && !(endpointArg.protocol === 'https' && endpointArg.port === 443) ? `:${endpointArg.port}` : '';
    return `${endpointArg.protocol}://${endpointArg.host}${port}`;
  }

  private accessToken(): string | undefined {
    return stringValue(this.config.accessToken) || stringValue(this.config.token);
  }

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

  private hasManualData(): boolean {
    return Boolean(this.config.rawData || this.config.snapshot);
  }

  private deviceIds(deviceIndexArg: Record<string, unknown>): string[] {
    return Object.entries(deviceIndexArg)
      .filter(([keyArg, valueArg]) => !keyArg.startsWith('_') && valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg))
      .map(([keyArg]) => keyArg);
  }

  private isSnapshot(valueArg: unknown): valueArg is IBondSnapshot {
    return Boolean(valueArg && typeof valueArg === 'object' && 'hub' in valueArg && 'devices' in valueArg);
  }

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

  private messageId(): string {
    return `a0${plugins.crypto.randomBytes(7).toString('hex')}`.slice(0, 16).toLowerCase();
  }

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

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

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