import * as plugins from '../../plugins.js';
import { SimpleLocalClient, SimpleLocalMapper, type ISimpleLocalEntitySnapshot, type IServiceCallRequest, type IServiceCallResult } from '../../core/index.js';
import type { IPjlinkConfig, IPjlinkSnapshot } from './pjlink.types.js';
import { pjlinkDefaultName, pjlinkDefaultPort, pjlinkProfile } from './pjlink.types.js';

export class PjlinkClient extends SimpleLocalClient<IPjlinkConfig> {
  private liveSnapshot?: IPjlinkSnapshot;

  constructor(private readonly configArg: IPjlinkConfig) {
    super(pjlinkProfile, configArg);
  }

  public override async getSnapshot(forceRefreshArg = false): Promise<IPjlinkSnapshot> {
    if (!forceRefreshArg && this.liveSnapshot) {
      return SimpleLocalMapper.clone(this.liveSnapshot);
    }

    if (this.shouldUseNativeTcp(forceRefreshArg)) {
      try {
        this.liveSnapshot = await this.fetchSnapshot();
      } catch (errorArg) {
        this.liveSnapshot = this.createOfflineSnapshot(errorMessage(errorArg));
      }
      return SimpleLocalMapper.clone(this.liveSnapshot);
    }

    this.liveSnapshot = await super.getSnapshot(forceRefreshArg) as IPjlinkSnapshot;
    return SimpleLocalMapper.clone(this.liveSnapshot);
  }

  public override async refresh(): Promise<IServiceCallResult> {
    this.liveSnapshot = undefined;
    const snapshot = await this.getSnapshot(true);
    const success = snapshot.online && !snapshot.error;
    return { success, error: success ? undefined : snapshot.error, data: snapshot };
  }

  public override async execute(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
    if (this.configArg.client?.execute || this.configArg.commandExecutor || !this.endpoint()) {
      return super.execute(requestArg);
    }

    try {
      const command = this.commandForService(requestArg);
      const connection = await PjlinkConnection.connect(this.requireEndpoint(), this.configArg.timeoutMs || 3000, this.configArg.password, this.configArg.encoding || 'utf8');
      try {
        const response = await connection.command(command.body, command.param);
        this.liveSnapshot = undefined;
        return { success: true, data: { command, response } };
      } finally {
        connection.close();
      }
    } catch (errorArg) {
      return { success: false, error: errorMessage(errorArg) };
    }
  }

  private shouldUseNativeTcp(forceRefreshArg: boolean): boolean {
    if (!this.endpoint() || this.configArg.client || this.configArg.snapshotProvider) {
      return false;
    }
    if (!forceRefreshArg && this.configArg.snapshot) {
      return false;
    }
    if (!forceRefreshArg && (this.configArg.rawData !== undefined || this.configArg.rawText !== undefined || this.configArg.entities?.length)) {
      return false;
    }
    return true;
  }

  private async fetchSnapshot(): Promise<IPjlinkSnapshot> {
    const endpoint = this.requireEndpoint();
    const connection = await PjlinkConnection.connect(endpoint, this.configArg.timeoutMs || 3000, this.configArg.password, this.configArg.encoding || 'utf8');
    try {
      const responses: Record<string, string | undefined> = {};
      const power = await connection.command('POWR', '?');
      responses.POWR = power;
      responses.NAME = await optionalCommand(connection, 'NAME');
      responses.INF1 = await optionalCommand(connection, 'INF1');
      responses.INF2 = await optionalCommand(connection, 'INF2');
      responses.INFO = await optionalCommand(connection, 'INFO');
      responses.INST = await optionalCommand(connection, 'INST');
      responses.AVMT = await optionalCommand(connection, 'AVMT');
      responses.INPT = await optionalCommand(connection, 'INPT');

      const powerState = pjlinkPowerState(power);
      const input = parseInput(responses.INPT);
      const inputs = parseInputs(responses.INST);
      const name = this.configArg.name || responses.NAME || pjlinkDefaultName;
      const entity: ISimpleLocalEntitySnapshot = {
        id: 'media_player',
        uniqueId: `${pjlinkProfile.domain}_${SimpleLocalMapper.slug(this.configArg.uniqueId || this.configArg.deviceId || endpoint.host)}_media_player`,
        name,
        platform: 'media_player',
        state: powerState === 'on' || powerState === 'warm-up' ? 'on' : 'off',
        available: true,
        writable: true,
        attributes: cleanAttributes({
          powerState,
          powerRaw: power,
          isVolumeMuted: pjlinkAudioMuted(responses.AVMT),
          avMuteRaw: responses.AVMT,
          source: input?.label,
          sourceCode: input?.code,
          sourceList: inputs.length ? inputs.map((inputArg) => inputArg.label).sort() : undefined,
          inputCodes: inputs.length ? inputs.map((inputArg) => inputArg.code).sort() : undefined,
          manufacturerInfo: responses.INF1,
          productInfo: responses.INF2,
          otherInfo: responses.INFO,
        }),
      };

      return {
        device: {
          id: this.configArg.uniqueId || this.configArg.deviceId || `${endpoint.host}:${endpoint.port}`,
          name,
          manufacturer: responses.INF1 || 'PJLink',
          model: responses.INF2 || 'PJLink Projector',
          host: endpoint.host,
          port: endpoint.port,
          protocol: 'tcp',
        },
        entities: [entity],
        online: true,
        updatedAt: new Date().toISOString(),
        source: 'tcp',
        rawData: responses,
        capabilities: {
          localRead: true,
          localControl: true,
          pjlink: true,
        },
      };
    } finally {
      connection.close();
    }
  }

