import type { IncomingHttpHeaders } from 'node:http';
import { FroniusMapper } from './fronius.mapper.js';
import type { IFroniusClientLike, IFroniusCommandRequest, IFroniusConfig, IFroniusRawData, IFroniusRefreshResult, IFroniusSnapshot, TFroniusApiVersion, TFroniusProtocol } from './fronius.types.js';
import { froniusDefaultHttpPort, froniusDefaultHttpsPort, froniusDefaultTimeoutMs } from './fronius.types.js';

export class FroniusApiError extends Error {}
export class FroniusApiConnectionError extends FroniusApiError {}
export class FroniusApiInvalidResponseError extends FroniusApiError {}
export class FroniusApiUnsupportedError extends FroniusApiError {}
export class FroniusApiBadStatusError extends FroniusApiError {
  public readonly response: unknown;

  constructor(endpointArg: string, codeArg: number, reasonArg: string | undefined, responseArg: unknown) {
    super(`Fronius Solar API bad status at ${endpointArg}. Code: ${codeArg}. Reason: ${reasonArg || 'unknown'}.`);
    this.response = responseArg;
  }
}

interface IFroniusEndpoint {
  protocol: TFroniusProtocol;
  host: string;
  port: number;
}

interface IFroniusApiInfo {
  apiVersion: Exclude<TFroniusApiVersion, 'auto'>;
  baseUrl: string;
  raw: Record<string, unknown>;
}

interface IFroniusHttpResponse {
  status: number;
  text: string;
  headers: IncomingHttpHeaders;
}

export class FroniusClient {
  private currentSnapshot?: IFroniusSnapshot;

  constructor(private readonly config: IFroniusConfig) {}

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

    if (!forceRefreshArg && this.config.snapshot) {
      this.currentSnapshot = FroniusMapper.toSnapshot({ config: this.config, source: 'snapshot', online: this.config.snapshot.online });
      return FroniusMapper.clone(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 FroniusMapper.clone(this.currentSnapshot);
    }

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

    if (this.endpoint()) {
      try {
        this.currentSnapshot = await this.fetchSnapshot();
      } catch (errorArg) {
        this.currentSnapshot = this.offlineSnapshot(this.errorMessage(errorArg));
      }
      return FroniusMapper.clone(this.currentSnapshot);
    }

    this.currentSnapshot = this.offlineSnapshot('No Fronius local endpoint, injected client, snapshot, or raw data is configured.');
    return FroniusMapper.clone(this.currentSnapshot);
  }

  public async refresh(): Promise<IFroniusRefreshResult> {
    try {
      this.currentSnapshot = undefined;
      const liveCapable = Boolean(this.config.client || this.endpoint());
      const snapshot = await this.getSnapshot(liveCapable);
      const success = liveCapable && snapshot.online && !snapshot.error && snapshot.source !== 'runtime';
      return { success, snapshot, error: success ? undefined : snapshot.error || 'Fronius refresh requires a reachable local Solar API endpoint or injected client.', data: { source: snapshot.source } };
    } catch (errorArg) {
      const error = this.errorMessage(errorArg);
      const snapshot = this.offlineSnapshot(error);
      this.currentSnapshot = snapshot;
      return { success: false, snapshot: FroniusMapper.clone(snapshot), error };
    }
  }

