import { PermissionsAndroid, Platform } from 'react-native';
import { BleManager, Device, Subscription } from 'react-native-ble-plx';

import { EncodedLabelImage, EncodedLabelRow } from '@/src/lib/api';

const NIMBOT_NAME_MARKERS = ['NIIMBOT', 'NIMBOT', 'M2', 'D11', 'D110', 'B21'];
const SCAN_TIMEOUT_MS = 12_000;
const SCAN_AFTER_FIRST_FOUND_MS = 2_200;

type DiscoveredNimbotDevice = {
  id: string;
  name: string;
  rssi: number | null;
};

type NimbotConnectionInfo = {
  device: DiscoveredNimbotDevice;
  writeServiceId: string | null;
  writeCharacteristicId: string | null;
  writeWithoutResponse: boolean;
  writeWithResponse: boolean;
  notifyServiceId: string | null;
  notifyCharacteristicId: string | null;
};

type NimbotPrintOptions = {
  density?: number;
  labelType?: number;
  copies?: number;
  totalPages?: number;
  packetIntervalMs?: number;
  printheadPixels?: number;
  useIndexedRows?: boolean;
};

type NimbotPacket = {
  command: number;
  data: Uint8Array;
};

type PacketWaiter = {
  ids: number[];
  resolve: (packet: NimbotPacket) => void;
  reject: (error: Error) => void;
  timeoutId: ReturnType<typeof setTimeout>;
};

let manager: BleManager | null = null;
let disconnectSubscription: Subscription | null = null;
let notifySubscription: Subscription | null = null;
let activeConnection: NimbotConnectionInfo | null = null;
let packetBuffer = new Uint8Array();

const packetWaiters: PacketWaiter[] = [];

function getManager(): BleManager {
  if (!manager) manager = new BleManager();
  return manager;
}

function normalizeName(device: Device): string {
  return String(device.localName || device.name || '').trim();
}

function isNimbotDevice(device: Device): boolean {
  const name = normalizeName(device).toUpperCase();
  if (!name) return false;
  return NIMBOT_NAME_MARKERS.some((marker) => name.includes(marker));
}

function toDiscoveredDevice(device: Device): DiscoveredNimbotDevice {
  return {
    id: device.id,
    name: normalizeName(device) || device.id,
    rssi: typeof device.rssi === 'number' ? device.rssi : null,
  };
}

function clamp(value: number, min: number, max: number): number {
  return Math.max(min, Math.min(max, value));
}

function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

function toU16BE(value: number): [number, number] {
  const v = clamp(Math.floor(value), 0, 0xffff);
  return [(v >> 8) & 0xff, v & 0xff];
}

function toU16Parts(value: number): [number, number] {
  return toU16BE(value);
}

function countPixelsForBitmapPacketWithMode(
  buf: Uint8Array,
  printheadPixels: number,
  mode: 'auto' | 'split' | 'total' = 'auto'
): { total: number; parts: [number, number, number] } {
  let total = 0;
  const parts: [number, number, number] = [0, 0, 0];
  const chunkSize = Math.floor(printheadPixels / 8 / 3);

  let split = buf.byteLength <= chunkSize * 3;
  if (mode === 'total') {
    split = false;
  } else if (mode === 'split') {
    split = buf.byteLength <= chunkSize * 3;
  }

  buf.forEach((value, byteN) => {
    const chunkIdx = chunkSize > 0 ? Math.floor(byteN / chunkSize) : 0;
    for (let bitN = 0; bitN < 8; bitN += 1) {
      if ((value & (1 << bitN)) === 0) continue;
      total += 1;
      if (!split) continue;
      if (chunkIdx > 2) continue;
      parts[chunkIdx] = (parts[chunkIdx] + 1) & 0xff;
    }
  });

  if (split) {
    return { total, parts };
  }

  const [hi, lo] = toU16BE(total);
  return { total, parts: [0x00, lo, hi] };
}

function indexPixels(buf: Uint8Array): Uint8Array {
  const result: number[] = [];

  for (let bytePos = 0; bytePos < buf.byteLength; bytePos += 1) {
    const b = buf[bytePos];
    for (let bitPos = 0; bitPos < 8; bitPos += 1) {
      if (b & (1 << (7 - bitPos))) {
        const pixelIndex = bytePos * 8 + bitPos;
        const [hi, lo] = toU16BE(pixelIndex);
        result.push(hi, lo);
      }
    }
  }

  return Uint8Array.from(result);
}

