const crypto = require("crypto");
const dgram = require("dgram");
const fs = require("fs");
const path = require("path");

const mikrotik = require("./mikrotik-service.cjs");

function readJson(filePath, fallback) {
  try { return JSON.parse(fs.readFileSync(filePath, "utf8")); } catch (_) { return fallback; }
}

function parseAttributes(packet) {
  const attributes = [];
  for (let offset = 20; offset + 2 <= packet.length;) {
    const type = packet[offset];
    const length = packet[offset + 1];
    if (length < 2 || offset + length > packet.length) break;
    attributes.push({ type, value: packet.subarray(offset + 2, offset + length) });
    offset += length;
  }
  return attributes;
}

function firstAttribute(attributes, type) {
  return attributes.find((attribute) => attribute.type === type)?.value;
}

function decodePapPassword(encrypted, secret, requestAuthenticator) {
  if (!encrypted || encrypted.length === 0 || encrypted.length % 16 !== 0) return null;
  const output = Buffer.alloc(encrypted.length);
  let previous = requestAuthenticator;
  for (let offset = 0; offset < encrypted.length; offset += 16) {
    const digest = crypto.createHash("md5").update(Buffer.concat([Buffer.from(secret), previous])).digest();
    for (let index = 0; index < 16; index += 1) output[offset + index] = encrypted[offset + index] ^ digest[index];
    previous = encrypted.subarray(offset, offset + 16);
  }
  return output.toString("utf8").replace(/\0+$/, "");
}

function verifyChap(chapValue, challenge, password) {
  if (!chapValue || chapValue.length !== 17) return false;
  const expected = crypto.createHash("md5")
    .update(Buffer.concat([chapValue.subarray(0, 1), Buffer.from(password), challenge]))
    .digest();
  return crypto.timingSafeEqual(expected, chapValue.subarray(1));
}

function buildResponse(request, code, secret, responseAttributes = Buffer.alloc(0)) {
  const requestHasMessageAuthenticator = parseAttributes(request).some((attribute) => attribute.type === 80);
  const messageAuthenticator = requestHasMessageAuthenticator ? Buffer.concat([Buffer.from([80, 18]), Buffer.alloc(16)]) : Buffer.alloc(0);
  const allAttributes = Buffer.concat([responseAttributes, messageAuthenticator]);
  const header = Buffer.alloc(4);
  header[0] = code;
  header[1] = request[1];
  header.writeUInt16BE(20 + allAttributes.length, 2);
  const authenticator = crypto.createHash("md5").update(Buffer.concat([
    header, request.subarray(4, 20), allAttributes, Buffer.from(secret)
  ])).digest();
  const packet = Buffer.concat([header, authenticator, allAttributes]);
  if (requestHasMessageAuthenticator) {
    const digest = crypto.createHmac("md5", secret).update(packet).digest();
    digest.copy(packet, packet.length - 16);
  }
  return packet;
}

function verifyMessageAuthenticator(packet, secret) {
  const attributes = parseAttributes(packet);
  let offset = 20;
  for (const attribute of attributes) {
    if (attribute.type === 80 && attribute.value.length === 16) {
      const copy = Buffer.from(packet);
      copy.fill(0, offset + 2, offset + 18);
      const expected = crypto.createHmac("md5", secret).update(copy).digest();
      return crypto.timingSafeEqual(attribute.value, expected);
    }
    offset += attribute.value.length + 2;
  }
  return true;
}

function verifyAccountingRequest(packet, secret) {
  const supplied = packet.subarray(4, 20);
  const copy = Buffer.from(packet);
  copy.fill(0, 4, 20);
  const expected = crypto.createHash("md5").update(Buffer.concat([copy, Buffer.from(secret)])).digest();
  return crypto.timingSafeEqual(supplied, expected);
}

function ipFromBuffer(buffer) {
  return buffer && buffer.length === 4 ? Array.from(buffer).join(".") : "";
}

