'use strict';

/**
 * bio-rd gRPC client wrapper for PeerCortex.
 *
 * Wraps the RoutingInformationService (RIS) from bio-rd.
 * proto package: bio.ris  (cmd/ris/api/ris.proto)
 * proto package: bio.net  (net/api/net.proto)
 * proto package: bio.route (route/api/route.proto)
 *
 * Usage:
 *   const { createRisClient } = require('./bio-rd-client');
 *   const ris = createRisClient('localhost', 4321);
 *   const routes = await ris.dumpRib('router1', '0:0', 0);
 */

const path = require('path');
const grpc = require('@grpc/grpc-js');
const protoLoader = require('@grpc/proto-loader');

const PROTO_DIR = path.join(__dirname, 'protos');
const RIS_PROTO = path.join(PROTO_DIR, 'cmd/ris/api/ris.proto');

const LOADER_OPTIONS = {
  keepCase: true,
  longs: String,        // uint64 fields come back as strings (avoids JS BigInt issues)
  enums: String,        // enum values come back as their string names
  defaults: true,
  oneofs: true,
  includeDirs: [PROTO_DIR],
};

const HIDDEN_REASON_MAP = {
  HiddenReasonNone: null,
  HiddenReasonNextHopUnreachable: 'next-hop-unreachable',
  HiddenReasonFilteredByPolicy: 'filtered-by-policy',
  HiddenReasonASLoop: 'as-loop',
  HiddenReasonOurOriginatorID: 'our-originator-id',
  HiddenReasonClusterLoop: 'cluster-loop',
  HiddenReasonOTCMismatch: 'otc-mismatch',
};

const ORIGIN_MAP = { 0: 'IGP', 1: 'EGP', 2: 'Incomplete' };

// ─── IP helpers ──────────────────────────────────────────────────────────────

/**
 * Convert a dotted-decimal or colon-hex IP string to the bio.net.IP proto object.
 * uint64 fields are passed as strings (matching longs:'String' loader option).
 */
function ipToProto(ipString) {
  if (ipString.includes(':')) {
    return _ipv6ToProto(ipString);
  }
  return _ipv4ToProto(ipString);
}

function _ipv4ToProto(ipString) {
  const parts = ipString.split('.').map(Number);
  if (parts.length !== 4 || parts.some(isNaN)) {
    throw new Error(`Invalid IPv4 address: ${ipString}`);
  }
  const val = ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0;
  return { higher: '0', lower: String(val), version: 'IPv4' };
}

function _ipv6ToProto(ipString) {
  // Expand :: notation and parse into two uint64 halves
  const expanded = _expandIPv6(ipString);
  const groups = expanded.split(':').map(g => parseInt(g, 16));
  const higher = BigInt(groups[0]) << 48n
    | BigInt(groups[1]) << 32n
    | BigInt(groups[2]) << 16n
    | BigInt(groups[3]);
  const lower = BigInt(groups[4]) << 48n
    | BigInt(groups[5]) << 32n
    | BigInt(groups[6]) << 16n
    | BigInt(groups[7]);
  return { higher: String(higher), lower: String(lower), version: 'IPv6' };
}

function _expandIPv6(addr) {
  if (addr.includes('::')) {
    const [left, right] = addr.split('::');
    const leftGroups = left ? left.split(':') : [];
    const rightGroups = right ? right.split(':') : [];
    const missing = 8 - leftGroups.length - rightGroups.length;
    const mid = Array(missing).fill('0');
    return [...leftGroups, ...mid, ...rightGroups].join(':');
  }
  return addr;
}

/**
 * Convert a bio.net.IP proto object back to a human-readable IP string.
 */
