import type {
  IPlexClientInfo,
  IPlexCommandResult,
  IPlexConfig,
  IPlexEvent,
  IPlexLibrarySection,
  IPlexMediaContainer,
  IPlexPlayMediaCommand,
  IPlexPlaybackCommand,
  IPlexRefreshLibraryCommand,
  IPlexServerInfo,
  IPlexSession,
  IPlexSnapshot,
  TPlexMediaContentType,
} from './plex.types.js';
import { plexDefaultPort } from './plex.types.js';

type TPlexEventHandler = (eventArg: IPlexEvent) => void;

const defaultTimeoutMs = 7000;
const defaultClientIdentifier = 'smarthome-exchange-plex';
const plexProduct = 'smarthome.exchange';
const plexPlatform = 'Node.js';
const plexDeviceName = 'smarthome.exchange';

export class PlexClient {
  private commandId = 0;
  private currentSnapshot?: IPlexSnapshot;
  private readonly eventHandlers = new Set<TPlexEventHandler>();

  constructor(private readonly config: IPlexConfig) {
    this.currentSnapshot = config.snapshot ? this.cloneSnapshot(config.snapshot) : undefined;
  }

  public async getSnapshot(): Promise<IPlexSnapshot> {
    if (!this.hasLiveServer()) {
      const snapshot = this.normalizeSnapshot(this.currentSnapshot || this.manualSnapshot(), 'manual');
      this.currentSnapshot = this.cloneSnapshot(snapshot);
      return this.cloneSnapshot(snapshot);
    }

    try {
      const [server, identity, clients, sessions, libraries] = await Promise.all([
        this.getServerInfo().catch(() => undefined),
        this.getIdentity().catch(() => undefined),
        this.getClients().catch(() => []),
        this.getSessions().catch(() => []),
        this.getLibraries().catch(() => []),
      ]);
      if (!server && !identity) {
        throw new Error('Plex server did not respond to / or /identity.');
      }

      const librariesWithCounts = await Promise.all(libraries.map(async (libraryArg) => ({
        ...libraryArg,
        itemCount: libraryArg.itemCount ?? await this.getLibraryItemCount(libraryArg).catch(() => undefined),
      })));

      const snapshot = this.normalizeSnapshot({
        server: {
          ...this.serverFromConfig(),
          ...server,
          ...identity,
          online: true,
        },
        clients: this.mergeClients(clients, sessions),
        sessions,
        libraries: librariesWithCounts,
        capturedAt: new Date().toISOString(),
        source: 'http',
        online: true,
      }, 'http');
      this.currentSnapshot = this.cloneSnapshot(snapshot);
      this.emit({ type: 'snapshot', timestamp: Date.now(), serverId: snapshot.server.machineIdentifier, data: snapshot });
      return this.cloneSnapshot(snapshot);
    } catch (errorArg) {
      const fallback = this.normalizeSnapshot(this.currentSnapshot || this.manualSnapshot(), this.currentSnapshot ? this.currentSnapshot.source || 'manual' : 'manual');
      fallback.online = false;
      fallback.server.online = false;
      this.emit({ type: 'error', timestamp: Date.now(), serverId: fallback.server.machineIdentifier, data: { message: this.errorMessage(errorArg) } });
      return fallback;
    }
  }

  public async getServerInfo(): Promise<IPlexServerInfo> {
    const container = await this.requestJson<IPlexMediaContainer>('/', { method: 'GET' });
    return this.normalizeServer(container.MediaContainer || {});
  }

  public async getIdentity(): Promise<IPlexServerInfo> {
    const container = await this.requestJson<IPlexMediaContainer>('/identity', { method: 'GET', allowWithoutToken: true });
    return this.normalizeServer(container.MediaContainer || {});
  }

