import * as plugins from '../../plugins.js';
import { SimpleLocalClient, type ISimpleLocalEntitySnapshot, type IServiceCallRequest, type IServiceCallResult } from '../../core/index.js';
import { ImmichMapper } from './immich.mapper.js';
import type { IImmichConfig, IImmichRawData, IImmichServerAbout, IImmichServerStatistics, IImmichServerStorage, IImmichServerVersionCheck, IImmichSnapshot, IImmichUserInfo } from './immich.types.js';
import { immichDefaultName, immichProfile } from './immich.types.js';

export class ImmichClient extends SimpleLocalClient<IImmichConfig> {
  constructor(private readonly configArg: IImmichConfig) {
    super(immichProfile, configArg);
  }

  public async getSnapshot(forceRefreshArg = false): Promise<IImmichSnapshot> {
    if (this.shouldUseLiveApi()) {
      try {
        return await this.fetchLiveSnapshot();
      } catch (errorArg) {
        return this.offlineImmichSnapshot(errorMessage(errorArg));
      }
    }

    if (this.hasEndpoint() && !this.apiKey() && !this.hasOfflineSource()) {
      return this.offlineImmichSnapshot('Immich live polling requires config.apiKey or config.token for x-api-key authentication.');
    }

    return await super.getSnapshot(forceRefreshArg) as IImmichSnapshot;
  }

  public async execute(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
    if (requestArg.service !== 'upload_file') {
      return super.execute(requestArg);
    }

    if (this.shouldUseLiveApi()) {
      try {
        return { success: true, data: await this.uploadFile(requestArg) };
      } catch (errorArg) {
        return { success: false, error: errorMessage(errorArg) };
      }
    }

    if (this.configArg.client?.execute || this.configArg.commandExecutor) {
      return super.execute(requestArg);
    }

    return {
      success: false,
      error: 'Immich upload_file requires config.url or config.host plus config.apiKey/config.token and data.path/data.filePath/data.file.path for a local file.',
    };
  }

  private async fetchLiveSnapshot(): Promise<IImmichSnapshot> {
    const user = await this.requestJson<IImmichUserInfo>('users/me');
    const [serverAbout, serverStorage] = await Promise.all([
      this.requestJson<IImmichServerAbout>('server/about'),
      this.requestJson<IImmichServerStorage>('server/storage'),
    ]);
    const serverUsage = user.isAdmin ? await this.requestJson<IImmichServerStatistics>('server/statistics') : null;
    const serverVersionCheck = versionAtLeast(serverAbout.version, '1.134.0')
      ? await this.requestJson<IImmichServerVersionCheck>('server/version-check')
      : null;
    const rawData: IImmichRawData = {
      user,
      server_about: serverAbout,
      server_storage: serverStorage,
      server_usage: serverUsage,
      server_version_check: serverVersionCheck,
    };
    const endpoint = this.endpoint();
    const updatedAt = new Date().toISOString();
    return ImmichMapper.toSnapshot({
      config: this.configArg,
      rawData: {
        device: {
          id: this.configArg.uniqueId || stringValue(user.id) || endpoint?.host || immichProfile.domain,
          name: this.configArg.name || stringValue(user.name) || immichDefaultName,
          manufacturer: 'Immich',
          model: 'Immich server',
          host: endpoint?.host,
          port: endpoint?.port,
          protocol: endpoint?.useTls ? 'https' : 'http',
          configurationUrl: endpoint?.origin,
          attributes: {
            version: serverAbout.version,
            licensed: serverAbout.licensed,
            userEmail: user.email,
            isAdmin: user.isAdmin,
          },
        },
        entities: this.entitiesFromData(rawData),
        online: true,
        updatedAt,
        source: 'http',
        rawData,
        capabilities: {
          localRead: true,
          localControl: true,
          uploadFile: true,
        },
      },
      online: true,
      source: 'http',
    }) as IImmichSnapshot;
  }

  private entitiesFromData(rawDataArg: IImmichRawData): ISimpleLocalEntitySnapshot[] {
    const storage = rawDataArg.server_storage || {};
    const usage = rawDataArg.server_usage;
    const versionCheck = rawDataArg.server_version_check;
    const about = rawDataArg.server_about || {};
    const entities: ISimpleLocalEntitySnapshot[] = [
      this.sensor('disk_size', 'Disk size', storage.diskSizeRaw, 'B', 'data_size', 'measurement', { formatted: storage.diskSize }),
      this.sensor('disk_available', 'Disk available', storage.diskAvailableRaw, 'B', 'data_size', 'measurement', { formatted: storage.diskAvailable }),
      this.sensor('disk_use', 'Disk use', storage.diskUseRaw, 'B', 'data_size', 'measurement', { formatted: storage.diskUse }),
      this.sensor('disk_usage', 'Disk usage', storage.diskUsagePercentage, '%', undefined, 'measurement'),
    ];

    if (usage) {
      entities.push(
        this.sensor('photos_count', 'Photos count', usage.photos, undefined, undefined, 'measurement'),
        this.sensor('videos_count', 'Videos count', usage.videos, undefined, undefined, 'measurement'),
        this.sensor('usage_by_photos', 'Usage by photos', usage.usagePhotos, 'B', 'data_size', 'measurement'),
        this.sensor('usage_by_videos', 'Usage by videos', usage.usageVideos, 'B', 'data_size', 'measurement'),
      );
    }

    if (versionCheck?.releaseVersion) {
      entities.push({
        id: 'update',
        name: 'Update',
        platform: 'update',
        state: versionCheck.releaseVersion,
        available: true,
        attributes: {
          installedVersion: about.version,
          latestVersion: versionCheck.releaseVersion,
          releaseUrl: `https://github.com/immich-app/immich/releases/tag/${versionCheck.releaseVersion}`,
          checkedAt: versionCheck.checkedAt,
        },
      });
    }

    return entities.filter((entityArg) => entityArg.state !== undefined);
  }