  private commandForService(requestArg: IServiceCallRequest): IPjlinkCommand {
    const data = requestArg.data || {};
    if (typeof data.body === 'string' && typeof data.param === 'string') {
      return { body: data.body.trim().toUpperCase(), param: data.param.trim() };
    }
    if (requestArg.service === 'turn_on') {
      return { body: 'POWR', param: '1' };
    }
    if (requestArg.service === 'turn_off') {
      return { body: 'POWR', param: '0' };
    }
    if (requestArg.service === 'volume_mute' || requestArg.service === 'mute_volume') {
      return { body: 'AVMT', param: boolValue(data.mute ?? data.is_volume_muted ?? true) ? '21' : '20' };
    }
    if (requestArg.service === 'select_source') {
      const input = stringValue(data.input) || stringValue(data.sourceCode) || parseInputLabel(stringValue(data.source) || stringValue(data.source_name));
      if (!input || !/^\d{2}$/.test(input)) {
        throw new Error('PJLink select_source requires data.input/sourceCode like "11" or data.source like "RGB 1".');
      }
      return { body: 'INPT', param: input };
    }
    throw new Error(`Unsupported PJLink native service: ${requestArg.domain}.${requestArg.service}`);
  }

  private requireEndpoint(): IPjlinkEndpoint {
    const endpoint = this.endpoint();
    if (!endpoint) {
      throw new Error('PJLink TCP requires config.host or config.url.');
    }
    return endpoint;
  }

  private endpoint(): IPjlinkEndpoint | undefined {
    const rawUrl = stringValue(this.configArg.url) || stringValue(this.configArg.metadata?.url);
    if (rawUrl) {
      const parsed = parseUrl(rawUrl);
      if (parsed) {
        return { host: parsed.hostname, port: this.configArg.port || numberValue(parsed.port) || pjlinkDefaultPort };
      }
    }
    const host = stringValue(this.configArg.host);
    if (!host) {
      return undefined;
    }
    const parsedHost = parseUrl(host);
    if (parsedHost) {
      return { host: parsedHost.hostname, port: this.configArg.port || numberValue(parsedHost.port) || pjlinkDefaultPort };
    }
    const hostPort = splitHostPort(host);
    return { host: hostPort.host, port: this.configArg.port || hostPort.port || pjlinkDefaultPort };
  }

  private createOfflineSnapshot(errorArg: string): IPjlinkSnapshot {
    return SimpleLocalMapper.toSnapshot({ profile: pjlinkProfile, config: this.configArg, online: false, source: 'runtime', error: errorArg }) as IPjlinkSnapshot;
  }
}

interface IPjlinkEndpoint {
  host: string;
  port: number;
}

interface IPjlinkCommand {
  body: string;
  param: string;
}

class PjlinkConnection {
  private buffer = '';
  private lines: string[] = [];
  private waiters: Array<() => void> = [];
  private closed = false;
  private lastError?: Error;
  private authDigest?: string;

  private constructor(private readonly socket: plugins.net.Socket, private readonly timeoutMs: number, private readonly encoding: BufferEncoding) {
    socket.on('data', (chunkArg) => this.pushData(Buffer.isBuffer(chunkArg) ? chunkArg : Buffer.from(chunkArg)));
    socket.on('close', () => {
      this.closed = true;
      this.notify();
    });
    socket.on('end', () => {
      this.closed = true;
      this.notify();
    });
    socket.on('error', (errorArg) => {
      this.lastError = errorArg;
      this.closed = true;
      this.notify();
    });
  }

