import * as plugins from '../../plugins.js';
import { SimpleLocalClient, type ISimpleLocalEntitySnapshot, type ISimpleLocalSnapshot, type IServiceCallRequest, type IServiceCallResult } from '../../core/index.js';
import { MotionmountMapper } from './motionmount.mapper.js';
import type { IMotionmountConfig } from './motionmount.types.js';
import { motionmountDefaultPort, motionmountDefaultTimeoutMs, motionmountProfile } from './motionmount.types.js';

export class MotionmountClientError extends Error {
  constructor(messageArg: string) {
    super(messageArg);
    this.name = 'MotionmountClientError';
  }
}

export class MotionmountConnectionError extends MotionmountClientError {
  constructor(messageArg: string) {
    super(messageArg);
    this.name = 'MotionmountConnectionError';
  }
}

export class MotionmountResponseError extends MotionmountClientError {
  constructor(public readonly responseCode: number) {
    super(`MotionMount returned #${responseCode}${motionmountResponseMessages[responseCode] ? ` ${motionmountResponseMessages[responseCode]}` : ''}.`);
    this.name = 'MotionmountResponseError';
  }
}

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

interface IMotionmountPreset {
  index: number;
  name: string;
  extension: number;
  turn: number;
}

interface IMotionmountRawState {
  device: Record<string, unknown>;
  extension?: number;
  turn?: number;
  isMoving?: boolean;
  targetExtension?: number;
  targetTurn?: number;
  errorStatus?: number;
  systemStatus: string[];
  authenticationStatus?: number;
  isAuthenticated: boolean;
  presets: IMotionmountPreset[];
  entities: ISimpleLocalEntitySnapshot[];
  fetchedAt: string;
}

interface IPendingMotionmountRequest {
  expectedKey?: string;
  mode: 'read' | 'write';
  optional: boolean;
  timer: NodeJS.Timeout;
  resolve(valueArg: string | undefined): void;
  reject(errorArg: Error): void;
}

interface IParsedLine {
  key: string;
  value: string;
}

export class MotionmountClient extends SimpleLocalClient<IMotionmountConfig> {
  private liveSnapshot?: ISimpleLocalSnapshot;

  constructor(private readonly configArg: IMotionmountConfig) {
    super(motionmountProfile, configArg);
  }

  public override async getSnapshot(forceRefreshArg = false): Promise<ISimpleLocalSnapshot> {
    if (!forceRefreshArg && this.liveSnapshot) {
      return MotionmountMapper.toSnapshot({ config: this.configArg, rawData: this.liveSnapshot, online: this.liveSnapshot.online, source: this.liveSnapshot.source });
    }

    if (!this.hasLiveTcpConfig()) {
      return super.getSnapshot(forceRefreshArg);
    }

    try {
      this.liveSnapshot = await this.fetchSnapshot();
      return MotionmountMapper.toSnapshot({ config: this.configArg, rawData: this.liveSnapshot, online: true, source: 'tcp' });
    } catch (errorArg) {
      this.liveSnapshot = this.motionmountOfflineSnapshot(errorMessage(errorArg));
      return MotionmountMapper.toSnapshot({ config: this.configArg, rawData: this.liveSnapshot, online: false, source: 'runtime', error: this.liveSnapshot.error });
    }
  }

  public override async refresh(): Promise<IServiceCallResult> {
    this.liveSnapshot = undefined;
    if (!this.hasLiveTcpConfig()) {
      return super.refresh();
    }

    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.hasLiveTcpConfig()) {
      return super.execute(requestArg);
    }

