#!/usr/bin/env node
'use strict';

const http = require('node:http');
const { URL, URLSearchParams } = require('node:url');

let WebSocketServer;
try {
  ({ WebSocketServer } = require('ws'));
} catch (error) {
  console.error('[wecker-remote] Bitte zuerst "npm install ws" in webso/njs ausfuehren.');
  console.error(error.message);
  process.exit(1);
}

const PORT = Number(process.env.WECKER_WS_PORT || 8080);
const HOST = process.env.WECKER_WS_HOST || '0.0.0.0';
const PUBLIC_HOST = process.env.WECKER_WS_PUBLIC_HOST || 'njs.piclock.cylone-network.com';
const API_URL = process.env.WECKER_API_URL || 'https://piclock.cylone-network.com/api_wecker.php';
const DEFAULT_API_ROOT = process.env.WECKER_DEFAULT_API_ROOT || deriveApiRootFromLegacyApiUrl(API_URL);
const DEFAULT_UI_URL = process.env.WECKER_DEFAULT_UI_URL || new URL('index.php', DEFAULT_API_ROOT).toString();
const POLL_INTERVAL_MS = Number(process.env.WECKER_POLL_INTERVAL_MS || 10000);
const DEVICE_TTL_MS = Number(process.env.WECKER_DEVICE_TTL_MS || 45000);

const httpServer = http.createServer((req, res) => {
  if (req.url === '/health') {
    res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
    res.end(JSON.stringify({ ok: true, service: 'wecker-remote-ws' }));
    return;
  }

  res.writeHead(404, { 'content-type': 'application/json; charset=utf-8' });
  res.end(JSON.stringify({ ok: false, error: 'Not found' }));
});

const wss = new WebSocketServer({ server: httpServer, path: '/ws' });
const clients = new Set();
const devices = new Map();
const pendingDeviceRpc = new Map();
const deviceStateCache = new Map();
let deviceRpcCounter = 0;

function deriveApiRootFromLegacyApiUrl(apiUrl) {
  try {
    const u = new URL(apiUrl);
    const path = u.pathname || '/';
    const idx = path.lastIndexOf('/');
    u.pathname = idx >= 0 ? path.slice(0, idx + 1) : '/';
    u.search = '';
    u.hash = '';
    return u.toString();
  } catch {
    return 'http://127.0.0.1/wecker/html/';
  }
}

function send(client, payload) {
  if (client.readyState !== client.OPEN) {
    return;
  }
  client.send(JSON.stringify(payload));
}

function broadcast(payload) {
  for (const client of clients) {
    send(client, payload);
  }
}

function nextRpcId(deviceId) {
  deviceRpcCounter += 1;
  return `rpc_${sanitizeDeviceId(deviceId) || 'device'}_${Date.now()}_${deviceRpcCounter}`;
}

function sanitizeDeviceId(input) {
  const str = String(input || '').trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-');
  return str.slice(0, 64);
}

function normalizeApiRoot(input) {
  try {
    const u = new URL(String(input || ''));
    u.search = '';
    u.hash = '';
    if (!u.pathname.endsWith('/')) {
      u.pathname += '/';
    }
    return u.toString();
  } catch {
    return DEFAULT_API_ROOT;
  }
}

function normalizeUiUrl(input, fallback) {
  try {
    const u = new URL(String(input || ''));
    u.hash = '';
    return u.toString();
  } catch {
    return fallback;
  }
}

function getDefaultDeviceSummary() {
  return {
    id: 'default',
    name: 'Wecker (Default)',
    apiRoot: DEFAULT_API_ROOT,
    uiUrl: DEFAULT_UI_URL,
    online: true,
    lastSeen: Date.now(),
    source: 'default',
    meta: {},
  };
}