function startRadiusServer(options = {}) {
  const cwd = options.cwd || process.cwd();
  const nasPath = path.join(cwd, "radius_nas.json");
  const configPath = path.join(cwd, "radius_config.json");
  const sessionsPath = path.join(cwd, "radius_sessions.json");
  const credentialCache = new Map();
  let lastRefresh = 0;
  let refreshPromise = null;

  function configuredNas() {
    return readJson(nasPath, []).filter((nas) => nas && nas.ip_address && nas.secret);
  }

  function nasForSource(sourceAddress) {
    const source = String(sourceAddress || "").replace(/^::ffff:/, "");
    const list = configuredNas();
    const exact = list.find((nas) => [nas.radius_source_ip, nas.ip_address]
      .filter(Boolean).map(String).map((item) => item.replace(/^::ffff:/, "").split(":")[0]).includes(source));
    if (exact) return exact;
    return list.length === 1 ? list[0] : null;
  }

  async function refreshCredentials(force = false) {
    if (!force && Date.now() - lastRefresh < 30000 && credentialCache.size) return;
    if (refreshPromise) return refreshPromise;
    refreshPromise = (async () => {
      const next = new Map();
      for (const nas of configuredNas()) {
        const [pppoe, hotspot] = await Promise.all([
          mikrotik.listPppoeAccounts(nas),
          mikrotik.listHotspotUsers(nas)
        ]);
        for (const account of [...pppoe, ...hotspot]) {
          if (account.username && account.password !== undefined) next.set(account.username, account);
        }
      }
      credentialCache.clear();
      for (const [username, account] of next) credentialCache.set(username, account);
      lastRefresh = Date.now();
    })().finally(() => { refreshPromise = null; });
    return refreshPromise;
  }

  function saveAccounting(attributes, sourceAddress) {
    const text = (type) => firstAttribute(attributes, type)?.toString("utf8") || "";
    const integer = (type) => firstAttribute(attributes, type)?.readUInt32BE(0) || 0;
    const statusType = integer(40);
    const sessionId = text(44);
    const sessions = readJson(sessionsPath, []);
    const record = {
      id: sessionId || `${sourceAddress}-${text(1)}`,
      session_id: sessionId,
      username: text(1),
      nas_ip_address: ipFromBuffer(firstAttribute(attributes, 4)) || sourceAddress,
      framed_ip_address: ipFromBuffer(firstAttribute(attributes, 8)),
      status_type: statusType,
      session_time: integer(46),
      input_octets: integer(42),
      output_octets: integer(43),
      updated_at: new Date().toISOString()
    };
    const index = sessions.findIndex((item) => item.id === record.id);
    if (statusType === 2 || statusType === 8) {
      if (index !== -1) sessions.splice(index, 1);
    } else if (index === -1) sessions.push(record);
    else sessions[index] = { ...sessions[index], ...record };
    fs.writeFileSync(sessionsPath, JSON.stringify(sessions, null, 2), "utf8");
  }

  const config = readJson(configPath, { auth_port: 1812, acct_port: 1813 });
  const authSocket = dgram.createSocket("udp4");
  const accountingSocket = dgram.createSocket("udp4");

  authSocket.on("message", async (message, remote) => {
    try {
      if (message.length < 20 || message[0] !== 1 || message.readUInt16BE(2) !== message.length) return;
      const nas = nasForSource(remote.address);
      if (!nas) return console.warn(`[RADIUS] Access-Request dari NAS tidak dikenal: ${remote.address}`);
      const attributes = parseAttributes(message);
      if (!verifyMessageAuthenticator(message, nas.secret)) return console.warn(`[RADIUS] Message-Authenticator tidak valid dari ${remote.address}`);
      const username = firstAttribute(attributes, 1)?.toString("utf8") || "";
      await refreshCredentials();
      let account = credentialCache.get(username);
      if (!account) {
        await refreshCredentials(true);
        account = credentialCache.get(username);
      }
      const pap = decodePapPassword(firstAttribute(attributes, 2), nas.secret, message.subarray(4, 20));
      const chap = firstAttribute(attributes, 3);
      const challenge = firstAttribute(attributes, 60) || message.subarray(4, 20);
      const validPassword = account && !account.disabled && (
        (pap !== null && pap === account.password) || verifyChap(chap, challenge, account.password || "")
      );
      const response = buildResponse(message, validPassword ? 2 : 3, nas.secret);
      authSocket.send(response, remote.port, remote.address);
      console.log(`[RADIUS] ${validPassword ? "Access-Accept" : "Access-Reject"} user=${username} nas=${remote.address}`);
    } catch (error) {
      console.error("[RADIUS] Gagal memproses Access-Request:", error.message);
    }
  });
  authSocket.on("error", (error) => console.error("[RADIUS] Auth socket:", error.message));

  accountingSocket.on("message", (message, remote) => {
    try {
      if (message.length < 20 || message[0] !== 4 || message.readUInt16BE(2) !== message.length) return;
      const nas = nasForSource(remote.address);
      if (!nas || !verifyAccountingRequest(message, nas.secret)) {
        return console.warn(`[RADIUS] Accounting request tidak valid dari ${remote.address}`);
      }
      const attributes = parseAttributes(message);
      saveAccounting(attributes, remote.address);
      accountingSocket.send(buildResponse(message, 5, nas.secret), remote.port, remote.address);
    } catch (error) {
      console.error("[RADIUS] Gagal memproses Accounting-Request:", error.message);
    }
  });
  accountingSocket.on("error", (error) => console.error("[RADIUS] Accounting socket:", error.message));

  authSocket.bind(Number(config.auth_port) || 1812, "0.0.0.0", () => console.log(`[RADIUS] Auth aktif UDP ${authSocket.address().port}`));
  accountingSocket.bind(Number(config.acct_port) || 1813, "0.0.0.0", () => console.log(`[RADIUS] Accounting aktif UDP ${accountingSocket.address().port}`));
  setTimeout(() => refreshCredentials(true).catch((error) => console.error("[RADIUS] Cache akun gagal:", error.message)), 500);

  return { authSocket, accountingSocket, refreshCredentials };
}

module.exports = { buildResponse, decodePapPassword, parseAttributes, startRadiusServer, verifyChap, verifyMessageAuthenticator };