    try {
      const command = await this.commandForRequest(requestArg);
      await this.withSession(async (sessionArg) => {
        await this.ensureAuthenticated(sessionArg);
        await sessionArg.writeCommand(command.line);
      });
      this.liveSnapshot = undefined;
      return { success: true, data: { command } };
    } catch (errorArg) {
      return { success: false, error: errorMessage(errorArg) };
    }
  }

  public async fetchSnapshot(): Promise<ISimpleLocalSnapshot> {
    const rawData = await this.getRawData();
    return MotionmountMapper.toSnapshot({
      config: this.configArg,
      rawData: {
        device: rawData.device as ISimpleLocalSnapshot['device'],
        entities: rawData.entities,
        online: true,
        updatedAt: rawData.fetchedAt,
        source: 'tcp',
        rawData,
        capabilities: {
          localRead: true,
          localControl: true,
          liveControlRequiresExecutor: false,
        },
      } satisfies ISimpleLocalSnapshot,
      online: true,
      source: 'tcp',
    });
  }

  public async getRawData(): Promise<IMotionmountRawState> {
    return this.withSession(async (sessionArg) => {
      const endpoint = this.requireEndpoint();
      const mac = formatMac(await sessionArg.readValue('mac', true));
      const name = stringValue(await sessionArg.readValue('configuration/name', true)) || this.configArg.name || motionmountProfile.defaultName || motionmountProfile.displayName;
      const extension = numberValue(await sessionArg.readValue('mount/extension/current', true));
      const turn = numberValue(await sessionArg.readValue('mount/turn/current', true));
      const isMoving = booleanValue(await sessionArg.readValue('mount/isMoving', true));
      const targetExtension = numberValue(await sessionArg.readValue('mount/extension/target', true));
      const targetTurn = numberValue(await sessionArg.readValue('mount/turn/target', true));
      const errorStatus = numberValue(await sessionArg.readValue('mount/errorStatus', true));
      let authenticationStatus = await this.readAuthenticationStatus(sessionArg);

      if (authenticationStatus !== undefined && !isAuthenticated(authenticationStatus) && this.pin() !== undefined) {
        authenticationStatus = await this.authenticate(sessionArg);
      }

      const presets = authenticationStatus === undefined || isAuthenticated(authenticationStatus) ? await this.readPresets(sessionArg) : [];
      const systemStatus = systemStatusFromError(errorStatus);
      const state: Omit<IMotionmountRawState, 'entities'> = {
        device: {
          id: this.configArg.uniqueId || this.configArg.deviceId || mac || `${endpoint.host}:${endpoint.port}`,
          name,
          manufacturer: motionmountProfile.manufacturer,
          model: 'MotionMount SIGNATURE Pro',
          serialNumber: mac,
          host: endpoint.host,
          port: endpoint.port,
          protocol: 'tcp',
          attributes: {
            targetExtension,
            targetTurn,
            authenticationStatus,
            isAuthenticated: authenticationStatus === undefined ? true : isAuthenticated(authenticationStatus),
            errorStatus,
            systemStatus,
          },
        },
        extension,
        turn,
        isMoving,
        targetExtension,
        targetTurn,
        errorStatus,
        systemStatus,
        authenticationStatus,
        isAuthenticated: authenticationStatus === undefined ? true : isAuthenticated(authenticationStatus),
        presets,
        fetchedAt: new Date().toISOString(),
      };
      return { ...state, entities: entitiesForState(state) };
    });
  }

  private async commandForRequest(requestArg: IServiceCallRequest): Promise<{ line: string; kind: string; value?: number | string }> {
    if (requestArg.service === 'set_value') {
      const key = this.numberTargetKey(requestArg);
      const value = numberValue(requestArg.data?.value ?? requestArg.data?.nativeValue ?? requestArg.data?.position);
      if (value === undefined) {
        throw new MotionmountClientError('MotionMount set_value requires numeric data.value.');
      }
      if (key === 'extension') {
        const extension = validateRange(Math.round(value), 0, 100, 'extension');
        return { line: `mount/extension/target = ${extension}`, kind: 'set_extension', value: extension };
      }
      if (key === 'turn') {
        const turn = validateRange(Math.round(value), -100, 100, 'turn') * -1;
        return { line: `mount/turn/target = ${turn}`, kind: 'set_turn', value };
      }
      throw new MotionmountClientError('MotionMount set_value requires a target or data.key containing extension or turn.');
    }

    if (requestArg.service === 'select_option') {
      const option = stringValue(requestArg.data?.option ?? requestArg.data?.value ?? requestArg.data?.preset);
      const index = await this.presetIndexForOption(option, requestArg.data?.index);
      return { line: `mount/preset/index = ${index}`, kind: 'go_to_preset', value: option || index };
    }

    throw new MotionmountClientError(`Unsupported MotionMount service: ${requestArg.service}.`);
  }

  private numberTargetKey(requestArg: IServiceCallRequest): 'extension' | 'turn' | undefined {
    const direct = stringValue(requestArg.data?.key ?? requestArg.data?.entityKey ?? requestArg.data?.target ?? requestArg.data?.command);
    const haystack = slug(`${direct || ''} ${requestArg.target.entityId || ''} ${requestArg.target.deviceId || ''}`);
    if (haystack.includes('extension')) {
      return 'extension';
    }
    if (haystack.includes('turn') || haystack.includes('rotation')) {
      return 'turn';
    }
    return undefined;
  }

  private async presetIndexForOption(optionArg: string | undefined, indexArg: unknown): Promise<number> {
    const directIndex = numberValue(indexArg ?? optionArg);
    if (directIndex !== undefined) {
      return validateRange(Math.round(directIndex), 0, 7, 'preset index');
    }
    if (!optionArg) {
      throw new MotionmountClientError('MotionMount select_option requires data.option or data.index.');
    }

    const normalized = slug(optionArg);
    if (normalized === 'wall' || normalized === '0_wall') {
      return 0;
    }

    const snapshot = await this.getSnapshot();
    const rawData = snapshot.rawData as IMotionmountRawState | undefined;
    const match = rawData?.presets.find((presetArg) => slug(presetArg.name) === normalized || slug(`${presetArg.index} ${presetArg.name}`) === normalized || `${presetArg.index}` === optionArg.trim());
    if (!match) {
      throw new MotionmountClientError(`Unknown MotionMount preset option: ${optionArg}.`);
    }
    return match.index;
  }

  private async withSession<TResult>(handlerArg: (sessionArg: MotionmountProtocolSession) => Promise<TResult>): Promise<TResult> {
    const session = new MotionmountProtocolSession(this.requireEndpoint(), this.configArg.timeoutMs || motionmountDefaultTimeoutMs);
    await session.connect();
    try {
      return await handlerArg(session);
    } finally {
      session.close();
    }
  }

  private async readAuthenticationStatus(sessionArg: MotionmountProtocolSession): Promise<number | undefined> {
    return byteValue(await sessionArg.readValue('configuration/authentication/status', true));
  }

  private async authenticate(sessionArg: MotionmountProtocolSession): Promise<number | undefined> {
    const pin = this.pin();
    if (pin === undefined) {
      throw new MotionmountClientError('MotionMount control requires config.pin when the device reports unauthenticated status.');
    }
    await sessionArg.writeCommand(`configuration/authentication/pin = ${pin}`);
    const status = await this.readAuthenticationStatus(sessionArg);
    if (status !== undefined && !isAuthenticated(status)) {
      throw new MotionmountClientError('MotionMount rejected the configured PIN.');
    }
    return status;
  }

  private async ensureAuthenticated(sessionArg: MotionmountProtocolSession): Promise<void> {
    const status = await this.readAuthenticationStatus(sessionArg);
    if (status === undefined || isAuthenticated(status)) {
      return;
    }
    await this.authenticate(sessionArg);
  }

  private async readPresets(sessionArg: MotionmountProtocolSession): Promise<IMotionmountPreset[]> {
    const presets: IMotionmountPreset[] = [];
    for (let index = 1; index <= 7; index++) {
      const active = booleanValue(await sessionArg.readValue(`mount/preset/${index}/active`, true));
      if (!active) {
        continue;
      }
      const name = stringValue(await sessionArg.readValue(`mount/preset/${index}/name`, true)) || `Preset ${index}`;
      const extension = numberValue(await sessionArg.readValue(`mount/preset/${index}/extension`, true)) ?? 0;
      const turn = numberValue(await sessionArg.readValue(`mount/preset/${index}/turn`, true)) ?? 0;
      presets.push({ index, name, extension, turn });
    }
    return presets;
  }

  private hasLiveTcpConfig(): boolean {
    return Boolean(this.endpoint()) && this.configArg.transport !== 'snapshot';
  }

  private endpoint(): IMotionmountEndpoint | undefined {
    const configuredUrl = stringValue(this.configArg.url) || (typeof this.configArg.host === 'string' && this.configArg.host.includes('://') ? this.configArg.host : undefined);
    if (configuredUrl) {
      try {
        const url = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(configuredUrl) ? configuredUrl : `tcp://${configuredUrl}`);
        return { host: url.hostname, port: this.configArg.port || (url.port ? Number(url.port) : motionmountDefaultPort) };
      } catch {
        return undefined;
      }
    }
    if (!this.configArg.host) {
      return undefined;
    }
    return { host: this.configArg.host, port: this.configArg.port || motionmountDefaultPort };
  }

  private requireEndpoint(): IMotionmountEndpoint {
    const endpoint = this.endpoint();
    if (!endpoint?.host || !endpoint.port) {
      throw new MotionmountConnectionError('MotionMount TCP access requires config.host/config.url and port 23.');
    }
    return endpoint;
  }

  private motionmountOfflineSnapshot(errorArg: string): ISimpleLocalSnapshot {
    return MotionmountMapper.toSnapshot({ config: this.configArg, rawData: this.configArg.rawData, online: false, source: 'runtime', error: errorArg });
  }

  private pin(): number | undefined {
    return numberValue(this.configArg.pin ?? this.configArg.authenticationPin ?? this.configArg.password);
  }
}