function summarizeDevice(device) {
  const lastSeen = Number(device.lastSeen || 0);
  const alive = !!device.socket && device.socket.readyState === device.socket.OPEN;
  const online = alive && (Date.now() - lastSeen <= DEVICE_TTL_MS);
  return {
    id: device.id,
    name: device.name,
    apiRoot: device.apiRoot,
    uiUrl: device.uiUrl,
    online,
    lastSeen,
    source: 'registered',
    meta: device.meta || {},
  };
}

function getAvailableDevices() {
  const list = Array.from(devices.values()).map(summarizeDevice);
  if (list.length === 0) {
    list.push(getDefaultDeviceSummary());
  }
  list.sort((a, b) => String(a.name).localeCompare(String(b.name), 'de'));
  return list;
}

function broadcastDevices() {
  broadcast({ type: 'devices', payload: { devices: getAvailableDevices() } });
}

function setDeviceStateCache(deviceId, data) {
  if (!deviceId) return;
  deviceStateCache.set(deviceId, {
    updatedAt: Date.now(),
    data: data || {},
  });
}

function getDeviceStateCache(deviceId) {
  if (!deviceId || !deviceStateCache.has(deviceId)) return null;
  return deviceStateCache.get(deviceId)?.data || null;
}

function registerDevice(socket, data = {}) {
  const rawId = data.deviceId || data.id || `${socket._remote || 'wecker'}`;
  const id = sanitizeDeviceId(rawId) || `wecker-${Date.now()}`;
  const existing = devices.get(id);
  if (existing && existing.socket && existing.socket !== socket) {
    try {
      existing.socket.close(1000, 'Neue Registrierung');
    } catch {}
  }
  const apiRoot = normalizeApiRoot(data.apiRoot || data.apiBaseUrl || data.api_url);
  const uiUrl = normalizeUiUrl(data.uiUrl || data.ui_url, new URL('index.php', apiRoot).toString());
  const entry = {
    id,
    name: String(data.name || `Wecker ${id}`).trim() || `Wecker ${id}`,
    apiRoot,
    uiUrl,
    lastSeen: Date.now(),
    socket,
    meta: typeof data.meta === 'object' && data.meta ? data.meta : {},
  };
  devices.set(id, entry);
  socket._deviceId = id;
  socket._role = 'device';
  send(socket, { type: 'device_registered', payload: summarizeDevice(entry) });
  broadcastDevices();
}

function touchDevice(socket, data = {}) {
  const id = socket._deviceId;
  if (!id || !devices.has(id)) {
    return;
  }
  const device = devices.get(id);
  device.lastSeen = Date.now();
  if (typeof data.meta === 'object' && data.meta) {
    device.meta = { ...device.meta, ...data.meta };
  }
  if (typeof data.name === 'string' && data.name.trim() !== '') {
    device.name = data.name.trim();
  }
  devices.set(id, device);
}

function resolveDeviceById(requestedId) {
  const id = sanitizeDeviceId(requestedId || '');
  if (id && devices.has(id)) {
    return devices.get(id);
  }
  if (!id && devices.size === 1) {
    return devices.values().next().value;
  }
  if (!id || id === 'default') {
    return getDefaultDeviceSummary();
  }
  return null;
}

function supportsDeviceRpc(device) {
  return !!device && !!device.socket && device.socket.readyState === device.socket.OPEN;
}

function devicePayloadSummary(device) {
  if (device && device.source === 'default') {
    return device;
  }
  return summarizeDevice(device);
}

function buildApiUrl(apiRoot, endpoint, params) {
  const url = new URL(endpoint, apiRoot);
  Object.entries(params).forEach(([key, value]) => {
    url.searchParams.set(key, String(value));
  });
  return url;
}

async function apiGet(device, endpoint, action, extra = {}) {
  const url = buildApiUrl(device.apiRoot, endpoint, { action, ...extra });
  const response = await fetch(url, {
    method: 'GET',
    headers: {
      Accept: 'application/json',
    },
  });

  if (!response.ok) {
    throw new Error(`API GET ${action} fehlgeschlagen (${response.status}) auf ${device.id}`);
  }
  return response.json();
}

