import crypto from "node:crypto";
import dgram from "node:dgram";

export const RADIUS = {
  ACCESS_REQUEST: 1,
  ACCESS_ACCEPT: 2,
  ACCESS_REJECT: 3,
  ACCOUNTING_REQUEST: 4,
  ACCOUNTING_RESPONSE: 5,
};

const ATTR = {
  USER_NAME: 1,
  REPLY_MESSAGE: 18,
  CLASS: 25,
  SESSION_TIMEOUT: 27,
  IDLE_TIMEOUT: 28,
  CALLING_STATION_ID: 31,
  NAS_IDENTIFIER: 32,
  ACCT_STATUS_TYPE: 40,
  ACCT_INPUT_OCTETS: 42,
  ACCT_OUTPUT_OCTETS: 43,
  ACCT_SESSION_ID: 44,
  ACCT_SESSION_TIME: 46,
  VENDOR_SPECIFIC: 26,
  MESSAGE_AUTHENTICATOR: 80,
};

const MIKROTIK_VENDOR_ID = 14988;
const MIKROTIK_RATE_LIMIT = 8;

export function normalizeDeviceId(value) {
  const normalized = String(value || "").trim().toLowerCase();
  const mac = normalized.replace(/[^0-9a-f]/g, "");
  return mac.length === 12 ? mac : normalized;
}

export function fingerprintDevice(value) {
  return crypto.createHash("sha256").update(normalizeDeviceId(value)).digest("hex");
}

export function parsePacket(packet) {
  if (!Buffer.isBuffer(packet) || packet.length < 20) throw new Error("RADIUS packet is too short");
  const length = packet.readUInt16BE(2);
  if (length < 20 || length > packet.length || length > 4096) throw new Error("RADIUS packet length is invalid");
  const attributes = new Map();
  const attributeDetails = [];
  let offset = 20;
  while (offset < length) {
    if (offset + 2 > length) throw new Error("RADIUS attribute header is incomplete");
    const type = packet[offset];
    const attributeLength = packet[offset + 1];
    if (attributeLength < 2 || offset + attributeLength > length) throw new Error("RADIUS attribute length is invalid");
    const values = attributes.get(type) || [];
    values.push(packet.subarray(offset + 2, offset + attributeLength));
    attributes.set(type, values);
    attributeDetails.push({ type, offset, length: attributeLength, valueOffset: offset + 2 });
    offset += attributeLength;
  }
  return { code: packet[0], identifier: packet[1], length, authenticator: packet.subarray(4, 20), attributes, attributeDetails, raw: packet.subarray(0, length) };
}

function first(packet, type) {
  return packet.attributes.get(type)?.[0] || null;
}

export function stringAttribute(packet, type) {
  return first(packet, type)?.toString("utf8").trim() || "";
}

export function integerAttribute(packet, type) {
  const value = first(packet, type);
  return value?.length === 4 ? value.readUInt32BE(0) : null;
}

export function encodeStringAttribute(type, value) {
  const body = Buffer.from(String(value), "utf8").subarray(0, 253);
  return Buffer.concat([Buffer.from([type, body.length + 2]), body]);
}

export function encodeIntegerAttribute(type, value) {
  const attribute = Buffer.alloc(6);
  attribute[0] = type;
  attribute[1] = 6;
  attribute.writeUInt32BE(Math.max(0, Number(value) || 0), 2);
  return attribute;
}

export function encodeVendorStringAttribute(vendorId, vendorType, value) {
  const body = Buffer.from(String(value), "utf8").subarray(0, 247);
  const attribute = Buffer.alloc(8 + body.length);
  attribute[0] = ATTR.VENDOR_SPECIFIC;
  attribute[1] = attribute.length;
  attribute.writeUInt32BE(vendorId, 2);
  attribute[6] = vendorType;
  attribute[7] = body.length + 2;
  body.copy(attribute, 8);
  return attribute;
}

export function verifyMessageAuthenticator(request, secret) {
  const matches = request.attributeDetails.filter((attribute) => attribute.type === ATTR.MESSAGE_AUTHENTICATOR);
  if (!matches.length) return null;
  if (matches.length !== 1 || matches[0].length !== 18) return false;
  const copy = Buffer.from(request.raw);
  const supplied = Buffer.from(copy.subarray(matches[0].valueOffset, matches[0].valueOffset + 16));
  copy.fill(0, matches[0].valueOffset, matches[0].valueOffset + 16);
  const expected = crypto.createHmac("md5", secret).update(copy).digest();
  return crypto.timingSafeEqual(supplied, expected);
}

export function buildResponse(code, request, secret, attributes = [], includeMessageAuthenticator = false) {
  const responseAttributes = includeMessageAuthenticator
    ? [...attributes, Buffer.concat([Buffer.from([ATTR.MESSAGE_AUTHENTICATOR, 18]), Buffer.alloc(16)])]
    : attributes;
  const body = Buffer.concat(responseAttributes);
  const response = Buffer.alloc(20 + body.length);
  response[0] = code;
  response[1] = request.identifier;
  response.writeUInt16BE(response.length, 2);
  request.authenticator.copy(response, 4);
  body.copy(response, 20);
  if (includeMessageAuthenticator) {
    const valueOffset = response.length - 16;
    crypto.createHmac("md5", secret).update(response).digest().copy(response, valueOffset);
  }
  const hashInput = Buffer.concat([response.subarray(0, 4), request.authenticator, body, Buffer.from(secret)]);
  // Re-read attributes from the response because Message-Authenticator was
  // inserted after the initial body buffer was copied.
  const finalHashInput = Buffer.concat([response.subarray(0, 4), request.authenticator, response.subarray(20), Buffer.from(secret)]);
  crypto.createHash("md5").update(includeMessageAuthenticator ? finalHashInput : hashInput).digest().copy(response, 4);
  return response;
}