  public async fetchSnapshot(): Promise<IFroniusSnapshot> {
    const endpoint = this.endpoint();
    if (!endpoint) {
      throw new FroniusApiConnectionError('Fronius local API snapshot requires config.host or config.url.');
    }

    const apiInfo = await this.resolveApiInfo(endpoint);
    const rawData: Partial<IFroniusRawData> = { apiVersion: apiInfo.raw, errors: {}, inverterDeviceData: {}, fetchedAt: new Date().toISOString() };

    rawData.loggerInfo = await this.optionalSolarResource(rawData, endpoint, apiInfo, 'loggerInfo', 'GetLoggerInfo.cgi');
    rawData.inverterInfo = await this.optionalSolarResource(rawData, endpoint, apiInfo, 'inverterInfo', 'GetInverterInfo.cgi');
    rawData.activeDeviceInfo = await this.optionalSolarResource(rawData, endpoint, apiInfo, 'activeDeviceInfo', 'GetActiveDeviceInfo.cgi?DeviceClass=System', true);
    rawData.powerFlow = await this.optionalSolarResource(rawData, endpoint, apiInfo, 'powerFlow', 'GetPowerFlowRealtimeData.fcgi', true);
    rawData.systemMeter = await this.optionalSolarResource(rawData, endpoint, apiInfo, 'systemMeter', 'GetMeterRealtimeData.cgi?Scope=System', true);
    rawData.systemOhmpilot = await this.optionalSolarResource(rawData, endpoint, apiInfo, 'systemOhmpilot', 'GetOhmPilotRealtimeData.cgi?Scope=System', true);
    rawData.systemStorage = await this.optionalSolarResource(rawData, endpoint, apiInfo, 'systemStorage', 'GetStorageRealtimeData.cgi?Scope=System', true);

    for (const deviceId of FroniusMapper.inverterDeviceIds(rawData.inverterInfo)) {
      const queryKey = apiInfo.apiVersion === 'v0' ? 'DeviceIndex' : 'DeviceId';
      const path = `GetInverterRealtimeData.cgi?Scope=Device&${queryKey}=${encodeURIComponent(deviceId)}&DataCollection=CommonInverterData`;
      const value = await this.optionalSolarResource(rawData, endpoint, apiInfo, `inverterDeviceData.${deviceId}`, path);
      if (value !== undefined) {
        rawData.inverterDeviceData = rawData.inverterDeviceData || {};
        rawData.inverterDeviceData[deviceId] = value;
      }
    }

    if (!Object.keys(rawData.errors || {}).length) {
      delete rawData.errors;
    }
    if (!FroniusMapper.hasUsableRawData(rawData)) {
      const error = Object.values(rawData.errors || {})[0] || 'Fronius Solar API did not return logger, inverter, power-flow, meter, ohmpilot, or storage data.';
      throw new FroniusApiInvalidResponseError(error);
    }

    return FroniusMapper.toSnapshot({ config: this.config, rawData, online: true, source: 'http' });
  }

  public async execute(commandArg: IFroniusCommandRequest): Promise<unknown> {
    if (this.config.commandExecutor) {
      return this.config.commandExecutor.execute(commandArg);
    }
    if (this.config.client?.execute) {
      return this.config.client.execute(commandArg);
    }
    if (commandArg.action === 'refresh') {
      return (await this.refresh()).snapshot;
    }
    if (commandArg.action === 'snapshot') {
      return this.getSnapshot();
    }
    throw new FroniusApiConnectionError('Fronius Solar API snapshots are read-only here. Writes require an injected client.execute or commandExecutor that represents the command explicitly.');
  }

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

  private async snapshotFromClient(clientArg: IFroniusClientLike): Promise<IFroniusSnapshot> {
    if (clientArg.getSnapshot) {
      const result = await clientArg.getSnapshot();
      if (this.isSnapshot(result)) {
        return FroniusMapper.toSnapshot({ config: { ...this.config, snapshot: result }, source: 'client', online: result.online });
      }
      return FroniusMapper.toSnapshot({ config: this.config, rawData: result as Partial<IFroniusRawData>, online: true, source: 'client' });
    }
    if (clientArg.getRawData) {
      return FroniusMapper.toSnapshot({ config: this.config, rawData: await clientArg.getRawData(), online: true, source: 'client' });
    }

    const rawData = await this.rawDataFromClient(clientArg);
    if (!FroniusMapper.hasUsableRawData(rawData)) {
      throw new FroniusApiConnectionError('Fronius client did not return logger, inverter, power-flow, meter, ohmpilot, or storage data.');
    }
    return FroniusMapper.toSnapshot({ config: this.config, rawData, online: true, source: 'client' });
  }

  private async rawDataFromClient(clientArg: IFroniusClientLike): Promise<Partial<IFroniusRawData>> {
    const rawData: Partial<IFroniusRawData> = { errors: {}, inverterDeviceData: {}, fetchedAt: new Date().toISOString() };
    rawData.loggerInfo = await this.callOptional(clientArg.currentLoggerInfo || clientArg.current_logger_info, clientArg).catch((errorArg) => this.captureClientError(rawData, 'loggerInfo', errorArg));
    rawData.inverterInfo = await this.callOptional(clientArg.inverterInfo || clientArg.inverter_info, clientArg).catch((errorArg) => this.captureClientError(rawData, 'inverterInfo', errorArg));
    rawData.activeDeviceInfo = await this.callOptional(clientArg.currentActiveDeviceInfo || clientArg.current_active_device_info, clientArg).catch((errorArg) => this.captureClientError(rawData, 'activeDeviceInfo', errorArg));
    rawData.powerFlow = await this.callOptional(clientArg.currentPowerFlow || clientArg.current_power_flow, clientArg).catch((errorArg) => this.captureClientError(rawData, 'powerFlow', errorArg));
    rawData.systemMeter = await this.callOptional(clientArg.currentSystemMeterData || clientArg.current_system_meter_data, clientArg).catch((errorArg) => this.captureClientError(rawData, 'systemMeter', errorArg));
    rawData.systemOhmpilot = await this.callOptional(clientArg.currentSystemOhmpilotData || clientArg.current_system_ohmpilot_data, clientArg).catch((errorArg) => this.captureClientError(rawData, 'systemOhmpilot', errorArg));
    rawData.systemStorage = await this.callOptional(clientArg.currentSystemStorageData || clientArg.current_system_storage_data, clientArg).catch((errorArg) => this.captureClientError(rawData, 'systemStorage', errorArg));

    for (const deviceId of FroniusMapper.inverterDeviceIds(rawData.inverterInfo)) {
      const value = await this.callOptional(clientArg.currentInverterData || clientArg.current_inverter_data, clientArg, deviceId).catch((errorArg) => this.captureClientError(rawData, `inverterDeviceData.${deviceId}`, errorArg));
      if (value !== undefined) {
        rawData.inverterDeviceData = rawData.inverterDeviceData || {};
        rawData.inverterDeviceData[deviceId] = value;
      }
    }

    if (!Object.keys(rawData.errors || {}).length) {
      delete rawData.errors;
    }
    return rawData;
  }