async function apiPost(device, endpoint, action, data = {}) {
  const body = new URLSearchParams({ action, ...data });
  const targetUrl = new URL(endpoint, device.apiRoot);
  const response = await fetch(targetUrl, {
    method: 'POST',
    headers: {
      Accept: 'application/json',
      'content-type': 'application/x-www-form-urlencoded; charset=utf-8',
    },
    body,
  });

  if (!response.ok) {
    throw new Error(`API POST ${action} fehlgeschlagen (${response.status}) auf ${device.id}`);
  }
  return response.json();
}

async function deviceRpcCall(device, method, params = {}) {
  if (!supportsDeviceRpc(device)) {
    throw new Error(`Geraet ${device?.id || 'unbekannt'} ist nicht per WS erreichbar`);
  }
  const requestId = nextRpcId(device.id);
  return new Promise((resolve, reject) => {
    const timeout = setTimeout(() => {
      pendingDeviceRpc.delete(requestId);
      reject(new Error(`RPC Timeout (${method}) fuer ${device.id}`));
    }, 10000);
    pendingDeviceRpc.set(requestId, {
      requestId,
      socket: device.socket,
      resolve,
      reject,
      timeout,
      method,
      deviceId: device.id,
    });
    send(device.socket, {
      type: 'device_rpc',
      requestId,
      method,
      params,
    });
  });
}

function clearPendingRpcForSocket(socket, reason) {
  for (const [requestId, pending] of pendingDeviceRpc.entries()) {
    if (pending.socket !== socket) {
      continue;
    }
    clearTimeout(pending.timeout);
    pendingDeviceRpc.delete(requestId);
    pending.reject(new Error(reason || `RPC abgebrochen (${pending.method})`));
  }
}

function handleDeviceRpcResult(client, msg) {
  const requestId = String(msg?.requestId || '');
  if (!requestId || !pendingDeviceRpc.has(requestId)) {
    return;
  }
  const pending = pendingDeviceRpc.get(requestId);
  if (pending.socket !== client) {
    return;
  }
  clearTimeout(pending.timeout);
  pendingDeviceRpc.delete(requestId);
  if (msg.ok === false) {
    pending.reject(new Error(msg.error || `RPC Fehler (${pending.method})`));
    return;
  }
  pending.resolve(msg.data);
}

function asInt(value, fallback = 0) {
  const parsed = Number.parseInt(String(value), 10);
  return Number.isFinite(parsed) ? parsed : fallback;
}

async function pushFullState(client, deviceId) {
  const device = resolveDeviceById(deviceId);
  if (!device) {
    send(client, { type: 'error', message: `Geraet nicht gefunden: ${String(deviceId || '')}` });
    return;
  }
  const cached = getDeviceStateCache(device.id);
  if (cached && cached.list && cached.alarmState) {
    send(client, {
      type: 'device_state',
      payload: {
        device: devicePayloadSummary(device),
        list: cached.list,
        alarmState: cached.alarmState,
        audioState: cached.audioState || null,
      },
    });
    return;
  }
  let list;
  let alarmState;
  let audioState = null;
  if (supportsDeviceRpc(device)) {
    const state = await deviceRpcCall(device, 'get_state', {});
    list = state?.list || {};
    alarmState = state?.alarmState || {};
    audioState = state?.audioState || null;
  } else {
    [list, alarmState] = await Promise.all([
      apiGet(device, 'api_wecker.php', 'list'),
      apiGet(device, 'api_wecker.php', 'alarm_state'),
    ]);
  }
  setDeviceStateCache(device.id, { list, alarmState, audioState });

  send(client, {
    type: 'device_state',
    payload: {
      device: devicePayloadSummary(device),
      list,
      alarmState,
      audioState,
    },
  });
}