  public async getClients(): Promise<IPlexClientInfo[]> {
    const container = await this.requestJson<IPlexMediaContainer<unknown, unknown>>('/clients', { method: 'GET' });
    const mediaContainer = container.MediaContainer || {};
    const rawClients = this.arrayValue<IPlexClientInfo>(mediaContainer.Server) || this.arrayValue<IPlexClientInfo>(mediaContainer.Directory) || [];
    return rawClients.map((clientArg) => this.normalizeClient({ ...clientArg, source: clientArg.source || 'PMS' }));
  }

  public async getSessions(): Promise<IPlexSession[]> {
    const container = await this.requestJson<IPlexMediaContainer<unknown, IPlexSession>>('/status/sessions', { method: 'GET' });
    return (container.MediaContainer?.Metadata || []).map((sessionArg) => this.normalizeSession(sessionArg));
  }

  public async getLibraries(): Promise<IPlexLibrarySection[]> {
    const container = await this.requestJson<IPlexMediaContainer<IPlexLibrarySection>>('/library/sections/all', { method: 'GET' });
    return (container.MediaContainer?.Directory || []).map((libraryArg) => this.normalizeLibrary(libraryArg));
  }

  public async refreshLibrary(commandArg: IPlexRefreshLibraryCommand): Promise<IPlexCommandResult> {
    try {
      const library = await this.resolveLibrary(commandArg);
      if (!library) {
        return { success: false, error: 'Plex refresh_library requires data.library_name or data.library_id.' };
      }

      if (!this.hasLiveServer()) {
        this.emit({ type: 'library_refresh', timestamp: Date.now(), serverId: this.currentSnapshot?.server.machineIdentifier, command: commandArg, data: { library } });
        return { success: true, data: { library, mode: 'snapshot' } };
      }

      await this.requestText(`/library/sections/${encodeURIComponent(String(library.key))}/refresh`, {
        method: 'POST',
        searchParams: {
          force: commandArg.force === undefined ? undefined : commandArg.force ? 1 : 0,
          path: commandArg.path,
        },
      });
      this.emit({ type: 'library_refresh', timestamp: Date.now(), serverId: this.currentSnapshot?.server.machineIdentifier, command: commandArg, data: { library } });
      return { success: true, data: { library } };
    } catch (errorArg) {
      const error = this.errorMessage(errorArg);
      this.emit({ type: 'error', timestamp: Date.now(), command: commandArg, data: { message: error } });
      return { success: false, error };
    }
  }

  public async sendPlaybackCommand(commandArg: IPlexPlaybackCommand | IPlexPlayMediaCommand): Promise<IPlexCommandResult> {
    try {
      if (!commandArg.clientIdentifier) {
        return { success: false, error: 'Plex playback command requires a client identifier.' };
      }

      if (!this.hasLiveServer()) {
        const snapshot = this.currentSnapshot || this.manualSnapshot();
        this.applySnapshotCommand(snapshot, commandArg);
        this.currentSnapshot = this.normalizeSnapshot(snapshot, snapshot.source || 'manual');
        this.emit({ type: 'command', timestamp: Date.now(), serverId: this.currentSnapshot.server.machineIdentifier, clientIdentifier: commandArg.clientIdentifier, command: commandArg });
        return { success: true, data: { mode: 'snapshot', command: commandArg } };
      }

      const path = `/player/playback/${commandArg.command}`;
      await this.requestText(path, {
        method: 'GET',
        headers: {
          'X-Plex-Target-Client-Identifier': commandArg.clientIdentifier,
        },
        searchParams: this.commandSearchParams(commandArg),
      });
      this.emit({ type: 'command', timestamp: Date.now(), serverId: this.currentSnapshot?.server.machineIdentifier, clientIdentifier: commandArg.clientIdentifier, command: commandArg });
      return { success: true };
    } catch (errorArg) {
      const error = this.errorMessage(errorArg);
      this.emit({ type: 'error', timestamp: Date.now(), clientIdentifier: commandArg.clientIdentifier, command: commandArg, data: { message: error } });
      return { success: false, error };
    }
  }