  private captureClientError(rawDataArg: Partial<IFroniusRawData>, keyArg: string, errorArg: unknown): undefined {
    rawDataArg.errors = rawDataArg.errors || {};
    rawDataArg.errors[keyArg] = this.errorMessage(errorArg);
    return undefined;
  }

  private async optionalSolarResource(rawDataArg: Partial<IFroniusRawData>, endpointArg: IFroniusEndpoint, apiInfoArg: IFroniusApiInfo, keyArg: string, pathArg: string, v1OnlyArg = false): Promise<unknown> {
    try {
      return await this.requestSolarJson(endpointArg, apiInfoArg, pathArg, v1OnlyArg);
    } catch (errorArg) {
      rawDataArg.errors = rawDataArg.errors || {};
      rawDataArg.errors[keyArg] = this.errorMessage(errorArg);
      return undefined;
    }
  }

  private async resolveApiInfo(endpointArg: IFroniusEndpoint): Promise<IFroniusApiInfo> {
    if (this.config.apiVersion === 'v0') {
      return { apiVersion: 'v0', baseUrl: '/solar_api/', raw: { APIVersion: 0, BaseURL: '/solar_api/' } };
    }
    if (this.config.apiVersion === 'v1') {
      return { apiVersion: 'v1', baseUrl: '/solar_api/v1/', raw: { APIVersion: 1, BaseURL: '/solar_api/v1/' } };
    }

    const response = await this.requestText(endpointArg, '/solar_api/GetAPIVersion.cgi');
    if (response.status === 404) {
      return { apiVersion: 'v0', baseUrl: '/solar_api/', raw: { APIVersion: 0, BaseURL: '/solar_api/' } };
    }
    this.assertHttpSuccess(response, '/solar_api/GetAPIVersion.cgi');
    const raw = this.parseJson(response.text, '/solar_api/GetAPIVersion.cgi');
    const apiVersion = Number(raw.APIVersion) === 0 ? 'v0' : 'v1';
    const baseUrl = typeof raw.BaseURL === 'string' && raw.BaseURL ? raw.BaseURL : apiVersion === 'v0' ? '/solar_api/' : '/solar_api/v1/';
    return { apiVersion, baseUrl, raw };
  }

  private async requestSolarJson(endpointArg: IFroniusEndpoint, apiInfoArg: IFroniusApiInfo, pathArg: string, v1OnlyArg: boolean): Promise<Record<string, unknown>> {
    if (v1OnlyArg && apiInfoArg.apiVersion === 'v0') {
      throw new FroniusApiUnsupportedError(`Fronius API v0 does not support ${pathArg}.`);
    }
    const path = joinSolarPath(apiInfoArg.baseUrl, pathArg);
    const response = await this.requestText(endpointArg, path);
    this.assertHttpSuccess(response, path);
    const json = this.parseJson(response.text, path);
    this.assertSolarStatus(json, path);
    return json;
  }

  private async requestText(endpointArg: IFroniusEndpoint, pathArg: string, redirectLimitArg = 2): Promise<IFroniusHttpResponse> {
    const response = await this.httpRequest(endpointArg, pathArg);
    if (isRedirect(response.status) && response.headers.location && redirectLimitArg > 0) {
      const redirected = redirectedEndpoint(endpointArg, pathArg, String(response.headers.location));
      return this.requestText(redirected.endpoint, redirected.path, redirectLimitArg - 1);
    }
    return response;
  }

