#!/bin/bash
# Mixed-version interop harness: generate the per-node configs for a
# node-spec.
#
# A NODE-SPEC is a multiset of image slots (`a`, `b`, `c`), of size >= 2.
# Each spec entry is one node; the same slot may appear more than once.
# The slots resolve to the three Docker images built by build-images.sh:
#
#   a -> fips-interop:a   ("version under test")
#   b -> fips-interop:b   ("parent / comparison")
#   c -> fips-interop:c   ("release baseline")
#
# Node identity vs image slot are SEPARATE:
#   - A spec entry is a slot letter.
#   - Node id = <slot><ordinal>, ordinal counting occurrences of that
#     slot, 1-based. Spec `a a b c` -> node ids `a1 a2 b1 c1`.
#   - Container name = fips-interop-<nodeid>.
#   - IPv4 = 172.30.0.1<index>, index = 0-based position in the spec
#     (10, 11, 12, 13, ...).
#
# Topology is a FULL MESH by default: every node auto-connects to every
# other node. Set FIPS_INTEROP_EDGES (see Environment) to an explicit
# undirected edge list for a multi-hop / leaf topology instead.
# Node identities are derived deterministically via the shared
# derive_keys.py helper, keyed by a unique per-node string so that two
# nodes of the same slot (a1, a2) still get DISTINCT identities.
#
# Output goes to <runs-base>/generated-configs/ (see FIPS_INTEROP_RUNS_DIR
# under Environment, below):
#   <nodeid>.yaml                  per-node daemon config (one per node)
#   docker-compose.generated.yml   generated compose, one service per node
#   nodes.env                      manifest for the driver to source
#   npubs.env                      NPUB_<NODEID> map (per-node npubs)
#
# Generation is deterministic and idempotent: same node-spec in -> same
# files out.
#
# Usage:
#   ./generate-configs.sh [node-spec...]
#
#   node-spec   space-separated slot letters, default `a b c`.
#
# Environment:
#   REKEY_AFTER_SECS       FSP/FMP rekey interval (default 35, matching
#                          the static rekey suite).
#   FIPS_INTEROP_EDGES     Optional undirected edge list (space/comma
#                          separated `nid-nid` tokens, e.g.
#                          "a1-b1 a1-c1 b1-a2"). Unset ⇒ full mesh. When
#                          set, each node peers only with its listed
#                          neighbors; the graph must be connected with no
#                          isolated node. Enables multi-hop forwarding /
#                          leaf-node topologies.
#   FIPS_INTEROP_RUNS_DIR  Root for harness scratch dirs (.build/,
#                          .stress-runs/, generated-configs/). When
#                          unset, falls back to in-tree paths under
#                          testing/interop/ and prints a warning to
#                          stderr; set it to a path outside the source
#                          tree to keep generated artefacts out of the
#                          checkout.

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
DERIVE_KEYS="$SCRIPT_DIR/../lib/derive_keys.py"

# ── Scratch-dir root ─────────────────────────────────────────────────
#
# FIPS_INTEROP_RUNS_DIR controls where the harness writes its scratch
# directories (.build/, .stress-runs/, generated-configs/). When unset
# we fall back to in-tree paths under testing/interop/ and warn the
# operator, so the warning fires exactly once per invocation. When a
# parent script has already warned it exports _FIPS_INTEROP_WARNED=1
# to suppress duplicate warnings in child scripts.
if [[ -n "${FIPS_INTEROP_RUNS_DIR:-}" ]]; then
    RUNS_BASE="$FIPS_INTEROP_RUNS_DIR"
    mkdir -p "$RUNS_BASE"
else
    RUNS_BASE="$SCRIPT_DIR"
    if [[ -z "${_FIPS_INTEROP_WARNED:-}" ]]; then
        echo >&2 "WARNING: FIPS_INTEROP_RUNS_DIR not set; harness output will be written under the source tree at $RUNS_BASE. Set FIPS_INTEROP_RUNS_DIR to a path outside the source tree to avoid this."
        export _FIPS_INTEROP_WARNED=1
    fi
fi

OUT_DIR="$RUNS_BASE/generated-configs"

REKEY_AFTER_SECS="${REKEY_AFTER_SECS:-35}"
MESH_NAME="fips-interop-mesh"
UDP_PORT=2121
SUBNET_PREFIX="172.30.0.1"     # node N gets ${SUBNET_PREFIX}<index>

# ── Args: the node-spec ──────────────────────────────────────────────