  public onEvent(handlerArg: TPlexEventHandler): () => void {
    this.eventHandlers.add(handlerArg);
    return () => this.eventHandlers.delete(handlerArg);
  }

  public async destroy(): Promise<void> {
    this.eventHandlers.clear();
  }

  private async getLibraryItemCount(libraryArg: IPlexLibrarySection): Promise<number | undefined> {
    const container = await this.requestJson<IPlexMediaContainer>(`/library/sections/${encodeURIComponent(String(libraryArg.key))}/all`, {
      method: 'GET',
      searchParams: {
        'X-Plex-Container-Start': 0,
        'X-Plex-Container-Size': 0,
      },
    });
    return this.numberValue(container.MediaContainer?.totalSize) ?? this.numberValue(container.MediaContainer?.size);
  }

  private async resolveLibrary(commandArg: IPlexRefreshLibraryCommand): Promise<IPlexLibrarySection | undefined> {
    const snapshot = this.currentSnapshot || await this.getSnapshot();
    if (commandArg.libraryKey !== undefined) {
      const key = String(commandArg.libraryKey);
      return snapshot.libraries.find((libraryArg) => String(libraryArg.key) === key);
    }
    if (commandArg.libraryName) {
      const name = commandArg.libraryName.toLowerCase();
      return snapshot.libraries.find((libraryArg) => libraryArg.title?.toLowerCase() === name);
    }
    return undefined;
  }

  private commandSearchParams(commandArg: IPlexPlaybackCommand | IPlexPlayMediaCommand): Record<string, string | number | boolean | undefined> {
    const params: Record<string, string | number | boolean | undefined> = {
      ...commandArg.params,
      commandID: ++this.commandId,
      type: commandArg.mediaType || 'video',
    };
    if (commandArg.command === 'seekTo' && typeof commandArg.offsetMs === 'number') {
      params.offset = Math.max(0, Math.round(commandArg.offsetMs));
    }
    if (commandArg.command === 'setParameters' && typeof commandArg.volume === 'number') {
      params.volume = Math.max(0, Math.min(100, Math.round(commandArg.volume)));
    }
    if (commandArg.command === 'playMedia') {
      const playMedia = commandArg as IPlexPlayMediaCommand;
      const server = this.currentSnapshot?.server || this.serverFromConfig();
      params.key = playMedia.key;
      params.containerKey = playMedia.containerKey || playMedia.key;
      params.machineIdentifier = playMedia.machineIdentifier || server.machineIdentifier;
      params.address = playMedia.address || server.host || this.config.host;
      params.port = playMedia.port || server.port || this.config.port || plexDefaultPort;
      params.protocol = playMedia.protocol || (server.ssl || this.config.ssl ? 'https' : 'http');
      params.token = playMedia.token || this.config.token;
      params.providerIdentifier = 'com.plexapp.plugins.library';
      params.offset = playMedia.offsetMs || commandArg.offsetMs || 0;
    }
    return params;
  }

  private applySnapshotCommand(snapshotArg: IPlexSnapshot, commandArg: IPlexPlaybackCommand | IPlexPlayMediaCommand): void {
    const session = snapshotArg.sessions.find((sessionArg) => this.clientIdentifier(sessionArg.Player) === commandArg.clientIdentifier);
    const client = snapshotArg.clients.find((clientArg) => this.clientIdentifier(clientArg) === commandArg.clientIdentifier);
    if (commandArg.command === 'play') {
      if (session) {
        session.state = 'playing';
      }
      if (client) {
        client.state = 'playing';
      }
    } else if (commandArg.command === 'pause') {
      if (session) {
        session.state = 'paused';
      }
      if (client) {
        client.state = 'paused';
      }
    } else if (commandArg.command === 'stop') {
      if (session) {
        session.state = 'stopped';
      }
      if (client) {
        client.state = 'idle';
      }
    } else if (commandArg.command === 'seekTo' && session && typeof commandArg.offsetMs === 'number') {
      session.viewOffset = Math.max(0, Math.round(commandArg.offsetMs));
      session.mediaPositionUpdatedAt = new Date().toISOString();
    } else if (commandArg.command === 'setParameters' && client && typeof commandArg.volume === 'number') {
      client.volumeLevel = Math.max(0, Math.min(1, commandArg.volume > 1 ? commandArg.volume / 100 : commandArg.volume));
    }
  }