  public static async connect(endpointArg: IPjlinkEndpoint, timeoutMsArg: number, passwordArg: unknown, encodingArg: BufferEncoding): Promise<PjlinkConnection> {
    const connection = await new Promise<PjlinkConnection>((resolve, reject) => {
      const socket = plugins.net.createConnection({ host: endpointArg.host, port: endpointArg.port });
      let settled = false;
      const finish = (connectionArg?: PjlinkConnection, errorArg?: Error) => {
        if (settled) {
          return;
        }
        settled = true;
        socket.off('connect', onConnect);
        socket.off('error', onError);
        socket.off('timeout', onTimeout);
        if (errorArg) {
          socket.destroy();
          reject(errorArg);
          return;
        }
        resolve(connectionArg!);
      };
      const onConnect = () => finish(new PjlinkConnection(socket, timeoutMsArg, encodingArg));
      const onError = (errorArg: Error) => finish(undefined, errorArg);
      const onTimeout = () => finish(undefined, new Error(`PJLink TCP connection to ${endpointArg.host}:${endpointArg.port} timed out after ${timeoutMsArg}ms.`));
      socket.setTimeout(timeoutMsArg);
      socket.once('connect', onConnect);
      socket.once('error', onError);
      socket.once('timeout', onTimeout);
    });
    await connection.authenticate(stringValue(passwordArg));
    return connection;
  }

  public async command(bodyArg: string, paramArg: string): Promise<string> {
    const body = bodyArg.trim().toUpperCase();
    if (!/^[A-Z0-9]{4}$/.test(body)) {
      throw new Error(`Invalid PJLink command body: ${bodyArg}`);
    }
    if (paramArg.length > 128) {
      throw new Error(`Invalid PJLink command parameter length for ${body}.`);
    }
    const prefix = this.authDigest || '';
    this.authDigest = undefined;
    await this.write(`${prefix}%1${body} ${paramArg}\r`);
    const line = await this.readLine(this.timeoutMs);
    if (!line) {
      throw new Error(`PJLink command ${body} timed out without a response.`);
    }
    if (line.toUpperCase() === 'PJLINK ERRA') {
      throw new Error('PJLink authentication failed.');
    }
    const response = parsePjlinkResponse(line, body);
    const protocolError = pjlinkErrors[response];
    if (protocolError) {
      throw new Error(`PJLink ${body} failed: ${protocolError}`);
    }
    return response;
  }

  public close(): void {
    this.closed = true;
    this.socket.destroy();
    this.notify();
  }

  private async authenticate(passwordArg: string | undefined): Promise<void> {
    const greeting = await this.readLine(this.timeoutMs);
    const match = /^PJLINK\s+([01])(?:\s+([0-9A-Za-z]{8}))?$/i.exec(greeting || '');
    if (!match) {
      throw new Error(`Invalid PJLink greeting: ${greeting || '<empty>'}`);
    }
    if (match[1] === '0') {
      return;
    }
    if (!passwordArg) {
      throw new Error('PJLink projector requires config.password for MD5 challenge authentication.');
    }
    this.authDigest = plugins.crypto.createHash('md5').update(`${match[2]}${passwordArg}`, 'utf8').digest('hex');
  }

  private async write(dataArg: string): Promise<void> {
    if (this.lastError) {
      throw this.lastError;
    }
    await new Promise<void>((resolve, reject) => {
      this.socket.write(dataArg, this.encoding, (errorArg) => errorArg ? reject(errorArg) : resolve());
    });
  }

  private async readLine(timeoutMsArg: number): Promise<string | undefined> {
    if (this.lastError) {
      throw this.lastError;
    }
    if (this.lines.length) {
      return this.lines.shift();
    }
    if (this.closed) {
      return undefined;
    }
    return new Promise<string | undefined>((resolve, reject) => {
      let settled = false;
      const cleanup = () => {
        clearTimeout(timer);
        this.waiters = this.waiters.filter((waiterArg) => waiterArg !== waiter);
      };
      const done = (valueArg?: string, errorArg?: Error) => {
        if (settled) {
          return;
        }
        settled = true;
        cleanup();
        if (errorArg) {
          reject(errorArg);
          return;
        }
        resolve(valueArg);
      };
      const waiter = () => {
        if (this.lastError) {
          done(undefined, this.lastError);
          return;
        }
        if (this.lines.length) {
          done(this.lines.shift());
          return;
        }
        if (this.closed) {
          done(undefined);
        }
      };
      const timer = setTimeout(() => done(undefined), timeoutMsArg);
      this.waiters.push(waiter);
    });
  }