function normalizeRowData(rowData: EncodedLabelRow['rowData']): Uint8Array {
  if (!rowData) return new Uint8Array();
  if (rowData instanceof Uint8Array) return rowData;
  if (Array.isArray(rowData)) return Uint8Array.from(rowData.map((v) => Number(v) & 0xff));

  return Uint8Array.from(
    Object.keys(rowData)
      .sort((a, b) => Number(a) - Number(b))
      .map((key) => Number((rowData as Record<string, number>)[key]) & 0xff)
  );
}

function buildNimbotPacket(command: number, payload: number[]): Uint8Array {
  const cmd = command & 0xff;
  const length = payload.length & 0xff;
  let checksum = cmd ^ length;
  for (const b of payload) checksum ^= b & 0xff;
  return Uint8Array.from([0x55, 0x55, cmd, length, ...payload, checksum & 0xff, 0xaa, 0xaa]);
}

function encodeBase64(bytes: Uint8Array): string {
  const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
  let output = '';

  for (let i = 0; i < bytes.length; i += 3) {
    const b0 = bytes[i] ?? 0;
    const b1 = bytes[i + 1] ?? 0;
    const b2 = bytes[i + 2] ?? 0;
    const chunk = (b0 << 16) | (b1 << 8) | b2;

    output += chars[(chunk >> 18) & 63];
    output += chars[(chunk >> 12) & 63];
    output += i + 1 < bytes.length ? chars[(chunk >> 6) & 63] : '=';
    output += i + 2 < bytes.length ? chars[chunk & 63] : '=';
  }

  return output;
}

function decodeBase64(input: string): Uint8Array {
  const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
  const clean = input.replace(/[^A-Za-z0-9+/=]/g, '');
  const out: number[] = [];

  let i = 0;
  while (i < clean.length) {
    const c0 = chars.indexOf(clean[i] || 'A');
    const c1 = chars.indexOf(clean[i + 1] || 'A');
    const c2 = chars.indexOf(clean[i + 2] || 'A');
    const c3 = chars.indexOf(clean[i + 3] || 'A');

    const n = (Math.max(c0, 0) << 18) | (Math.max(c1, 0) << 12) | ((Math.max(c2, 0) & 63) << 6) | (Math.max(c3, 0) & 63);
    out.push((n >> 16) & 0xff);
    if (clean[i + 2] !== '=') out.push((n >> 8) & 0xff);
    if (clean[i + 3] !== '=') out.push(n & 0xff);
    i += 4;
  }

  return Uint8Array.from(out);
}

function clearPacketWaiters(message: string): void {
  while (packetWaiters.length > 0) {
    const waiter = packetWaiters.shift();
    if (!waiter) continue;
    clearTimeout(waiter.timeoutId);
    waiter.reject(new Error(message));
  }
}

function parseNimbotPackets(chunk: Uint8Array): NimbotPacket[] {
  packetBuffer = Uint8Array.from([...packetBuffer, ...chunk]);
  const packets: NimbotPacket[] = [];
  let cursor = 0;

  while (cursor + 8 <= packetBuffer.length) {
    if (packetBuffer[cursor] !== 0x55 || packetBuffer[cursor + 1] !== 0x55) {
      cursor += 1;
      continue;
    }

    const command = packetBuffer[cursor + 2] & 0xff;
    const len = packetBuffer[cursor + 3] & 0xff;
    const end = cursor + 8 + len;
    if (end > packetBuffer.length) break;

    if (packetBuffer[end - 2] !== 0xaa || packetBuffer[end - 1] !== 0xaa) {
      cursor += 1;
      continue;
    }

    const dataStart = cursor + 4;
    const dataEnd = dataStart + len;
    const data = packetBuffer.slice(dataStart, dataEnd);
    const checksum = packetBuffer[dataEnd] & 0xff;

    let calculated = command ^ len;
    for (let i = 0; i < data.length; i += 1) {
      calculated ^= data[i] & 0xff;
    }

    if ((calculated & 0xff) === checksum) {
      packets.push({ command, data });
      cursor = end;
      continue;
    }

    cursor += 1;
  }

  packetBuffer = packetBuffer.slice(cursor);
  return packets;
}

function onRawNotification(base64Value: string): void {
  if (!base64Value) return;
  const packets = parseNimbotPackets(decodeBase64(base64Value));
  if (packets.length === 0) return;

  for (const packet of packets) {
    if (packet.command === 0xdb || packet.command === 0x00) {
      clearPacketWaiters(`Drucker meldet Fehler (0x${packet.command.toString(16).padStart(2, '0')}).`);
      continue;
    }

    const waiterIndex = packetWaiters.findIndex((waiter) => waiter.ids.length === 0 || waiter.ids.includes(packet.command));
    if (waiterIndex < 0) continue;

    const waiter = packetWaiters.splice(waiterIndex, 1)[0];
    clearTimeout(waiter.timeoutId);
    waiter.resolve(packet);
  }
}