export function verifyAccountingRequest(request, secret) {
  if (request.code !== RADIUS.ACCOUNTING_REQUEST) return false;
  const copy = Buffer.from(request.raw);
  copy.fill(0, 4, 20);
  const expected = crypto.createHash("md5").update(copy).update(secret).digest();
  return request.authenticator.length === expected.length && crypto.timingSafeEqual(request.authenticator, expected);
}

export function createRadiusServers(config, state, log = console) {
  const allowed = (address) => config.radiusClients.has(address) || (address.startsWith("::ffff:") && config.radiusClients.has(address.slice(7)));
  const auth = dgram.createSocket("udp4");
  const accounting = dgram.createSocket("udp4");

  auth.on("message", (message, remote) => {
    if (!allowed(remote.address)) return;
    try {
      const request = parsePacket(message);
      if (request.code !== RADIUS.ACCESS_REQUEST) return;
      if (verifyMessageAuthenticator(request, config.radiusSecret) === false) return;
      const deviceId = stringAttribute(request, ATTR.CALLING_STATION_ID) || stringAttribute(request, ATTR.USER_NAME);
      const grant = deviceId ? state.getGrant(fingerprintDevice(deviceId)) : null;
      const sessionSeconds = grant ? Math.max(1, Math.floor((new Date(grant.expiresAt).getTime() - Date.now()) / 1000)) : 0;
      const attributes = grant ? [
        encodeIntegerAttribute(ATTR.SESSION_TIMEOUT, sessionSeconds),
        encodeIntegerAttribute(ATTR.IDLE_TIMEOUT, 300),
        encodeStringAttribute(ATTR.CLASS, grant.voucherId),
        encodeVendorStringAttribute(MIKROTIK_VENDOR_ID, MIKROTIK_RATE_LIMIT, `${grant.uploadKbps}k/${grant.downloadKbps}k`),
        encodeStringAttribute(ATTR.REPLY_MESSAGE, "Vimify access granted"),
      ] : [encodeStringAttribute(ATTR.REPLY_MESSAGE, "Open the Vimify portal to buy or recover access")];
      const response = buildResponse(grant ? RADIUS.ACCESS_ACCEPT : RADIUS.ACCESS_REJECT, request, config.radiusSecret, attributes, true);
      auth.send(response, remote.port, remote.address);
    } catch (error) {
      log.warn(`Discarded invalid RADIUS access packet from ${remote.address}: ${error.message}`);
    }
  });

  accounting.on("message", async (message, remote) => {
    if (!allowed(remote.address)) return;
    try {
      const request = parsePacket(message);
      if (!verifyAccountingRequest(request, config.radiusSecret)) return;
      const response = buildResponse(RADIUS.ACCOUNTING_RESPONSE, request, config.radiusSecret);
      accounting.send(response, remote.port, remote.address);

      const deviceId = stringAttribute(request, ATTR.CALLING_STATION_ID);
      const sessionId = stringAttribute(request, ATTR.ACCT_SESSION_ID);
      const statusType = integerAttribute(request, ATTR.ACCT_STATUS_TYPE);
      if (!deviceId || !sessionId || ![1, 2, 3].includes(statusType)) return;
      const deviceHash = fingerprintDevice(deviceId);
      const grant = state.getGrant(deviceHash);
      await state.queueAccounting({
        accountingSessionId: sessionId,
        hotspotId: config.hotspotId,
        edgeDeviceId: config.edgeDeviceId,
        voucherId: stringAttribute(request, ATTR.CLASS) || grant?.voucherId || undefined,
        deviceHash,
        status: statusType === 1 ? "start" : statusType === 2 ? "stop" : "interim",
        inputOctets: integerAttribute(request, ATTR.ACCT_INPUT_OCTETS) || 0,
        outputOctets: integerAttribute(request, ATTR.ACCT_OUTPUT_OCTETS) || 0,
        sessionTime: integerAttribute(request, ATTR.ACCT_SESSION_TIME) || 0,
        nasIdentifier: stringAttribute(request, ATTR.NAS_IDENTIFIER) || undefined,
        recordedAt: new Date().toISOString(),
      });
    } catch (error) {
      log.warn(`Discarded invalid RADIUS accounting packet from ${remote.address}: ${error.message}`);
    }
  });

  return {
    async listen() {
      await Promise.all([
        new Promise((resolve, reject) => auth.once("error", reject).bind(config.authPort, config.radiusHost, resolve)),
        new Promise((resolve, reject) => accounting.once("error", reject).bind(config.accountingPort, config.radiusHost, resolve)),
      ]);
      log.info(`RADIUS authentication listening on ${config.radiusHost}:${config.authPort}`);
      log.info(`RADIUS accounting listening on ${config.radiusHost}:${config.accountingPort}`);
    },
    close() {
      auth.close();
      accounting.close();
    },
  };
}

export const RadiusAttributes = ATTR;