class MotionmountProtocolSession {
  private socket?: ReturnType<typeof plugins.net.createConnection>;
  private pending?: IPendingMotionmountRequest;
  private buffer = '';
  private connected = false;

  constructor(private readonly endpoint: IMotionmountEndpoint, private readonly timeoutMs: number) {}

  public async connect(): Promise<void> {
    if (this.connected) {
      return;
    }

    await new Promise<void>((resolve, reject) => {
      const socket = plugins.net.createConnection({ host: this.endpoint.host, port: this.endpoint.port });
      this.socket = socket;
      const timer = setTimeout(() => {
        socket.destroy();
        reject(new MotionmountConnectionError(`MotionMount TCP connection to ${this.endpoint.host}:${this.endpoint.port} timed out after ${this.timeoutMs}ms.`));
      }, this.timeoutMs);

      socket.on('data', (chunkArg) => this.handleData(Buffer.isBuffer(chunkArg) ? chunkArg.toString('utf8') : String(chunkArg)));
      socket.once('connect', () => {
        clearTimeout(timer);
        this.connected = true;
        resolve();
      });
      socket.once('error', (errorArg) => {
        clearTimeout(timer);
        this.rejectPending(new MotionmountConnectionError(errorMessage(errorArg)));
        reject(new MotionmountConnectionError(errorMessage(errorArg)));
      });
      socket.once('close', () => {
        this.connected = false;
        this.rejectPending(new MotionmountConnectionError(`MotionMount TCP connection to ${this.endpoint.host}:${this.endpoint.port} closed.`));
      });
    });
  }