async function pushMusicList(client, deviceId) {
  const device = resolveDeviceById(deviceId);
  if (!device) {
    send(client, { type: 'error', message: `Geraet nicht gefunden: ${String(deviceId || '')}` });
    return;
  }
  const music = supportsDeviceRpc(device)
    ? await deviceRpcCall(device, 'music_list', {})
    : await apiGet(device, 'api_musik.php', 'list');
  send(client, { type: 'device_music', payload: { deviceId: device.id, music } });
}

async function broadcastDeviceState(deviceId) {
  const device = resolveDeviceById(deviceId);
  if (!device) {
    return;
  }
  let list;
  let alarmState;
  if (supportsDeviceRpc(device)) {
    const state = await deviceRpcCall(device, 'get_state', {});
    list = state?.list || {};
    alarmState = state?.alarmState || {};
    const audioState = state?.audioState || null;
    setDeviceStateCache(device.id, { list, alarmState, audioState });
    broadcast({
      type: 'device_state',
      payload: {
        device: devicePayloadSummary(device),
        list,
        alarmState,
        audioState,
      },
    });
    return;
  } else {
    [list, alarmState] = await Promise.all([
      apiGet(device, 'api_wecker.php', 'list'),
      apiGet(device, 'api_wecker.php', 'alarm_state'),
    ]);
  }
  setDeviceStateCache(device.id, { list, alarmState, audioState: null });
  broadcast({
    type: 'device_state',
    payload: {
      device: devicePayloadSummary(device),
      list,
      alarmState,
      audioState: null,
    },
  });
}