  private sensor(idArg: string, nameArg: string, stateArg: unknown, unitArg?: string, deviceClassArg?: string, stateClassArg?: string, attributesArg?: Record<string, unknown>): ISimpleLocalEntitySnapshot {
    return {
      id: idArg,
      name: nameArg,
      platform: 'sensor',
      state: stateArg,
      available: stateArg !== undefined,
      unit: unitArg,
      deviceClass: deviceClassArg,
      stateClass: stateClassArg,
      attributes: cleanAttributes(attributesArg),
    };
  }

  private async uploadFile(requestArg: IServiceCallRequest): Promise<Record<string, unknown>> {
    const filePath = this.filePathFromRequest(requestArg);
    if (!filePath) {
      throw new Error('Immich upload_file requires data.path, data.filePath, or data.file.path.');
    }

    const stats = await plugins.fs.stat(filePath);
    if (!stats.isFile()) {
      throw new Error(`Immich upload_file path is not a file: ${filePath}`);
    }

    const albumId = stringValue(requestArg.data?.album_id) || stringValue(requestArg.data?.albumId);
    if (albumId) {
      await this.requestJson(`albums/${encodeURIComponent(albumId)}`, { params: { withoutAssets: 'true' } });
    }

    const deviceId = this.configArg.deviceId || this.configArg.uniqueId || 'smarthome-exchange';
    const filename = plugins.path.basename(filePath);
    const bytes = new Uint8Array(await plugins.fs.readFile(filePath));
    const formData = new FormData();
    formData.set('deviceAssetId', `${deviceId}-${filePath}-${stats.mtimeMs}`);
    formData.set('deviceId', deviceId);
    formData.set('fileCreatedAt', stats.mtime.toISOString());
    formData.set('fileModifiedAt', stats.mtime.toISOString());
    formData.set('isFavorite', 'false');
    formData.set('assetData', new Blob([bytes], { type: 'application/octet-stream' }), filename);

    const upload = await this.requestJson<Record<string, unknown>>('assets', { method: 'POST', body: formData });
    let albumResult: unknown;
    if (albumId) {
      const assetId = stringValue(upload.id) || stringValue(upload.assetId);
      if (!assetId) {
        throw new Error('Immich upload response did not include an asset id.');
      }
      albumResult = await this.requestJson(`albums/${encodeURIComponent(albumId)}/assets`, {
        method: 'PUT',
        json: { ids: [assetId] },
      });
    }

    return { upload, album: albumResult };
  }

  private filePathFromRequest(requestArg: IServiceCallRequest): string | undefined {
    const data = requestArg.data || {};
    const file = data.file;
    if (typeof file === 'string') {
      return file;
    }
    if (file && typeof file === 'object') {
      const record = file as Record<string, unknown>;
      return stringValue(record.path) || stringValue(record.filePath) || filePathFromMediaContentId(record.media_content_id) || filePathFromMediaContentId(record.mediaContentId);
    }
    return stringValue(data.path) || stringValue(data.filePath) || filePathFromMediaContentId(data.media_content_id) || filePathFromMediaContentId(data.mediaContentId);
  }