  private normalizeSnapshot(snapshotArg: IPlexSnapshot, sourceArg: IPlexSnapshot['source']): IPlexSnapshot {
    const server = {
      ...this.serverFromConfig(),
      ...snapshotArg.server,
    };
    if (!server.machineIdentifier) {
      server.machineIdentifier = this.config.serverIdentifier || this.slug(server.friendlyName || server.name || server.host || 'plex');
    }
    if (!server.friendlyName) {
      server.friendlyName = server.name || this.config.name || server.host || 'Plex Media Server';
    }
    if (server.online === undefined) {
      server.online = snapshotArg.online ?? Boolean(this.hasLiveServer());
    }
    return {
      ...snapshotArg,
      server,
      clients: this.mergeClients((snapshotArg.clients || []).map((clientArg) => this.normalizeClient(clientArg)), snapshotArg.sessions || []),
      sessions: (snapshotArg.sessions || []).map((sessionArg) => this.normalizeSession(sessionArg)),
      libraries: (snapshotArg.libraries || []).map((libraryArg) => this.normalizeLibrary(libraryArg)),
      capturedAt: snapshotArg.capturedAt || new Date().toISOString(),
      source: sourceArg,
      online: snapshotArg.online ?? server.online,
    };
  }

  private manualSnapshot(): IPlexSnapshot {
    const hasManualData = Boolean(this.config.clients?.length || this.config.sessions?.length || this.config.libraries?.length);
    return {
      server: this.serverFromConfig(),
      clients: this.config.clients || [],
      sessions: this.config.sessions || [],
      libraries: this.config.libraries || [],
      capturedAt: new Date().toISOString(),
      source: 'manual',
      online: Boolean(this.config.snapshot?.online ?? this.config.server?.online ?? hasManualData),
    };
  }

  private serverFromConfig(): IPlexServerInfo {
    const url = this.baseUrl();
    return {
      ...this.config.server,
      machineIdentifier: this.config.server?.machineIdentifier || this.config.serverIdentifier,
      friendlyName: this.config.server?.friendlyName || this.config.name || this.config.server?.name || this.config.host || 'Plex Media Server',
      url,
      host: this.config.server?.host || this.config.host,
      port: this.config.server?.port || this.config.port || (this.config.host || this.config.url ? plexDefaultPort : undefined),
      ssl: this.config.server?.ssl ?? this.config.ssl,
    };
  }

  private normalizeServer(valueArg: Record<string, unknown>): IPlexServerInfo {
    return {
      ...valueArg,
      machineIdentifier: this.stringValue(valueArg.machineIdentifier) || this.stringValue(valueArg.machine_identifier),
      friendlyName: this.stringValue(valueArg.friendlyName) || this.stringValue(valueArg.name) || this.stringValue(valueArg.title),
      version: this.stringValue(valueArg.version),
      platform: this.stringValue(valueArg.platform),
      platformVersion: this.stringValue(valueArg.platformVersion),
      claimed: this.booleanValue(valueArg.claimed),
      myPlex: this.booleanValue(valueArg.myPlex),
      myPlexUsername: this.stringValue(valueArg.myPlexUsername),
      allowSync: this.booleanValue(valueArg.allowSync),
      allowSharing: this.booleanValue(valueArg.allowSharing),
      allowMediaDeletion: this.booleanValue(valueArg.allowMediaDeletion),
      transcoderActiveVideoSessions: this.numberValue(valueArg.transcoderActiveVideoSessions),
      updatedAt: this.numberValue(valueArg.updatedAt) ?? this.stringValue(valueArg.updatedAt),
    };
  }