async function handleMessage(client, rawMessage) {
  let msg;
  try {
    msg = JSON.parse(rawMessage.toString());
  } catch {
    send(client, { type: 'error', message: 'Ungueltiges JSON' });
    return;
  }

  const type = msg?.type;
  try {
    switch (type) {
      case 'device_register': {
        registerDevice(client, msg);
        break;
      }

      case 'device_heartbeat': {
        touchDevice(client, msg);
        send(client, { type: 'device_heartbeat_ack', ts: Date.now() });
        break;
      }

      case 'device_update': {
        touchDevice(client, msg);
        broadcastDevices();
        break;
      }

      case 'device_state_push': {
        touchDevice(client, msg);
        const deviceId = client._deviceId || sanitizeDeviceId(msg.deviceId || '');
        if (!deviceId) {
          break;
        }
        const device = resolveDeviceById(deviceId);
        const prev = getDeviceStateCache(deviceId) || {};
        const hasList = Object.prototype.hasOwnProperty.call(msg?.state || {}, 'list');
        const hasAlarmState = Object.prototype.hasOwnProperty.call(msg?.state || {}, 'alarmState');
        const hasAudioState = Object.prototype.hasOwnProperty.call(msg?.state || {}, 'audioState');
        const list = hasList ? (msg?.state?.list || {}) : (prev.list || {});
        const alarmState = hasAlarmState ? (msg?.state?.alarmState || {}) : (prev.alarmState || {});
        const audioState = hasAudioState ? (msg?.state?.audioState || null) : (prev.audioState || null);
        setDeviceStateCache(deviceId, { list, alarmState, audioState });
        broadcast({
          type: 'device_state',
          payload: {
            device: device ? devicePayloadSummary(device) : { id: deviceId },
            list,
            alarmState,
            audioState,
          },
        });
        break;
      }

      case 'device_rpc_result': {
        handleDeviceRpcResult(client, msg);
        break;
      }

      case 'get_devices': {
        send(client, { type: 'devices', payload: { devices: getAvailableDevices() } });
        break;
      }

      case 'get_state':
      case 'get_device_state': {
        await pushFullState(client, msg.deviceId);
        break;
      }

      case 'get_alarm': {
        const device = resolveDeviceById(msg.deviceId);
        if (!device) {
          throw new Error(`Geraet nicht gefunden: ${String(msg.deviceId || '')}`);
        }
        const id = asInt(msg.id, 0);
        if (id <= 0) {
          throw new Error('get_alarm erwartet gueltige "id"');
        }
        const payload = supportsDeviceRpc(device)
          ? await deviceRpcCall(device, 'get_alarm', { id })
          : await apiGet(device, 'api_wecker.php', 'get', { id });
        send(client, {
          type: 'device_alarm',
          payload: {
            deviceId: device.id,
            alarm: payload?.wecker || payload?.alarm || null,
          },
        });
        break;
      }

      case 'music_list': {
        await pushMusicList(client, msg.deviceId);
        break;
      }

      case 'radio_list': {
        const device = resolveDeviceById(msg.deviceId);
        if (!device) {
          throw new Error(`Geraet nicht gefunden: ${String(msg.deviceId || '')}`);
        }
        const radio = supportsDeviceRpc(device)
          ? await deviceRpcCall(device, 'radio_list', {})
          : await apiGet(device, 'api_radio.php', 'list_de');
        send(client, { type: 'device_radio', payload: { deviceId: device.id, radio } });
        break;
      }

      case 'audio_get_state': {
        const device = resolveDeviceById(msg.deviceId);
        if (!device) {
          throw new Error(`Geraet nicht gefunden: ${String(msg.deviceId || '')}`);
        }
        const audioState = supportsDeviceRpc(device)
          ? await deviceRpcCall(device, 'audio_get_state', {})
          : null;
        send(client, { type: 'device_audio_state', payload: { deviceId: device.id, audioState } });
        break;
      }

      case 'audio_play_pause':
      case 'audio_prev':
      case 'audio_next':
      case 'audio_set_volume':
      case 'audio_play_track':
      case 'audio_play_radio': {
        const device = resolveDeviceById(msg.deviceId);
        if (!device) {
          throw new Error(`Geraet nicht gefunden: ${String(msg.deviceId || '')}`);
        }
        if (!supportsDeviceRpc(device)) {
          throw new Error(`Audio-Steuerung nur fuer verbundene Geraete verfuegbar (${device.id})`);
        }
        const method = String(type);
        const params = Object.assign({}, msg);
        delete params.type;
        delete params.deviceId;
        const result = await deviceRpcCall(device, method, params);
        broadcast({ type: 'action_result', action: method, deviceId: device.id, payload: result });
        await broadcastDeviceState(device.id);
        break;
      }

      case 'toggle': {
        const device = resolveDeviceById(msg.deviceId);
        if (!device) {
          throw new Error(`Geraet nicht gefunden: ${String(msg.deviceId || '')}`);
        }
        const id = asInt(msg.id);
        const aktiv = msg.aktiv ? 1 : 0;
        if (id <= 0) {
          throw new Error('toggle erwartet gueltige "id"');
        }
        const result = supportsDeviceRpc(device)
          ? await deviceRpcCall(device, 'toggle', { id, aktiv })
          : await apiPost(device, 'api_wecker.php', 'toggle', { id, aktiv });
        broadcast({ type: 'action_result', action: 'toggle', deviceId: device.id, payload: result });
        await broadcastDeviceState(device.id);
        break;
      }

      case 'stop_alarm': {
        const device = resolveDeviceById(msg.deviceId);
        if (!device) {
          throw new Error(`Geraet nicht gefunden: ${String(msg.deviceId || '')}`);
        }
        const result = supportsDeviceRpc(device)
          ? await deviceRpcCall(device, 'stop_alarm', {})
          : await apiPost(device, 'api_wecker.php', 'stop_alarm');
        broadcast({ type: 'action_result', action: 'stop_alarm', deviceId: device.id, payload: result });
        await broadcastDeviceState(device.id);
        break;
      }

      case 'snooze': {
        const device = resolveDeviceById(msg.deviceId);
        if (!device) {
          throw new Error(`Geraet nicht gefunden: ${String(msg.deviceId || '')}`);
        }
        const minutes = Math.max(1, Math.min(60, asInt(msg.minutes, 5)));
        const result = supportsDeviceRpc(device)
          ? await deviceRpcCall(device, 'snooze', { minutes })
          : await apiPost(device, 'api_wecker.php', 'snooze', { minutes });
        broadcast({ type: 'action_result', action: 'snooze', deviceId: device.id, payload: result });
        await broadcastDeviceState(device.id);
        break;
      }

      case 'save_alarm': {
        const device = resolveDeviceById(msg.deviceId);
        if (!device) {
          throw new Error(`Geraet nicht gefunden: ${String(msg.deviceId || '')}`);
        }
        const weckzeit = String(msg.weckzeit || '').trim();
        const sprachtext = String(msg.sprachtext || '').trim();
        if (!weckzeit || !sprachtext) {
          throw new Error('save_alarm erwartet "weckzeit" und "sprachtext"');
        }
        const payload = {
          id: asInt(msg.id, 0),
          weckzeit,
          sprachtext,
          aktiv: msg.aktiv ? 1 : 0,
          wiederholung: String(msg.wiederholung || 'taeglich'),
        };
        if (msg.weckdatum) {
          payload.weckdatum = String(msg.weckdatum);
        }
        const result = supportsDeviceRpc(device)
          ? await deviceRpcCall(device, 'save_alarm', payload)
          : await apiPost(device, 'api_wecker.php', 'save', payload);
        broadcast({ type: 'action_result', action: 'save_alarm', deviceId: device.id, payload: result });
        await broadcastDeviceState(device.id);
        break;
      }

      default:
        send(client, { type: 'error', message: `Unbekannter Typ: ${String(type)}` });
    }
  } catch (error) {
    send(client, {
      type: 'error',
      message: error instanceof Error ? error.message : 'Unbekannter Fehler',
    });
  }
}