  private async httpRequest(endpointArg: IFroniusEndpoint, pathArg: string): Promise<IFroniusHttpResponse> {
    const transport = endpointArg.protocol === 'https' ? await import('node:https') : await import('node:http');
    const timeoutMs = this.config.timeoutMs || froniusDefaultTimeoutMs;
    return await new Promise<IFroniusHttpResponse>((resolve, reject) => {
      const requestOptions: Record<string, unknown> = {
        method: 'GET',
        hostname: endpointArg.host,
        port: endpointArg.port,
        path: pathArg,
        headers: { Accept: 'application/json' },
      };
      if (endpointArg.protocol === 'https') {
        requestOptions.rejectUnauthorized = this.config.verifyTls === true;
      }
      const request = transport.request(requestOptions, (res) => {
        const chunks: Buffer[] = [];
        res.on('data', (chunkArg: Buffer | string) => {
          chunks.push(Buffer.isBuffer(chunkArg) ? chunkArg : Buffer.from(String(chunkArg)));
        });
        res.on('end', () => {
          clearTimeout(timer);
          resolve({ status: res.statusCode || 0, text: Buffer.concat(chunks).toString('utf8'), headers: res.headers });
        });
      });
      const timer = setTimeout(() => {
        request.destroy(new FroniusApiConnectionError(`Fronius API request timed out after ${timeoutMs}ms.`));
      }, timeoutMs);
      request.on('error', (errorArg: Error) => {
        clearTimeout(timer);
        reject(errorArg instanceof FroniusApiError ? errorArg : new FroniusApiConnectionError(this.errorMessage(errorArg)));
      });
      request.end();
    });
  }

  private assertHttpSuccess(responseArg: IFroniusHttpResponse, pathArg: string): void {
    if (responseArg.status === 404) {
      throw new FroniusApiUnsupportedError(`Fronius Solar API endpoint is not available: ${pathArg}`);
    }
    if (responseArg.status < 200 || responseArg.status >= 300) {
      throw new FroniusApiConnectionError(`Fronius Solar API request failed with HTTP ${responseArg.status}: ${responseArg.text}`);
    }
  }

  private assertSolarStatus(responseArg: Record<string, unknown>, pathArg: string): void {
    const status = objectValue(objectValue(responseArg.Head)?.Status);
    const code = numberValue(status?.Code);
    if (code !== undefined && code !== 0) {
      throw new FroniusApiBadStatusError(pathArg, code, stringValue(status?.Reason), responseArg);
    }
  }

  private parseJson(textArg: string, pathArg: string): Record<string, unknown> {
    try {
      const result = JSON.parse(textArg) as unknown;
      const object = objectValue(result);
      if (!object) {
        throw new Error('JSON root is not an object.');
      }
      return object;
    } catch (errorArg) {
      throw new FroniusApiInvalidResponseError(`Fronius Solar API returned non-JSON data at ${pathArg}: ${this.errorMessage(errorArg)}`);
    }
  }

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

  private isSnapshot(valueArg: unknown): valueArg is IFroniusSnapshot {
    return Boolean(valueArg && typeof valueArg === 'object' && 'system' in valueArg && 'sensors' in valueArg && 'capabilities' in valueArg);
  }

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

  private async callOptional<TValue>(methodArg: unknown, thisArg: unknown, ...args: unknown[]): Promise<TValue | undefined> {
    if (typeof methodArg !== 'function') {
      return undefined;
    }
    return await (methodArg as (...args: unknown[]) => Promise<TValue> | TValue).apply(thisArg, args);
  }

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

function joinSolarPath(baseUrlArg: string, pathArg: string): string {
  const base = baseUrlArg.endsWith('/') ? baseUrlArg : `${baseUrlArg}/`;
  return `${base}${pathArg.replace(/^\//, '')}`;
}

function isRedirect(statusArg: number): boolean {
  return statusArg === 301 || statusArg === 302 || statusArg === 303 || statusArg === 307 || statusArg === 308;
}

function redirectedEndpoint(endpointArg: IFroniusEndpoint, pathArg: string, locationArg: string): { endpoint: IFroniusEndpoint; path: string } {
  const base = `${endpointArg.protocol}://${endpointArg.host}:${endpointArg.port}${pathArg}`;
  const url = new URL(locationArg, base);
  const protocol = url.protocol === 'https:' ? 'https' : 'http';
  return {
    endpoint: {
      protocol,
      host: url.hostname,
      port: url.port ? Number(url.port) : protocol === 'https' ? froniusDefaultHttpsPort : froniusDefaultHttpPort,
    },
    path: `${url.pathname}${url.search}`,
  };
}

function objectValue(valueArg: unknown): Record<string, unknown> | undefined {
  return valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg) ? valueArg as Record<string, unknown> : undefined;
}

function stringValue(valueArg: unknown): string | undefined {
  return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
}

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