function protoToIp(protoIp) {
  if (!protoIp) return null;
  const version = protoIp.version || 'IPv4';
  const lower = BigInt(protoIp.lower || '0');
  const higher = BigInt(protoIp.higher || '0');

  if (version === 'IPv4' || version === 0) {
    const n = Number(lower);
    return [(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff].join('.');
  }

  // IPv6: reassemble 8 groups of 16 bits from two uint64 values
  const mask16 = 0xffffn;
  const groups = [
    (higher >> 48n) & mask16,
    (higher >> 32n) & mask16,
    (higher >> 16n) & mask16,
    higher & mask16,
    (lower >> 48n) & mask16,
    (lower >> 32n) & mask16,
    (lower >> 16n) & mask16,
    lower & mask16,
  ];
  return groups.map(g => g.toString(16)).join(':');
}

/**
 * Convert a CIDR string to a bio.net.Prefix proto object.
 */
function prefixToProto(cidr) {
  const [ipStr, lenStr] = cidr.split('/');
  const length = lenStr !== undefined ? parseInt(lenStr, 10) : (ipStr.includes(':') ? 128 : 32);
  return { address: ipToProto(ipStr), length };
}

// ─── Route decoding ───────────────────────────────────────────────────────────

/**
 * Decode a bio.route.Route proto object to a clean JS object.
 * Handles both DumpRIBReply.route and direct Route messages.
 */
function decodeRoute(risRoute) {
  if (!risRoute) return null;

  // DumpRIBReply wraps the route in a .route field
  const route = risRoute.route || risRoute;
  if (!route || !route.pfx) return null;

  const pfxAddr = protoToIp(route.pfx.address);
  const pfxLen = route.pfx.length || 0;
  const prefix = `${pfxAddr}/${pfxLen}`;

  const paths = (route.paths || []).map(_decodePath);

  // Flatten: return first path's attributes at top level, paths array for multi-path
  const first = paths[0] || {};
  return {
    prefix,
    nextHop: first.nextHop || null,
    asPaths: first.asPaths || [],
    communities: first.communities || [],
    largeCommunities: first.largeCommunities || [],
    localPref: first.localPref || 0,
    med: first.med || 0,
    origin: first.origin || null,
    ebgp: first.ebgp || false,
    hidden: first.hidden || false,
    hiddenReason: first.hiddenReason || null,
    paths,
  };
}

function _decodePath(path) {
  const hiddenReasonStr = typeof path.hidden_reason === 'string'
    ? path.hidden_reason
    : Object.keys(HIDDEN_REASON_MAP)[path.hidden_reason] || 'HiddenReasonNone';
  const hidden = hiddenReasonStr !== 'HiddenReasonNone';
  const hiddenReason = HIDDEN_REASON_MAP[hiddenReasonStr] || null;

  if (path.type === 'BGP' || path.type === 1) {
    return _decodeBgpPath(path.bgp_path, hidden, hiddenReason);
  }
  if (path.type === 'Static' || path.type === 0) {
    return _decodeStaticPath(path.static_path, hidden, hiddenReason);
  }

  return { nextHop: null, asPaths: [], communities: [], largeCommunities: [],
    localPref: 0, med: 0, origin: null, ebgp: false, hidden, hiddenReason };
}

function _decodeBgpPath(bgp, hidden, hiddenReason) {
  if (!bgp) return { nextHop: null, asPaths: [], communities: [], largeCommunities: [],
    localPref: 0, med: 0, origin: null, ebgp: false, hidden, hiddenReason };

  const asPaths = (bgp.as_path || []).flatMap(seg => seg.asns || []).map(Number);

  const communities = (bgp.communities || []).map(c => {
    const n = Number(c);
    return `${(n >> 16) & 0xffff}:${n & 0xffff}`;
  });

  const largeCommunities = (bgp.large_communities || []).map(lc =>
    `${lc.global_administrator}:${lc.data_part1}:${lc.data_part2}`
  );

  return {
    nextHop: protoToIp(bgp.next_hop),
    asPaths,
    communities,
    largeCommunities,
    localPref: Number(bgp.local_pref || 0),
    med: Number(bgp.med || 0),
    origin: ORIGIN_MAP[bgp.origin] || 'Unknown',
    ebgp: bgp.ebgp || false,
    hidden,
    hiddenReason,
  };
}

function _decodeStaticPath(staticPath, hidden, hiddenReason) {
  return {
    nextHop: staticPath ? protoToIp(staticPath.next_hop) : null,
    asPaths: [], communities: [], largeCommunities: [],
    localPref: 0, med: 0, origin: 'Static', ebgp: false,
    hidden, hiddenReason,
  };
}

// ─── Client factory ───────────────────────────────────────────────────────────

/**
 * Load the RIS proto definition (cached after first call).
 */
let _packageDef = null;
function _loadPackageDef() {
  if (_packageDef) return _packageDef;
  _packageDef = protoLoader.loadSync(RIS_PROTO, LOADER_OPTIONS);
  return _packageDef;
}

/**
 * Create a RIS gRPC client.
 * Returns null if the package definition cannot be loaded.
 */
function createRisClient(host = 'localhost', port = 4321) {
  let RisService;
  try {
    const packageDef = _loadPackageDef();
    const proto = grpc.loadPackageDefinition(packageDef);
    RisService = proto.bio.ris.RoutingInformationService;
  } catch (err) {
    console.error('[bio-rd] Failed to load proto definition:', err.message);
    return null;
  }

  const address = `${host}:${port}`;
  const channelOptions = {
    'grpc.connect_timeout_ms': 5000,
    'grpc.initial_reconnect_backoff_ms': 1000,
  };

  const channel = new grpc.Channel(
    address,
    grpc.credentials.createInsecure(),
    channelOptions
  );

  const client = new RisService(address, grpc.credentials.createInsecure(), channelOptions);

  // ── Unary helper ────────────────────────────────────────────────────────────
  function _unary(method, request) {
    return new Promise((resolve, reject) => {
      const deadline = new Date(Date.now() + 5000);
      client[method](request, { deadline }, (err, response) => {
        if (err) return reject(err);
        resolve(response);
      });
    });
  }

  // ── Stream collector ────────────────────────────────────────────────────────
  function _collectStream(method, request) {
    return new Promise((resolve, reject) => {
      const results = [];
      const deadline = new Date(Date.now() + 30000);
      const call = client[method](request, { deadline });
      call.on('data', chunk => results.push(chunk));
      call.on('error', reject);
      call.on('end', () => resolve(results));
    });
  }

  // ── Public API ──────────────────────────────────────────────────────────────

  /**
   * Longest Prefix Match for a given prefix.
   * Returns the best matching route or null.
   */
  async function lpm(router, prefix) {
    try {
      const response = await _unary('lPM', {
        router,
        vrf: '0:0',
        pfx: prefixToProto(prefix),
      });
      const routes = (response.routes || []).map(decodeRoute).filter(Boolean);
      return routes[0] || null;
    } catch (err) {
      console.warn(`[bio-rd] lpm(${router}, ${prefix}) failed:`, err.message);
      return null;
    }
  }

  /**
   * Exact prefix lookup.
   * Returns the matching route or null.
   */
  async function get(router, prefix) {
    try {
      const response = await _unary('get', {
        router,
        vrf: '0:0',
        pfx: prefixToProto(prefix),
      });
      const routes = (response.routes || []).map(decodeRoute).filter(Boolean);
      return routes[0] || null;
    } catch (err) {
      console.warn(`[bio-rd] get(${router}, ${prefix}) failed:`, err.message);
      return null;
    }
  }

  /**
   * Get all more-specific prefixes covered by the given prefix.
   * Returns an array of decoded routes.
   */
  async function getLonger(router, prefix) {
    try {
      const response = await _unary('getLonger', {
        router,
        vrf: '0:0',
        pfx: prefixToProto(prefix),
      });
      return (response.routes || []).map(decodeRoute).filter(Boolean);
    } catch (err) {
      console.warn(`[bio-rd] getLonger(${router}, ${prefix}) failed:`, err.message);
      return [];
    }
  }

  /**
   * Get all routers known to this RIS instance.
   * Returns an array of { name, address } objects.
   */
  async function getRouters() {
    try {
      const response = await _unary('getRouters', {});
      return (response.routers || []).map(r => ({
        name: r.sys_name || '',
        address: r.address || '',
      }));
    } catch (err) {
      console.warn('[bio-rd] getRouters() failed:', err.message);
      return [];
    }
  }

  /**
   * Dump the full RIB for a router/VRF, optionally filtered by origin ASN.
   * Collects the entire stream and returns an array of decoded routes.
   *
   * @param {string} router - Router identifier (e.g. "router1")
   * @param {string} vrfName - VRF name (e.g. "0:0" for default)
   * @param {number} originAsn - Filter by originating AS (0 = no filter)
   */
  async function dumpRib(router, vrfName = '0:0', originAsn = 0) {
    try {
      const request = {
        router,
        vrf: vrfName,
        afisafi: 'IPv4Unicast',
        filter: { originating_asn: originAsn || 0 },
      };
      const chunks = await _collectStream('dumpRIB', request);
      return chunks.map(decodeRoute).filter(Boolean);
    } catch (err) {
      console.warn(`[bio-rd] dumpRib(${router}) failed:`, err.message);
      return [];
    }
  }

  /**
   * Observe real-time RIB updates for a router/VRF.
   *
   * @param {string} router - Router identifier
   * @param {string} vrfName - VRF name (e.g. "0:0")
   * @param {function} onUpdate - Called with { type: 'add'|'withdraw', route }
   * @param {function} onError - Called with the error on stream failure
   * @returns {function} cancel - Call to stop the observation
   */
  function observeRib(router, vrfName = '0:0', onUpdate, onError) {
    const request = {
      router,
      vrf: vrfName,
      afisafi: 'IPv4Unicast',
    };

    let call;
    try {
      call = client.observeRIB(request);
    } catch (err) {
      if (onError) onError(err);
      return () => {};
    }

    call.on('data', (update) => {
      if (!update || !update.route) return;
      const type = update.advertisement ? 'add' : 'withdraw';
      const route = decodeRoute(update.route);
      if (route && onUpdate) onUpdate({ type, route });
    });

    call.on('error', (err) => {
      console.warn(`[bio-rd] observeRib(${router}) stream error:`, err.message);
      if (onError) onError(err);
    });

    call.on('end', () => {
      console.log(`[bio-rd] observeRib(${router}) stream ended`);
    });

    return () => {
      try { call.cancel(); } catch (_) {}
    };
  }

  // ── Cleanup ─────────────────────────────────────────────────────────────────
  function close() {
    try { channel.close(); } catch (_) {}
    try { client.close(); } catch (_) {}
  }

  return { lpm, get, getLonger, getRouters, dumpRib, observeRib, close };
}

module.exports = {
  createRisClient,
  ipToProto,
  prefixToProto,
  protoToIp,
  decodeRoute,
};