SPEC=("$@")
if [ "${#SPEC[@]}" -eq 0 ]; then
    SPEC=(a b c)
fi

if [ "${#SPEC[@]}" -lt 2 ]; then
    echo "ERROR: node-spec must have at least 2 nodes (got ${#SPEC[@]})" >&2
    echo "Usage: $0 [node-spec...]   e.g. $0 a a b c" >&2
    exit 1
fi

for slot in "${SPEC[@]}"; do
    case "$slot" in
        a|b|c) ;;
        *)
            echo "ERROR: invalid slot '$slot' — must be one of a, b, c" >&2
            exit 1
            ;;
    esac
done

# ── Derive node ids, slots, containers, IPs from the spec ────────────
#
# NODE_IDS preserves spec order. NODE_SLOT/NODE_IP/NODE_CTR are keyed by
# node id.

NODE_IDS=()
declare -A NODE_SLOT NODE_IP NODE_CTR
declare -A SLOT_COUNT=([a]=0 [b]=0 [c]=0)

index=0
for slot in "${SPEC[@]}"; do
    SLOT_COUNT[$slot]=$(( SLOT_COUNT[$slot] + 1 ))
    nid="${slot}${SLOT_COUNT[$slot]}"
    NODE_IDS+=("$nid")
    NODE_SLOT[$nid]="$slot"
    NODE_CTR[$nid]="fips-interop-$nid"
    NODE_IP[$nid]="${SUBNET_PREFIX}${index}"
    index=$(( index + 1 ))
done

# ── Topology: adjacency (full mesh by default, or explicit edges) ─────
#
# FIPS_INTEROP_EDGES, when set, is a space/comma list of `nid-nid` tokens
# describing UNDIRECTED edges (direct FMP peer links), e.g.
# "a1-b1 a1-c1 b1-a2". Empty ⇒ FULL MESH (every node peers with every
# other, the historical behavior). Edges are symmetric: `x-y` makes x and
# y each other's auto_connect peer.

declare -A ADJ                 # ADJ[nid] = space-separated neighbor ids
declare -A _ADJ_SEEN           # "x|y" dedup guard
TOPOLOGY="full-mesh"
EDGES_LIST=()

add_edge() {
    local x="$1" y="$2"
    if [ "$x" = "$y" ]; then
        echo "ERROR: self-edge '$x-$x' not allowed" >&2; exit 1
    fi
    if [ -z "${NODE_SLOT[$x]:-}" ]; then
        echo "ERROR: edge references unknown node '$x'" >&2; exit 1
    fi
    if [ -z "${NODE_SLOT[$y]:-}" ]; then
        echo "ERROR: edge references unknown node '$y'" >&2; exit 1
    fi
    if [ -z "${_ADJ_SEEN[$x|$y]:-}" ]; then
        ADJ[$x]="${ADJ[$x]:+${ADJ[$x]} }$y"; _ADJ_SEEN[$x|$y]=1
    fi
    if [ -z "${_ADJ_SEEN[$y|$x]:-}" ]; then
        ADJ[$y]="${ADJ[$y]:+${ADJ[$y]} }$x"; _ADJ_SEEN[$y|$x]=1
    fi
}