  private async requestJson<TValue = unknown>(endpointArg: string, optionsArg: { method?: string; params?: Record<string, string>; json?: unknown; body?: BodyInit } = {}): Promise<TValue> {
    const endpoint = this.endpoint();
    const apiKey = this.apiKey();
    if (!endpoint || !apiKey) {
      throw new Error('Immich live HTTP requests require config.url or config.host plus config.apiKey/config.token.');
    }

    const url = new URL(`${endpoint.apiBase.replace(/\/+$/, '')}/${endpointArg.replace(/^\/+/, '')}`);
    for (const [key, value] of Object.entries(optionsArg.params || {})) {
      url.searchParams.set(key, value);
    }

    const headers: Record<string, string> = { accept: 'application/json', 'x-api-key': apiKey };
    let body = optionsArg.body;
    if (optionsArg.json !== undefined) {
      headers['content-type'] = 'application/json';
      body = JSON.stringify(optionsArg.json);
    }

    const timeoutMs = this.timeoutMs();
    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), timeoutMs);
    try {
      const response = await globalThis.fetch(url, {
        method: optionsArg.method || 'GET',
        headers,
        body,
        signal: controller.signal,
      });
      const text = await response.text();
      if (!response.ok) {
        throw new Error(`Immich HTTP ${optionsArg.method || 'GET'} ${url.pathname} failed with ${response.status}: ${text.slice(0, 200)}`);
      }
      return parseJson<TValue>(text);
    } finally {
      clearTimeout(timer);
    }
  }

  private shouldUseLiveApi(): boolean {
    return Boolean(this.endpoint() && this.apiKey() && !this.hasOfflineSource());
  }

  private hasOfflineSource(): 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 hasEndpoint(): boolean {
    return Boolean(this.endpoint());
  }

  private endpoint(): { apiBase: string; origin: string; host: string; port?: number; useTls: boolean } | undefined {
    const rawUrl = stringValue(this.configArg.url) || (stringValue(this.configArg.host)?.includes('://') ? stringValue(this.configArg.host) : undefined);
    if (rawUrl) {
      const url = new URL(rawUrl);
      const path = (stringValue(this.configArg.path) || url.pathname || '').replace(/\/+$/, '');
      const apiPath = path.endsWith('/api') ? path : `${path || ''}/api`;
      return {
        apiBase: `${url.origin}${apiPath}`,
        origin: url.origin,
        host: url.hostname,
        port: url.port ? Number(url.port) : undefined,
        useTls: url.protocol === 'https:',
      };
    }

    const host = stringValue(this.configArg.host);
    if (!host) {
      return undefined;
    }
    const useTls = Boolean(this.configArg.useTls ?? this.configArg.ssl);
    const port = this.configArg.port || immichProfile.defaultPort;
    const scheme = useTls ? 'https' : 'http';
    const portPart = port && !((useTls && port === 443) || (!useTls && port === 80)) ? `:${port}` : '';
    const origin = `${scheme}://${host}${portPart}`;
    const path = stringValue(this.configArg.path)?.replace(/\/+$/, '') || '/api';
    const apiPath = path.endsWith('/api') ? path : `${path}/api`;
    return { apiBase: `${origin}${apiPath}`, origin, host, port, useTls };
  }

  private apiKey(): string | undefined {
    return stringValue(this.configArg.apiKey) || stringValue(this.configArg.token);
  }

  private timeoutMs(): number {
    return typeof this.configArg.timeoutMs === 'number' && this.configArg.timeoutMs > 0 ? this.configArg.timeoutMs : 5000;
  }

  private offlineImmichSnapshot(errorArg: string): IImmichSnapshot {
    const endpoint = this.endpoint();
    return ImmichMapper.toSnapshot({
      config: this.configArg,
      rawData: {
        device: {
          id: this.configArg.uniqueId || endpoint?.host || immichProfile.domain,
          name: this.configArg.name || immichDefaultName,
          manufacturer: 'Immich',
          model: 'Immich server',
          host: endpoint?.host,
          port: endpoint?.port,
          protocol: endpoint?.useTls ? 'https' : 'http',
          configurationUrl: endpoint?.origin,
        },
        entities: [{ id: 'online', name: 'Online', platform: 'binary_sensor', state: false, available: true, attributes: { error: errorArg } }],
        online: false,
        updatedAt: new Date().toISOString(),
        source: 'runtime',
        error: errorArg,
      },
      online: false,
      source: 'runtime',
      error: errorArg,
    }) as IImmichSnapshot;
  }
}

const versionAtLeast = (valueArg: unknown, minimumArg: string): boolean => {
  const current = numericVersion(valueArg);
  const minimum = numericVersion(minimumArg);
  for (let index = 0; index < Math.max(current.length, minimum.length); index += 1) {
    const currentPart = current[index] || 0;
    const minimumPart = minimum[index] || 0;
    if (currentPart > minimumPart) {
      return true;
    }
    if (currentPart < minimumPart) {
      return false;
    }
  }
  return true;
};

const numericVersion = (valueArg: unknown): number[] => String(valueArg || '').replace(/^v/i, '').split('.').map((partArg) => Number(partArg.replace(/\D.*$/, '')) || 0);

const parseJson = <TValue>(textArg: string): TValue => {
  if (!textArg.trim()) {
    return undefined as TValue;
  }
  return JSON.parse(textArg) as TValue;
};

const cleanAttributes = (attributesArg: Record<string, unknown> | undefined): Record<string, unknown> | undefined => {
  if (!attributesArg) {
    return undefined;
  }
  const result = Object.fromEntries(Object.entries(attributesArg).filter(([, valueArg]) => valueArg !== undefined));
  return Object.keys(result).length ? result : undefined;
};

const filePathFromMediaContentId = (valueArg: unknown): string | undefined => {
  const value = stringValue(valueArg);
  if (!value) {
    return undefined;
  }
  if (value.startsWith('file://')) {
    return new URL(value).pathname;
  }
  return value.startsWith('/') ? value : undefined;
};

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);