  private pushData(chunkArg: Buffer): void {
    this.buffer += chunkArg.toString(this.encoding);
    let match = /\r\n|\r|\n/.exec(this.buffer);
    while (match?.index !== undefined) {
      const line = this.buffer.slice(0, match.index).trim();
      this.buffer = this.buffer.slice(match.index + match[0].length);
      if (line) {
        this.lines.push(line);
      }
      match = /\r\n|\r|\n/.exec(this.buffer);
    }
    this.notify();
  }

  private notify(): void {
    const waiters = this.waiters.splice(0);
    for (const waiter of waiters) {
      waiter();
    }
  }
}

const optionalCommand = async (connectionArg: PjlinkConnection, bodyArg: string): Promise<string | undefined> => {
  try {
    return await connectionArg.command(bodyArg, '?');
  } catch {
    return undefined;
  }
};

const parsePjlinkResponse = (lineArg: string, expectedBodyArg: string): string => {
  const match = /^%1([A-Z0-9]{4})=(.*)$/i.exec(lineArg);
  if (!match) {
    throw new Error(`Invalid PJLink response: ${lineArg}`);
  }
  if (match[1].toUpperCase() !== expectedBodyArg.toUpperCase()) {
    throw new Error(`Unexpected PJLink response body ${match[1]} for ${expectedBodyArg}.`);
  }
  return match[2];
};

const pjlinkErrors: Record<string, string> = {
  ERR1: 'undefined command',
  ERR2: 'out of parameter',
  ERR3: 'unavailable time',
  ERR4: 'projector failure',
};

const pjlinkPowerState = (valueArg: string): string => ({ '0': 'off', '1': 'on', '2': 'cooling', '3': 'warm-up' }[valueArg] || 'unknown');

const pjlinkAudioMuted = (valueArg: string | undefined): boolean | undefined => {
  if (!valueArg) {
    return undefined;
  }
  return valueArg === '21' || valueArg === '31';
};

const sourceTypes: Record<string, string> = {
  '1': 'RGB',
  '2': 'VIDEO',
  '3': 'DIGITAL',
  '4': 'STORAGE',
  '5': 'NETWORK',
};

const sourceTypeCodes: Record<string, string> = Object.fromEntries(Object.entries(sourceTypes).map(([codeArg, nameArg]) => [nameArg, codeArg]));

const parseInput = (valueArg: string | undefined): { code: string; label: string } | undefined => {
  if (!valueArg || !/^\d{2}$/.test(valueArg)) {
    return undefined;
  }
  return { code: valueArg, label: `${sourceTypes[valueArg[0]] || valueArg[0]} ${Number(valueArg[1])}` };
};

const parseInputs = (valueArg: string | undefined): Array<{ code: string; label: string }> => valueArg
  ? valueArg.split(/\s+/).map((itemArg) => parseInput(itemArg)).filter((itemArg): itemArg is { code: string; label: string } => Boolean(itemArg))
  : [];

const parseInputLabel = (valueArg: string | undefined): string | undefined => {
  if (!valueArg) {
    return undefined;
  }
  const match = /^(RGB|VIDEO|DIGITAL|STORAGE|NETWORK)\s+([1-9])$/i.exec(valueArg.trim());
  return match ? `${sourceTypeCodes[match[1].toUpperCase()]}${match[2]}` : undefined;
};

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

const 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;
};

const boolValue = (valueArg: unknown): boolean => valueArg === true || valueArg === 'true' || valueArg === '1' || valueArg === 1;

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

const parseUrl = (valueArg: string): URL | undefined => {
  if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(valueArg)) {
    return undefined;
  }
  try {
    return new URL(valueArg);
  } catch {
    return undefined;
  }
};

const splitHostPort = (valueArg: string): { host: string; port?: number } => {
  const match = /^([^:]+):(\d+)$/.exec(valueArg);
  return match ? { host: match[1], port: Number(match[2]) } : { host: valueArg };
};

const errorMessage = (errorArg: unknown): string => errorArg instanceof Error ? errorArg.message : String(errorArg);