  private normalizeClient(clientArg: IPlexClientInfo): IPlexClientInfo {
    const capabilities = Array.isArray(clientArg.protocolCapabilities)
      ? clientArg.protocolCapabilities
      : typeof clientArg.protocolCapabilities === 'string'
        ? String(clientArg.protocolCapabilities).split(',').map((itemArg) => itemArg.trim()).filter(Boolean)
        : [];
    const port = this.numberValue(clientArg.port);
    const host = this.stringValue(clientArg.host) || this.stringValue(clientArg.address);
    return {
      ...clientArg,
      machineIdentifier: this.clientIdentifier(clientArg),
      host,
      address: this.stringValue(clientArg.address) || host,
      port,
      baseUrl: clientArg.baseUrl || (host && port ? `http://${host}:${port}` : undefined),
      name: clientArg.name || clientArg.title,
      title: clientArg.title || clientArg.name,
      product: clientArg.product,
      platform: clientArg.platform,
      protocolCapabilities: capabilities,
      state: clientArg.state || 'idle',
      volumeLevel: typeof clientArg.volumeLevel === 'number' ? clientArg.volumeLevel : undefined,
      muted: typeof clientArg.muted === 'boolean' ? clientArg.muted : undefined,
    };
  }

  private normalizeSession(sessionArg: IPlexSession): IPlexSession {
    const player = this.normalizeClient({ ...(sessionArg.Player || sessionArg.players?.[0] || {}), source: 'session' });
    const state = sessionArg.state || player.state || 'idle';
    return {
      ...sessionArg,
      state,
      Player: player,
      username: sessionArg.username || sessionArg.User?.title || sessionArg.User?.username,
      viewOffset: this.numberValue(sessionArg.viewOffset),
      duration: this.numberValue(sessionArg.duration),
      mediaPositionUpdatedAt: sessionArg.mediaPositionUpdatedAt || new Date().toISOString(),
    };
  }

  private normalizeLibrary(libraryArg: IPlexLibrarySection): IPlexLibrarySection {
    const key = libraryArg.key ?? libraryArg.uuid ?? libraryArg.title ?? 'library';
    return {
      ...libraryArg,
      key,
      title: libraryArg.title || String(key),
      type: libraryArg.type,
      totalSize: this.numberValue(libraryArg.totalSize),
      itemCount: this.numberValue(libraryArg.itemCount),
      leafCount: this.numberValue(libraryArg.leafCount),
      refreshing: this.booleanValue(libraryArg.refreshing),
    };
  }

  private mergeClients(clientsArg: IPlexClientInfo[], sessionsArg: IPlexSession[]): IPlexClientInfo[] {
    const clientsById = new Map<string, IPlexClientInfo>();
    for (const client of clientsArg) {
      const normalized = this.normalizeClient(client);
      const id = this.clientIdentifier(normalized);
      if (id) {
        clientsById.set(id, normalized);
      }
    }
    for (const session of sessionsArg) {
      const player = this.normalizeClient({ ...(session.Player || session.players?.[0] || {}), state: session.state || session.Player?.state || 'idle', source: 'session' });
      const id = this.clientIdentifier(player);
      if (!id) {
        continue;
      }
      clientsById.set(id, {
        ...clientsById.get(id),
        ...player,
        state: session.state || player.state,
        source: clientsById.get(id)?.source || player.source,
      });
    }
    return [...clientsById.values()];
  }

  private async requestJson<T>(pathArg: string, optionsArg: { method: string; headers?: Record<string, string>; searchParams?: Record<string, string | number | boolean | undefined>; allowWithoutToken?: boolean }): Promise<T> {
    const text = await this.requestText(pathArg, optionsArg);
    return text ? JSON.parse(text) as T : {} as T;
  }