function waitForPacket(ids: number[] = [], timeoutMs = 1800): Promise<NimbotPacket> {
  return new Promise<NimbotPacket>((resolve, reject) => {
    const timeoutId = setTimeout(() => {
      const idx = packetWaiters.findIndex((waiter) => waiter.timeoutId === timeoutId);
      if (idx >= 0) packetWaiters.splice(idx, 1);
      reject(new Error(`Timeout auf Druckerantwort (${ids.map((id) => `0x${id.toString(16)}`).join(', ') || 'any'})`));
    }, timeoutMs);

    packetWaiters.push({ ids, resolve, reject, timeoutId });
  });
}

async function findCharacteristics(device: Device): Promise<NimbotConnectionInfo> {
  const services = await device.services();
  let writeServiceId: string | null = null;
  let writeCharacteristicId: string | null = null;
  let writeWithoutResponse = false;
  let writeWithResponse = false;
  let notifyServiceId: string | null = null;
  let notifyCharacteristicId: string | null = null;

  for (const service of services) {
    if (!service.uuid || service.uuid.length < 5) continue;
    const characteristics = await service.characteristics();

    for (const characteristic of characteristics) {
      if (characteristic.isNotifiable && characteristic.isWritableWithoutResponse) {
        return {
          device: toDiscoveredDevice(device),
          writeServiceId: service.uuid,
          writeCharacteristicId: characteristic.uuid,
          writeWithoutResponse: true,
          writeWithResponse: Boolean(characteristic.isWritableWithResponse),
          notifyServiceId: service.uuid,
          notifyCharacteristicId: characteristic.uuid,
        };
      }
    }

    for (const characteristic of characteristics) {
      if (!writeCharacteristicId && (characteristic.isWritableWithResponse || characteristic.isWritableWithoutResponse)) {
        writeServiceId = service.uuid;
        writeCharacteristicId = characteristic.uuid;
        writeWithoutResponse = Boolean(characteristic.isWritableWithoutResponse);
        writeWithResponse = Boolean(characteristic.isWritableWithResponse);
      }
      if (!notifyCharacteristicId && characteristic.isNotifiable) {
        notifyServiceId = service.uuid;
        notifyCharacteristicId = characteristic.uuid;
      }
      if (writeCharacteristicId && notifyCharacteristicId) {
        return {
          device: toDiscoveredDevice(device),
          writeServiceId,
          writeCharacteristicId,
          writeWithoutResponse,
          writeWithResponse,
          notifyServiceId,
          notifyCharacteristicId,
        };
      }
    }
  }

  return {
    device: toDiscoveredDevice(device),
    writeServiceId,
    writeCharacteristicId,
    writeWithoutResponse,
    writeWithResponse,
    notifyServiceId,
    notifyCharacteristicId,
  };
}

export async function requestBluetoothPermissions(): Promise<boolean> {
  if (Platform.OS !== 'android') return true;

  if (Platform.Version >= 31) {
    const result = await PermissionsAndroid.requestMultiple([
      PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN,
      PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT,
      PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION,
    ]);

    return (
      result[PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN] === PermissionsAndroid.RESULTS.GRANTED &&
      result[PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT] === PermissionsAndroid.RESULTS.GRANTED &&
      result[PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION] === PermissionsAndroid.RESULTS.GRANTED
    );
  }

  const location = await PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION);
  return location === PermissionsAndroid.RESULTS.GRANTED;
}

export async function scanNimbotDevices(): Promise<DiscoveredNimbotDevice[]> {
  const hasPermission = await requestBluetoothPermissions();
  if (!hasPermission) throw new Error('Bluetooth-Berechtigungen fehlen.');

  const ble = getManager();
  const found = new Map<string, DiscoveredNimbotDevice>();
  await ble.stopDeviceScan();

  await new Promise<void>((resolve, reject) => {
    let timeoutId: ReturnType<typeof setTimeout> | null = setTimeout(() => {
      ble.stopDeviceScan();
      resolve();
    }, SCAN_TIMEOUT_MS);

    const finalizeSoon = () => {
      if (timeoutId) clearTimeout(timeoutId);
      timeoutId = setTimeout(() => {
        ble.stopDeviceScan();
        resolve();
      }, SCAN_AFTER_FIRST_FOUND_MS);
    };

    ble.startDeviceScan(null, null, (error, device) => {
      if (error) {
        if (timeoutId) clearTimeout(timeoutId);
        ble.stopDeviceScan();
        reject(error);
        return;
      }

      if (!device || !isNimbotDevice(device)) return;
      found.set(device.id, toDiscoveredDevice(device));
      finalizeSoon();
    });
  });

  return Array.from(found.values()).sort((a, b) => {
    const rssiA = typeof a.rssi === 'number' ? a.rssi : -200;
    const rssiB = typeof b.rssi === 'number' ? b.rssi : -200;
    return rssiB - rssiA;
  });
}