  public async readValue(keyArg: string, optionalArg = false): Promise<string | undefined> {
    return this.request(keyArg, { expectedKey: keyArg, mode: 'read', optional: optionalArg });
  }

  public async writeCommand(lineArg: string): Promise<void> {
    await this.request(lineArg, { mode: 'write', optional: false });
  }

  public close(): void {
    if (this.pending) {
      this.rejectPending(new MotionmountConnectionError('MotionMount TCP session closed before the pending request completed.'));
    }
    this.socket?.removeAllListeners();
    this.socket?.end();
    this.socket?.destroy();
    this.socket = undefined;
    this.connected = false;
  }

  private async request(lineArg: string, optionsArg: { expectedKey?: string; mode: 'read' | 'write'; optional: boolean }): Promise<string | undefined> {
    if (this.pending) {
      throw new MotionmountConnectionError('MotionMount only supports one in-flight TCP request per session.');
    }
    const socket = this.socket;
    if (!socket || !this.connected) {
      throw new MotionmountConnectionError('MotionMount TCP session is not connected.');
    }

    return new Promise<string | undefined>((resolve, reject) => {
      const timer = setTimeout(() => {
        this.rejectPending(new MotionmountConnectionError(`MotionMount TCP request "${lineArg}" timed out after ${this.timeoutMs}ms.`));
      }, this.timeoutMs);
      this.pending = { ...optionsArg, timer, resolve, reject };
      socket.write(lineArg.endsWith('\n') ? lineArg : `${lineArg}\n`);
    });
  }