wss.on('connection', (socket, req) => {
  clients.add(socket);
  socket._remote = req.socket.remoteAddress || 'unknown';
  send(socket, {
    type: 'connected',
    payload: {
      message: 'WebSocket verbunden',
      clientCount: clients.size,
      from: socket._remote,
    },
  });
  send(socket, { type: 'devices', payload: { devices: getAvailableDevices() } });

  socket.on('message', (msg) => {
    handleMessage(socket, msg);
  });

  socket.on('close', () => {
    clearPendingRpcForSocket(socket, 'WS Verbindung geschlossen');
    if (socket._role === 'device' && socket._deviceId) {
      const found = devices.get(socket._deviceId);
      if (found && found.socket === socket) {
        devices.delete(socket._deviceId);
        deviceStateCache.delete(socket._deviceId);
        broadcastDevices();
      }
    }
    clients.delete(socket);
  });

  socket.on('error', () => {
    clearPendingRpcForSocket(socket, 'WS Fehler');
    if (socket._role === 'device' && socket._deviceId) {
      const found = devices.get(socket._deviceId);
      if (found && found.socket === socket) {
        devices.delete(socket._deviceId);
        deviceStateCache.delete(socket._deviceId);
        broadcastDevices();
      }
    }
    clients.delete(socket);
  });
});

httpServer.listen(PORT, HOST, () => {
  console.log(`[wecker-remote] WS intern auf ws://${HOST}:${PORT}/ws`);
  console.log(`[wecker-remote] WS extern auf ws://${PUBLIC_HOST}:${PORT}/ws`);
  console.log(`[wecker-remote] Default API-Root: ${DEFAULT_API_ROOT}`);
});

setInterval(() => {
  if (clients.size === 0 && devices.size === 0) {
    return;
  }
  for (const [id, device] of devices.entries()) {
    if (!device.socket || device.socket.readyState !== device.socket.OPEN || Date.now() - Number(device.lastSeen || 0) > DEVICE_TTL_MS * 3) {
      devices.delete(id);
    }
  }
  broadcastDevices();
}, POLL_INTERVAL_MS);