export async function connectNimbotDevice(deviceId: string): Promise<NimbotConnectionInfo> {
  const ble = getManager();
  await ble.stopDeviceScan();

  if (activeConnection?.device.id && activeConnection.device.id !== deviceId) {
    try {
      await ble.cancelDeviceConnection(activeConnection.device.id);
    } catch {}
  }

  const connected = await ble.connectToDevice(deviceId, { autoConnect: false });
  await connected.discoverAllServicesAndCharacteristics();

  const metadata = await findCharacteristics(connected);
  activeConnection = metadata;

  if (metadata.notifyServiceId && metadata.notifyCharacteristicId) {
    notifySubscription?.remove();
    notifySubscription = ble.monitorCharacteristicForDevice(
      connected.id,
      metadata.notifyServiceId,
      metadata.notifyCharacteristicId,
      (error, characteristic) => {
        if (error) return;
        if (!characteristic?.value) return;
        onRawNotification(characteristic.value);
      }
    );
  }

  disconnectSubscription?.remove();
  disconnectSubscription = ble.onDeviceDisconnected(connected.id, () => {
    clearPacketWaiters('Bluetooth-Verbindung getrennt.');
    notifySubscription?.remove();
    notifySubscription = null;
    packetBuffer = new Uint8Array();
    activeConnection = null;
  });

  return activeConnection;
}

export function getActiveNimbotConnection(): NimbotConnectionInfo | null {
  return activeConnection;
}

export async function disconnectNimbotDevice(): Promise<void> {
  if (!activeConnection?.device.id) return;
  const ble = getManager();

  try {
    await ble.cancelDeviceConnection(activeConnection.device.id);
  } finally {
    clearPacketWaiters('Verbindung beendet.');
    notifySubscription?.remove();
    notifySubscription = null;
    packetBuffer = new Uint8Array();
    activeConnection = null;
    disconnectSubscription?.remove();
    disconnectSubscription = null;
  }
}

export async function sendNimbotRawCommand(payloadBase64: string): Promise<void> {
  if (!activeConnection) throw new Error('Kein Nimbot verbunden.');

  const ble = getManager();
  const writeServiceId = activeConnection.writeServiceId;
  const writeCharId = activeConnection.writeCharacteristicId;
  if (!writeServiceId || !writeCharId) throw new Error('Kein schreibbares Characteristic gefunden.');

  if (activeConnection.writeWithoutResponse) {
    await ble.writeCharacteristicWithoutResponseForDevice(activeConnection.device.id, writeServiceId, writeCharId, payloadBase64);
    return;
  }

  if (activeConnection.writeWithResponse) {
    await ble.writeCharacteristicWithResponseForDevice(activeConnection.device.id, writeServiceId, writeCharId, payloadBase64);
    return;
  }

  throw new Error('Characteristic ist nicht schreibbar.');
}

async function sendNimbotPacket(
  command: number,
  payload: number[],
  options: { expectedResponseIds?: number[]; timeoutMs?: number; connectPrefix03?: boolean; allowResponseTimeout?: boolean } = {}
): Promise<void> {
  const packet = buildNimbotPacket(command, payload);
  const payloadBytes = options.connectPrefix03 ? Uint8Array.from([0x03, ...Array.from(packet)]) : packet;
  const waiter = options.expectedResponseIds ? waitForPacket(options.expectedResponseIds, options.timeoutMs ?? 1800) : null;

  await sendNimbotRawCommand(encodeBase64(payloadBytes));

  if (waiter) {
    try {
      await waiter;
    } catch (error) {
      if (!options.allowResponseTimeout) {
        throw error;
      }
    }
  }
}

async function sendNimbotConnectHandshake(): Promise<void> {
  await sendNimbotPacket(0xc1, [0x01], {
    expectedResponseIds: [0xc2],
    timeoutMs: 2500,
    connectPrefix03: true,
    allowResponseTimeout: true,
  });
}