  private handleData(chunkArg: string): void {
    this.buffer += chunkArg;
    let delimiter = this.buffer.search(/[\r\n]/);
    while (delimiter >= 0) {
      const line = this.buffer.slice(0, delimiter).trim();
      this.buffer = this.buffer.slice(delimiter + 1).replace(/^\n/, '');
      if (line) {
        this.handleLine(line);
      }
      delimiter = this.buffer.search(/[\r\n]/);
    }
  }

  private handleLine(lineArg: string): void {
    const pending = this.pending;
    if (!pending) {
      return;
    }

    if (lineArg.startsWith('#')) {
      const responseCode = Number(lineArg.slice(1));
      if (responseCode === 202 && pending.mode === 'write') {
        this.resolvePending(undefined);
        return;
      }
      if (responseCode === 404 && pending.optional) {
        this.resolvePending(undefined);
        return;
      }
      this.rejectPending(new MotionmountResponseError(responseCode));
      return;
    }

    const parsed = parseLine(lineArg);
    if (!parsed) {
      return;
    }
    if (pending.expectedKey === parsed.key) {
      this.resolvePending(parsed.value);
    }
  }

  private resolvePending(valueArg: string | undefined): void {
    const pending = this.pending;
    if (!pending) {
      return;
    }
    this.pending = undefined;
    clearTimeout(pending.timer);
    pending.resolve(valueArg);
  }

  private rejectPending(errorArg: Error): void {
    const pending = this.pending;
    if (!pending) {
      return;
    }
    this.pending = undefined;
    clearTimeout(pending.timer);
    pending.reject(errorArg);
  }
}

const motionmountResponseMessages: Record<number, string> = {
  202: 'Accepted',
  400: 'Bad Request',
  401: 'Unauthorised',
  403: 'Forbidden',
  404: 'Not Found',
  405: 'Method Not Allowed',
  414: 'URI Too Long',
};

const parseLine = (lineArg: string): IParsedLine | undefined => {
  const index = lineArg.indexOf('=');
  if (index < 0) {
    return undefined;
  }
  return { key: lineArg.slice(0, index).trim(), value: lineArg.slice(index + 1).trim() };
};

const entitiesForState = (stateArg: Omit<IMotionmountRawState, 'entities'>): ISimpleLocalEntitySnapshot[] => {
  const currentPreset = currentPresetOption(stateArg.extension, stateArg.turn, stateArg.presets);
  return [
    {
      id: 'moving',
      name: 'Moving',
      platform: 'binary_sensor',
      state: Boolean(stateArg.isMoving),
      deviceClass: 'moving',
      attributes: { key: 'mount/isMoving' },
    },
    {
      id: 'extension',
      name: 'Extension',
      platform: 'number',
      state: stateArg.extension ?? 0,
      writable: true,
      unit: '%',
      attributes: { key: 'mount/extension/target', min: 0, max: 100, target: stateArg.targetExtension },
    },
    {
      id: 'turn',
      name: 'Turn',
      platform: 'number',
      state: stateArg.turn === undefined ? 0 : stateArg.turn * -1,
      writable: true,
      unit: '%',
      attributes: { key: 'mount/turn/target', min: -100, max: 100, deviceValue: stateArg.turn, target: stateArg.targetTurn === undefined ? undefined : stateArg.targetTurn * -1 },
    },
    {
      id: 'preset',
      name: 'Preset',
      platform: 'select',
      state: currentPreset,
      writable: true,
      attributes: { key: 'mount/preset/index', options: ['0_wall', ...stateArg.presets.map((presetArg) => presetArg.name)], presets: stateArg.presets },
    },
    {
      id: 'error_status',
      name: 'Error Status',
      platform: 'sensor',
      state: stateArg.systemStatus[0] || 'none',
      deviceClass: 'enum',
      attributes: { key: 'mount/errorStatus', errorStatus: stateArg.errorStatus, systemStatus: stateArg.systemStatus },
    },
  ];
};

