import originalFetch from 'node-fetch';
import fetch_retry from 'fetch-retry';

import { Execution, Hue, State } from '../state.js';
import * as https from 'node:https';
import { Logger } from 'homebridge';
import { HueSyncBoxPlatformConfig } from '../config.js';
import AsyncLock, { AsyncLockOptions } from 'async-lock';
import {
  HTTP_RETRY_COUNT,
  HTTP_RETRY_BASE_DELAY_MS,
  MAX_SYNC_BOX_RESPONSE_BYTES,
  SYNC_BOX_REQUEST_TIMEOUT_MS,
  SYNC_BOX_LOCK_QUEUE_TIMEOUT_MS,
  SYNC_BOX_LOCK_MAX_EXECUTION_TIME_MS,
} from './constants.js';
import { isValidState } from './validation.js';

const fetch = fetch_retry(originalFetch, {
  retries: HTTP_RETRY_COUNT,
  retryDelay: attempt => {
    return Math.pow(2, attempt) * HTTP_RETRY_BASE_DELAY_MS; // 1000, 2000, 4000
  },
  // fetch-retry reuses the same request `init` - and so the same
  // AbortSignal - across every retry attempt. Once our own request timeout
  // fires, the signal is permanently aborted, so retrying just burns the
  // full backoff delay on attempts that fail instantly for no benefit.
  // Real network errors (connection refused/reset, DNS failure, etc.) still
  // get retried normally.
  //
  // fetch-retry only enforces its own `retries` option when `retryOn` is an
  // array; a function `retryOn` fully replaces that bound, so this has to
  // check `attempt` itself or a persistent network error retries forever.
  retryOn: (attempt: number, error: Error | null) => {
    return !!error && error.name !== 'AbortError' && attempt < HTTP_RETRY_COUNT;
  },
});

export class SyncBoxClient {
  private readonly LOCK_KEY = 'sync-box';
  private readonly LOCK_OPTIONS: AsyncLockOptions = {
    timeout: SYNC_BOX_LOCK_QUEUE_TIMEOUT_MS,
    maxExecutionTime: SYNC_BOX_LOCK_MAX_EXECUTION_TIME_MS,
  };

  private readonly lock: AsyncLock;
  private readonly agent = new https.Agent({
    rejectUnauthorized: false,
    keepAlive: true,
  });

  constructor(
    private readonly log: Logger | Console,
    private readonly config: HueSyncBoxPlatformConfig
  ) {
    this.lock = new AsyncLock();
  }

  public async getState(): Promise<State | null> {
    return await this.lock
      .acquire(
        this.LOCK_KEY,
        async () => await this.sendRequest<State>('GET', ''),
        this.LOCK_OPTIONS
      )
      .catch(e => {
        this.log.error('Failed to get state from Sync Box:', e);
        return null;
      });
  }

  public async updateExecution(execution: Partial<Execution>): Promise<void> {
    return await this.lock
      .acquire(
        this.LOCK_KEY,
        async () => await this.sendRequest<void>('PUT', 'execution', execution),
        this.LOCK_OPTIONS
      )
      .catch(e => {
        this.log.error('Error updating execution:', e);
      });
  }

  public async updateHue(hue: Partial<Hue>): Promise<void> {
    return await this.lock
      .acquire(
        this.LOCK_KEY,
        async () => await this.sendRequest<void>('PUT', 'hue', hue),
        this.LOCK_OPTIONS
      )
      .catch(e => {
        this.log.error('Error updating hue:', e);
      });
  }

  private async sendRequest<T>(
    method: string,
    path: string,
    body?: Partial<Execution> | Partial<Hue> | null
  ): Promise<T> {
    const url = `https://${this.config.syncBoxIpAddress}/api/v1/${path}`;
    const options = {
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${this.config.syncBoxApiAccessToken}`,
      },
      method,
      body: body ? JSON.stringify(body) : null,
      agent: this.agent,
      // Aborts the response stream once it exceeds this many bytes, so a
      // spoofed oversized response can't be fully buffered by res.json()
      // before isValidState() gets a chance to reject it.
      size: MAX_SYNC_BOX_RESPONSE_BYTES,
      // Bounds the whole request (all retries included - see the retryOn
      // override above) in case the Sync Box accepts the connection but
      // never sends a response; node-fetch applies no timeout on its own.
      signal: AbortSignal.timeout(SYNC_BOX_REQUEST_TIMEOUT_MS),
    };

    this.log.debug(
      'Request to Sync Box:',
      url,
      JSON.stringify({
        ...options,
        headers: { ...options.headers, Authorization: '[REDACTED]' },
      })
    );

    const res = await fetch(url, options);
    if (!res.ok) {
      this.log.error(
        `Error: ${res.status} - ${res.statusText}. ${JSON.stringify(await res.json())}`
      );
      throw new Error(`Error: ${res.status} - ${res.statusText}`);
    }
    if (method !== 'GET') {
      return null as T;
    }
    const json = await res.json();
    // The Sync Box's cert is accepted without identity verification
    // (rejectUnauthorized: false), so a LAN-adjacent attacker able to spoof
    // its responses must not be able to hand every accessory's update() a
    // shape it dereferences unconditionally - that already crashed the
    // entire Homebridge process, not just this plugin's accessories.
    if (!isValidState(json)) {
      throw new Error('Sync Box returned a malformed state response.');
    }
    return json as T;
  }
}