if [ -n "${FIPS_INTEROP_EDGES:-}" ]; then
    TOPOLOGY="custom"
    for tok in ${FIPS_INTEROP_EDGES//,/ }; do
        x="${tok%%-*}"; y="${tok#*-}"
        if [ "$x" = "$tok" ] || [ -z "$x" ] || [ -z "$y" ] || [[ "$y" == *-* ]]; then
            echo "ERROR: malformed edge token '$tok' (want nid-nid)" >&2; exit 1
        fi
        add_edge "$x" "$y"
        EDGES_LIST+=("$x-$y")
    done
    # Validate: no isolated node, graph connected (BFS from node 0).
    for nid in "${NODE_IDS[@]}"; do
        if [ -z "${ADJ[$nid]:-}" ]; then
            echo "ERROR: node '$nid' has no edges (isolated)" >&2; exit 1
        fi
    done
    declare -A _BFS_SEEN=()
    bfs_queue=("${NODE_IDS[0]}"); _BFS_SEEN[${NODE_IDS[0]}]=1; bfs_reached=1
    while [ "${#bfs_queue[@]}" -gt 0 ]; do
        cur="${bfs_queue[0]}"; bfs_queue=("${bfs_queue[@]:1}")
        for nb in ${ADJ[$cur]}; do
            if [ -z "${_BFS_SEEN[$nb]:-}" ]; then
                _BFS_SEEN[$nb]=1; bfs_reached=$(( bfs_reached + 1 ))
                bfs_queue+=("$nb")
            fi
        done
    done
    if [ "$bfs_reached" -ne "${#NODE_IDS[@]}" ]; then
        echo "ERROR: topology is disconnected ($bfs_reached/${#NODE_IDS[@]} reachable from ${NODE_IDS[0]})" >&2
        exit 1
    fi
else
    # Full mesh: every node adjacent to every other, in spec order.
    for a in "${NODE_IDS[@]}"; do
        for b in "${NODE_IDS[@]}"; do
            [ "$a" = "$b" ] && continue
            ADJ[$a]="${ADJ[$a]:+${ADJ[$a]} }$b"
        done
    done
fi

mkdir -p "$OUT_DIR"

# Clear stale per-node configs from a previous (differently-shaped) spec
# so generation is idempotent — a smaller spec must not leave orphans.
rm -f "$OUT_DIR"/*.yaml

# ── Phase 1: derive identities ───────────────────────────────────────
#
# Key the derivation by node id (a1, a2, ...) so two same-slot nodes get
# distinct identities.

declare -A NSEC NPUB
for nid in "${NODE_IDS[@]}"; do
    keys="$(python3 "$DERIVE_KEYS" "$MESH_NAME" "$nid")"
    NSEC[$nid]="$(echo "$keys" | sed -n 's/^nsec=//p')"
    NPUB[$nid]="$(echo "$keys" | sed -n 's/^npub=//p')"
done

# ── Phase 2: emit per-node configs ───────────────────────────────────

emit_peer_block() {
    # Args: peer node-id
    local pid="$1"
    cat <<EOF
  - npub: "${NPUB[$pid]}"
    alias: "$pid"
    addresses:
      - transport: udp
        addr: "${NODE_IP[$pid]}:$UDP_PORT"
    connect_policy: auto_connect
EOF
}

for nid in "${NODE_IDS[@]}"; do
    cfg="$OUT_DIR/$nid.yaml"
    {
        echo "# FIPS interop mesh — node $nid (slot ${NODE_SLOT[$nid]})"
        echo "# Identity: ${NPUB[$nid]}"
        echo "# Generated by testing/interop/generate-configs.sh"
        echo ""
        echo "node:"
        echo "  identity:"
        echo "    nsec: \"${NSEC[$nid]}\""
        echo "  rekey:"
        echo "    enabled: true"
        echo "    after_secs: $REKEY_AFTER_SECS"
        echo "    after_messages: 65536"
        echo ""
        echo "tun:"
        echo "  enabled: true"
        echo "  name: fips0"
        echo "  mtu: 1280"
        echo ""
        echo "dns:"
        echo "  enabled: true"
        echo ""
        echo "transports:"
        echo "  udp:"
        echo "    bind_addr: \"0.0.0.0:$UDP_PORT\""
        echo "    mtu: 1472"
        echo ""
        echo "peers:"
        for pid in ${ADJ[$nid]}; do
            emit_peer_block "$pid"
        done
    } > "$cfg"
    echo "  generated $cfg"
done

# ── Phase 3: emit the generated docker-compose file ──────────────────
#
# One service per node, image pinned to the node's slot. This supersedes
# the static docker-compose.yml.

COMPOSE_FILE="$OUT_DIR/docker-compose.generated.yml"
{
    echo "# Mixed-version interop harness: generated compose, one service per node."
    echo "#"
    echo "# Generated by testing/interop/generate-configs.sh — DO NOT EDIT."
    echo "# Node-spec: ${SPEC[*]}"
    echo "#"
    echo "# Each service is pinned to its node's image slot, so two nodes of the"
    echo "# same slot (e.g. a1, a2) run the SAME daemon build. Netem is applied"
    echo "# by the driver via 'docker exec ... tc qdisc' on each container eth0."
    echo ""
    echo "networks:"
    echo "  fips-interop-net:"
    echo "    driver: bridge"
    echo "    ipam:"
    echo "      config:"
    echo "        - subnet: 172.30.0.0/24"
    echo ""
    echo "x-fips-interop-common: &fips-interop-common"
    echo "  cap_add:"
    echo "    - NET_ADMIN"
    echo "  devices:"
    echo "    - /dev/net/tun:/dev/net/tun"
    echo "  sysctls:"
    echo "    - net.ipv6.conf.all.disable_ipv6=0"
    echo "  restart: \"no\""
    echo "  environment:"
    echo "    - RUST_LOG=info,fips::node::handlers::rekey=debug,fips::node::handlers::handshake=debug"
    echo ""
    echo "services:"
    for nid in "${NODE_IDS[@]}"; do
        slot="${NODE_SLOT[$nid]}"
        echo "  $nid:"
        echo "    <<: *fips-interop-common"
        echo "    image: fips-interop:$slot"
        echo "    container_name: ${NODE_CTR[$nid]}"
        echo "    hostname: $nid"
        echo "    volumes:"
        echo "      - $REPO_ROOT/testing/docker/resolv.conf:/etc/resolv.conf:ro"
        echo "      - ./$nid.yaml:/etc/fips/fips.yaml:ro"
        echo "    networks:"
        echo "      fips-interop-net:"
        echo "        ipv4_address: ${NODE_IP[$nid]}"
    done
} > "$COMPOSE_FILE"
echo "  generated $COMPOSE_FILE"

# ── Phase 4: nodes.env manifest ──────────────────────────────────────
#
# The driver sources this. NODE_IDS is the canonical ordered node list;
# the per-node maps are emitted as space-separated key:value pairs so the
# driver can rebuild associative arrays without re-deriving anything.

MANIFEST="$OUT_DIR/nodes.env"
{
    echo "# Generated by testing/interop/generate-configs.sh"
    echo "# Mesh name: $MESH_NAME"
    echo "# Node-spec: ${SPEC[*]}"
    echo "INTEROP_SPEC=\"${SPEC[*]}\""
    echo "INTEROP_NODE_IDS=\"${NODE_IDS[*]}\""
    echo "INTEROP_TOPOLOGY=\"$TOPOLOGY\""
    echo "INTEROP_TOPOLOGY_EDGES=\"${EDGES_LIST[*]:-}\""
    # Per-node expected DIRECT-peer count (adjacency degree). The driver
    # uses this for the per-node peer-convergence check instead of N-1.
    {
        printf 'INTEROP_NODE_DEGREE="'
        for nid in "${NODE_IDS[@]}"; do
            d=0
            for _nb in ${ADJ[$nid]}; do d=$(( d + 1 )); done
            printf '%s:%s ' "$nid" "$d"
        done
        printf '"\n'
    }
    # Per-node maps, one var each, space-separated 'nodeid:value' tokens.
    {
        printf 'INTEROP_NODE_SLOTS="'
        for nid in "${NODE_IDS[@]}"; do printf '%s:%s ' "$nid" "${NODE_SLOT[$nid]}"; done
        printf '"\n'
    }
    {
        printf 'INTEROP_NODE_CONTAINERS="'
        for nid in "${NODE_IDS[@]}"; do printf '%s:%s ' "$nid" "${NODE_CTR[$nid]}"; done
        printf '"\n'
    }
    {
        printf 'INTEROP_NODE_IPS="'
        for nid in "${NODE_IDS[@]}"; do printf '%s:%s ' "$nid" "${NODE_IP[$nid]}"; done
        printf '"\n'
    }
    {
        printf 'INTEROP_NODE_NPUBS="'
        for nid in "${NODE_IDS[@]}"; do printf '%s:%s ' "$nid" "${NPUB[$nid]}"; done
        printf '"\n'
    }
} > "$MANIFEST"
echo "  generated $MANIFEST"

# ── Phase 5: npubs.env — per-node npub map ───────────────────────────

ENV_FILE="$OUT_DIR/npubs.env"
{
    echo "# Generated by testing/interop/generate-configs.sh"
    echo "# Mesh name: $MESH_NAME"
    for nid in "${NODE_IDS[@]}"; do
        upper="$(echo "$nid" | tr '[:lower:]' '[:upper:]')"
        echo "NPUB_${upper}=${NPUB[$nid]}"
    done
} > "$ENV_FILE"
echo "  generated $ENV_FILE"

echo ""
echo "Mesh configs ready (spec='${SPEC[*]}', topology=$TOPOLOGY, rekey.after_secs=$REKEY_AFTER_SECS):"
for nid in "${NODE_IDS[@]}"; do
    d=0; for _nb in ${ADJ[$nid]}; do d=$(( d + 1 )); done
    echo "  $nid  slot ${NODE_SLOT[$nid]}  ${NODE_IP[$nid]}  deg=$d  ${NPUB[$nid]}"
done
if [ "$TOPOLOGY" = "custom" ]; then
    echo "  edges: ${EDGES_LIST[*]}"
fi