const currentPresetOption = (extensionArg: number | undefined, turnArg: number | undefined, presetsArg: IMotionmountPreset[]): string | undefined => {
  if (extensionArg === undefined || turnArg === undefined) {
    return undefined;
  }
  if (extensionArg === 0 && turnArg === 0) {
    return '0_wall';
  }
  const preset = presetsArg.find((presetArg) => presetArg.extension === extensionArg && presetArg.turn === turnArg);
  return preset ? `${preset.index}: ${preset.name}` : undefined;
};

const systemStatusFromError = (errorStatusArg: number | undefined): string[] => {
  const status: string[] = [];
  const errorStatus = (errorStatusArg ?? 0) >>> 0;
  const newStyleErrors = (errorStatus >>> 16) & 0x7fff;
  if (newStyleErrors === 0) {
    if (errorStatus & 0x80000000) {
      if (errorStatus & (1 << 10)) {
        status.push('motor');
      } else if (errorStatus & (1 << 4)) {
        status.push('hdmi_cec');
      } else {
        status.push('internal');
      }
    }
    return status;
  }
  if (newStyleErrors & (1 << 10)) {
    status.push('motor');
  }
  if (newStyleErrors & (1 << 11)) {
    status.push('obstruction');
  }
  if (newStyleErrors & (1 << 12)) {
    status.push('tv_width_constraint');
  }
  if (newStyleErrors & (1 << 4)) {
    status.push('hdmi_cec');
  }
  if (newStyleErrors & 0b1111100000) {
    status.push('internal');
  }
  return status;
};

const validateRange = (valueArg: number, minArg: number, maxArg: number, nameArg: string): number => {
  if (valueArg < minArg || valueArg > maxArg) {
    throw new MotionmountClientError(`MotionMount ${nameArg} must be in the range [${minArg}...${maxArg}].`);
  }
  return valueArg;
};

const isAuthenticated = (statusArg: number): boolean => (statusArg & 0x80) === 0x80;

const byteValue = (valueArg: unknown): number | undefined => {
  const text = stringValue(valueArg);
  if (!text) {
    return undefined;
  }
  const hex = text.replace(/^\[/, '').replace(/\]$/, '').replace(/[^a-fA-F0-9]/g, '');
  if (!hex) {
    return undefined;
  }
  return Number.parseInt(hex.slice(0, 2), 16);
};

const formatMac = (valueArg: unknown): string | undefined => {
  const text = stringValue(valueArg);
  if (!text) {
    return undefined;
  }
  const hex = text.replace(/^\[/, '').replace(/\]$/, '').replace(/[^a-fA-F0-9]/g, '').toLowerCase();
  if (hex.length !== 12) {
    return undefined;
  }
  return hex.match(/.{2}/g)?.join(':');
};

const booleanValue = (valueArg: unknown): boolean | undefined => {
  if (typeof valueArg === 'boolean') {
    return valueArg;
  }
  const text = stringValue(valueArg)?.toLowerCase();
  if (!text) {
    return undefined;
  }
  if (['1', 'true', 'yes', 'on'].includes(text)) {
    return true;
  }
  if (['0', 'false', 'no', 'off'].includes(text)) {
    return false;
  }
  return undefined;
};

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

const stringValue = (valueArg: unknown): string | undefined => {
  if (typeof valueArg !== 'string') {
    return undefined;
  }
  const trimmed = valueArg.trim();
  if (!trimmed) {
    return undefined;
  }
  return trimmed.replace(/^"|"$/g, '');
};

const slug = (valueArg: unknown): string => String(valueArg || '').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');

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