function buildImagePackets(
  encoded: EncodedLabelImage,
  options: { printheadPixels?: number; useIndexedRows?: boolean } = {}
): { cmd: number; payload: number[] }[] {
  const packets: { cmd: number; payload: number[] }[] = [];
  const printheadPixels = clamp(Math.floor(options.printheadPixels ?? 567), 32, 2048);
  const useIndexedRows = Boolean(options.useIndexedRows);

  for (const row of encoded.rowsData || []) {
    if (row.dataType === 'void') {
      const [rowHi, rowLo] = toU16Parts(row.rowNumber);
      const repeat = clamp(row.repeat || 1, 1, 255);
      packets.push({ cmd: 0x84, payload: [rowHi, rowLo, repeat] });
      continue;
    }

    if (row.dataType !== 'pixels') continue;

    const rowData = normalizeRowData(row.rowData);
    const [rowHi, rowLo] = toU16Parts(row.rowNumber);
    const repeat = clamp(row.repeat || 1, 1, 255);
    const counts = countPixelsForBitmapPacketWithMode(rowData, printheadPixels, 'auto');

    if (useIndexedRows && (row.blackPixelsCount || 0) <= 6) {
      const indexes = Array.from(indexPixels(rowData));
      packets.push({
        cmd: 0x83,
        payload: [rowHi, rowLo, ...counts.parts, repeat, ...indexes],
      });
      continue;
    }

    packets.push({
      cmd: 0x85,
      payload: [rowHi, rowLo, ...counts.parts, repeat, ...Array.from(rowData)],
    });
  }

  return packets;
}

export async function printNimbotEncodedLabel(encoded: EncodedLabelImage, options: NimbotPrintOptions = {}): Promise<void> {
  if (!activeConnection) throw new Error('Kein Nimbot verbunden.');

  const density = clamp(Math.floor(options.density ?? 5), 1, 5);
  const labelType = clamp(Math.floor(options.labelType ?? 1), 1, 11);
  const copies = clamp(Math.floor(options.copies ?? 1), 1, 20);
  const totalPages = clamp(Math.floor(options.totalPages ?? 1), 1, 20);
  const packetIntervalMs = clamp(Math.floor(options.packetIntervalMs ?? 10), 2, 80);
  const printheadPixels = clamp(Math.floor(options.printheadPixels ?? 567), 32, 2048);
  const useIndexedRows = Boolean(options.useIndexedRows ?? false);

  if (!encoded?.rows || !encoded?.cols || !Array.isArray(encoded.rowsData)) {
    throw new Error('Ungültiges Label-Format.');
  }

  const rows = Number(encoded.rows);
  const cols = Number(encoded.cols);
  const [rowsHi, rowsLo] = toU16Parts(rows);
  const [colsHi, colsLo] = toU16Parts(cols);
  const [copiesHi, copiesLo] = toU16Parts(copies);

  await sendNimbotConnectHandshake();
  await sleep(30);

  await sendNimbotPacket(0x21, [density], { expectedResponseIds: [0x31], allowResponseTimeout: true });
  await sleep(packetIntervalMs);
  await sendNimbotPacket(0x23, [labelType], { expectedResponseIds: [0x33], allowResponseTimeout: true });
  await sleep(packetIntervalMs);
  await sendNimbotPacket(0x01, [0x00, totalPages, 0x00, 0x00, 0x00, 0x00, 0x00], {
    expectedResponseIds: [0x02],
    allowResponseTimeout: true,
  });
  await sleep(packetIntervalMs);
  await sendNimbotPacket(0x03, [0x01], { expectedResponseIds: [0x04], allowResponseTimeout: true });
  await sleep(packetIntervalMs);
  await sendNimbotPacket(0x13, [rowsHi, rowsLo, colsHi, colsLo, copiesHi, copiesLo], {
    expectedResponseIds: [0x14],
    allowResponseTimeout: true,
  });
  await sleep(packetIntervalMs);

  const imagePackets = buildImagePackets(encoded, { printheadPixels, useIndexedRows });
  for (let i = 0; i < imagePackets.length; i += 1) {
    const p = imagePackets[i];
    await sendNimbotPacket(p.cmd, p.payload);
    await sleep(packetIntervalMs);
  }

  await sendNimbotPacket(0xe3, [0x01], { expectedResponseIds: [0xe4], allowResponseTimeout: true });
  await sleep(25);
  await sendNimbotPacket(0xf3, [0x01], { expectedResponseIds: [0xf4], timeoutMs: 3000, allowResponseTimeout: true });
}