  private async requestText(pathArg: string, optionsArg: { method: string; headers?: Record<string, string>; searchParams?: Record<string, string | number | boolean | undefined>; allowWithoutToken?: boolean }): Promise<string> {
    const url = this.requestUrl(pathArg, optionsArg.searchParams);
    const controller = new AbortController();
    const timeout = globalThis.setTimeout(() => controller.abort(), this.config.timeoutMs || defaultTimeoutMs);
    try {
      const response = await globalThis.fetch(url, {
        method: optionsArg.method,
        headers: this.headers(optionsArg.headers, optionsArg.allowWithoutToken),
        signal: controller.signal,
      });
      const text = await response.text();
      if (!response.ok) {
        throw new Error(`Plex request ${optionsArg.method} ${pathArg} failed with HTTP ${response.status}: ${text}`);
      }
      return text;
    } finally {
      globalThis.clearTimeout(timeout);
    }
  }

  private requestUrl(pathArg: string, searchParamsArg?: Record<string, string | number | boolean | undefined>): string {
    const base = this.baseUrl();
    if (!base) {
      throw new Error('Plex host or url is required for live HTTP requests.');
    }
    const url = new URL(pathArg.startsWith('/') ? pathArg : `/${pathArg}`, `${base}/`);
    for (const [key, value] of Object.entries(searchParamsArg || {})) {
      if (value !== undefined) {
        url.searchParams.set(key, String(value));
      }
    }
    return url.toString();
  }

  private headers(headersArg: Record<string, string> | undefined, allowWithoutTokenArg?: boolean): Record<string, string> {
    const headers: Record<string, string> = {
      accept: 'application/json',
      'X-Plex-Client-Identifier': this.config.clientIdentifier || defaultClientIdentifier,
      'X-Plex-Product': plexProduct,
      'X-Plex-Version': '0.1.0',
      'X-Plex-Platform': plexPlatform,
      'X-Plex-Device-Name': plexDeviceName,
      ...headersArg,
    };
    if (this.config.token && !allowWithoutTokenArg) {
      headers['X-Plex-Token'] = this.config.token;
    }
    return headers;
  }

  private baseUrl(): string | undefined {
    if (this.config.url) {
      return this.config.url.replace(/\/+$/, '');
    }
    if (!this.config.host) {
      return undefined;
    }
    const protocol = this.config.ssl ? 'https' : 'http';
    return `${protocol}://${this.config.host}:${this.config.port || plexDefaultPort}`;
  }

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

  private emit(eventArg: IPlexEvent): void {
    for (const handler of this.eventHandlers) {
      handler(eventArg);
    }
  }

  private clientIdentifier(clientArg: IPlexClientInfo | undefined): string | undefined {
    return clientArg?.machineIdentifier || clientArg?.id || (clientArg?.title ? this.slug(clientArg.title) : undefined);
  }

  private arrayValue<T>(valueArg: unknown): T[] | undefined {
    if (Array.isArray(valueArg)) {
      return valueArg as T[];
    }
    if (valueArg && typeof valueArg === 'object') {
      return [valueArg as T];
    }
    return undefined;
  }

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

  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 booleanValue(valueArg: unknown): boolean | undefined {
    if (typeof valueArg === 'boolean') {
      return valueArg;
    }
    if (typeof valueArg === 'number') {
      return valueArg !== 0;
    }
    if (typeof valueArg === 'string') {
      if (['1', 'true', 'yes'].includes(valueArg.toLowerCase())) {
        return true;
      }
      if (['0', 'false', 'no'].includes(valueArg.toLowerCase())) {
        return false;
      }
    }
    return undefined;
  }

  private slug(valueArg: string): string {
    return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'plex';
  }

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

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

export const plexMediaTypeForSession = (sessionArg: IPlexSession | undefined): 'video' | 'music' | 'photo' => {
  const type = (sessionArg?.type || '').toLowerCase() as TPlexMediaContentType;
  if (type === 'track' || type === 'album' || type === 'artist' || type === 'music') {
    return 'music';
  }
  if (type === 'photo') {
    return 'photo';
  }
  return 'video';
};
