#!/usr/bin/python3

# Copyright (c) 2025, Thomas Goirand <zigo@debian.org>
#           (c) 2025, Siméon Gourlin <simeon.gourlin@infomaniak.com>
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#    http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
# implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# Note:
# Portions of the logic and code structure for this file were developed
# with the assistance of Qwen, a large language model by Tongyi Lab.
# Under its usage therm, this doesn't affect copyright ownership.

import eventlet

eventlet.monkey_patch()

import ipaddress
from ipaddress import ip_network, ip_address
import logging as pylogging
import os
import signal
import socket
import sys
import threading
from threading import Lock
import time

from keystoneauth1 import loading as ks_loading
from openstack import connection
#from openstack.network.v2 import bgp_speaker
from os_ken.services.protocols.bgp.bgpspeaker import BGPSpeaker
from oslo_config import cfg
from oslo_log import log as logging
import oslo_messaging
from oslo_messaging import rpc, opts as messaging_opts


LOG = logging.getLogger(__name__,project='neutron-ipv6-bgp-injector')

sync_lock = Lock()

# Define a function to generate the default queue name based on hostname.
def _get_sanitized_hostname(hostname=None):
    if hostname is None:
        hostname = socket.getfqdn()

    # Sanitize hostname to be suitable for AMQP queue names if needed
    # Basic sanitation: replace non-alphanumeric chars (except hyphen/underscore/dot) with hyphens
    sanitized_hostname = ''.join(c if c.isalnum() or c in '-_.' else '-' for c in hostname)
    # Limit length if necessary (AMQP has limits, e.g., 255 chars)
    return sanitized_hostname[:200] # Conservative limit


common_opts = [
    cfg.ListOpt('subnet_list',
        default=['internal-v6-subnet1'],
        help='List of subnets to advertize ports from.'),
    cfg.ListOpt('bgp_peer_list',
        default=[],
        sample_default='123@10.0.1.1, 456@10.0.1.2',
        help='List of BGP peers to connect to, in the form ASN@IP.'),
    cfg.IntOpt('bgp_peer_reconnect_interval',
        default=15,
        help='Time in seconds between BGP peer re-connection attempts'),
    cfg.StrOpt('bgp_our_router_id',
        default='10.10.10.11',
        help='Our router ID. Usually matches our IP address'),
    cfg.IntOpt('bgp_our_as',
        default=64914,
        help='Our ASN'),
    cfg.StrOpt('bgp_speaker_id',
        default=None,
        sample_default='3a2c9f6a-8afa-4b33-8ef8-9f8dd4d4fc5b',
        help='UUID of a BGP speaker doing IPv6 advertizing. '
             'Currently unused, as nibi can now find the next '
             'HOP of subnets without using BGP speakers.'),
    cfg.StrOpt('neutron_notifications_exchange_name',
        default='notifications',
        help='Name of the exchange where to recieve notifications from Neutron.'),
    cfg.StrOpt('neutron_notifications_topic',
        default='#',
        help='Name of the topics to watch to. "#" means all topics.'),
    cfg.IntOpt('periodic_sync_interval',
        default=300,
        help='Time in seconds between full Neutron re-syncs '
             '(default: 300).'),
    cfg.StrOpt('my_hostname',
        default=None,
        sample_default='compute1.example.com',
        help='FQDN of this NIBI instance. Used to derive the internal RabbitMQ queue name. '
             'Must be unique across the NIBI cluster. If None, the server FQDN is used.'),
    cfg.ListOpt('other_nibi_hostnames',
        default=[],
        sample_default='compute2.example.com,compute3.example.com',
        help='List of FQDNs for other NIBI instances in the cluster. '
             'Used to derive the internal RabbitMQ queue names for message forwarding. '
             'Should not include this instance\'s hostname.'),
]

def list_opts():
    """Return options to be picked up by oslo-config-generator."""
    auth_opts = ks_loading.get_auth_common_conf_options()
    session_opts = ks_loading.get_session_conf_options()
    adapter_opts = ks_loading.get_adapter_conf_options()

    neutron_opts = []
    for name in ks_loading.get_available_plugin_names():
        neutron_opts.extend(ks_loading.get_auth_plugin_conf_options(name))

    return [
        ("DEFAULT", common_opts),
        ("neutron", auth_opts + neutron_opts + session_opts + adapter_opts),
    ]

def sd_notify(state: str):
    """Send a notification to systemd."""
    notify_socket = os.getenv("NOTIFY_SOCKET")
    if not notify_socket:
        return False

    # Abstract namespace socket if starts with '@'
    if notify_socket[0] == '@':
        notify_socket = '\0' + notify_socket[1:]

    sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
    try:
        sock.connect(notify_socket)
        sock.sendall(state.encode())
    finally:
        sock.close()
    return True

def get_next_hop_for_subnet(conn, bgp_speaker_id, subnet_id):
    subnet = conn.network.get_subnet(subnet_id)
    cidr = subnet.cidr

    # We do this query like this because OpenStackSDK does
    # not have this facility in Victoria.
    url = f"{conn.network.get_endpoint()}/bgp-speakers/{bgp_speaker_id}/get_advertised_routes"
    resp = conn.session.get(url)
    routes = resp.json().get("advertised_routes", [])

    for route in routes:
        if route.get("destination") == cidr:
            return route.get("next_hop")

    return None


def get_reserved_ipv6_for_subnet(conn, subnet_id):
    """
    Returns a set of IPv6 addresses that should NOT be advertised for a given subnet:
    - Router gateway IPv6s
    - DHCP server IPv6s
    """
    reserved_ipv6 = set()

    # Router port(s) (default gateway)
    LOG.info(f"🔍 Searching for reserved IPs for subnet {subnet_id}.")
    router_ports = list(conn.network.ports(
        device_owner="network:ha_router_replicated_interface",
        fixed_ips=f"subnet_id={subnet_id}"
    ))
    for port in router_ports:
        for ip_info in port.fixed_ips:
            ip = ip_info.get("ip_address")
            if ip and ipaddress.ip_address(ip).version == 6:
                LOG.info(f"↪ Found {ip} as ha_router: will not advertize it.")
                reserved_ipv6.add(ip)

    # DHCP ports
    dhcp_ports = list(conn.network.ports(
        device_owner="network:dhcp",
        fixed_ips=f"subnet_id={subnet_id}"
    ))
    for port in dhcp_ports:
        for ip_info in port.fixed_ips:
            ip = ip_info.get("ip_address")
            if ip and ipaddress.ip_address(ip).version == 6:
                LOG.info(f"↪ Found {ip} as DHCP server: will not advertize it.")
                reserved_ipv6.add(ip)

    return reserved_ipv6



def get_router_gateway_ip_from_subnet(conn, subnet_id):
    try:
        # Equivalent of: router_port_id = openstack port list --device-owner 'network:ha_router_replicated_interface' --fixed-ip subnet=bdb1-vps-net1-subnet2 --format value -c ID
        LOG.info(f"🔍 Searching router ports for subnet {subnet_id}: openstack port list --device-owner 'network:ha_router_replicated_interface' --fixed-ip subnet={subnet_id} --format value -c ID...")

        router_ports = list(conn.network.ports(
            device_owner="network:ha_router_replicated_interface",
            fixed_ips=f"subnet_id={subnet_id}"
        ))

        if not router_ports:
            LOG.error(f"⧱ No router port found for subnet {subnet_id}: exiting get_router_gateway_ip_from_subnet() for subnet {subnet_id}")
            return None

        if len(router_ports) > 1:
            LOG.warning(f"⚠ Found multiple ports for the router of {subnet_id}:")
            LOG.warning(router_ports)
            LOG.warning(f"⚠ Will try to use the first one only")

        router_port = router_ports[0]
        LOG.info(f"↪ Found router port ID: {router_port.id}.")

        # Equivalent of: router_id = openstack port show --format value -c device_id $router_port_id
        LOG.info(f"🔍 Searching router ID for port: openstack port show --format value -c device_id {router_port.id}.")
        port = conn.network.get_port(router_port.id)
        if not port or not port.device_id:
            LOG.error(f"⧱ No router_id found for port {router_port.id}: exiting get_router_gateway_ip_from_subnet() for subnet {subnet_id}")
            return None

        router_id = port.device_id
        LOG.info(f"↪ Found router ID: {router_id}.")

        # Equivalent of: openstack router show $router_id --format json -c external_gateway_info | jq '.external_gateway_info.external_fixed_ips[].ip_address' -r | grep 2001
        LOG.info(f"🔍 Searching for external gateway info for router: openstack router show {router_id}.")
        router = conn.network.get_router(router_id)
        if not router or not router.external_gateway_info:
            LOG.error(f"⧱ No external gateway info found for router {router_id}: exiting get_router_gateway_ip_from_subnet() for subnet {subnet_id}")
            return None

        LOG.info(f"🔍 Searching IPv6 in the external_gw_info of router {router_id} (router: {router.name})")
        for ip_info in router.external_gateway_info.get("external_fixed_ips", []):
            ip = ip_info.get("ip_address")
            if ip and ipaddress.ip_address(ip).version == 6:
                return ip

        LOG.error(f"⧱ No IPv6 external gateway IP found for router {router_id}: exiting get_router_gateway_ip_from_subnet() for subnet {subnet_id}")
        return None

    except ResourceNotFound as e:
        LOG.error(f"⧱ Resource not found: {e}")
        return None
    except SDKException as e:
        LOG.error(f"⧱ OpenStack SDK error: {e}")
        return None
    except Exception as e:
        LOG.error(f"⧱ Unexpected error: {e}")
        return None

# ----------------------------------------------------------
# Query all currently reserved IPv6 of the subnet_list conf.
# ----------------------------------------------------------
def query_neutron_ipv6_next_hop(conf: cfg.ConfigOpts):
    # Load auth/session/adapter from [neutron] group
    auth    = ks_loading.load_auth_from_conf_options(conf, "neutron")
    sess    = ks_loading.load_session_from_conf_options(conf, "neutron", auth=auth)
    adapter = ks_loading.load_adapter_from_conf_options(conf, "neutron", session=sess, auth=auth)

    # Build OpenStackSDK Connection (so we can use network API)
    conn = connection.Connection(
        session=sess,
        region_name=adapter.region_name,
        interface=adapter.interface,
    )

    #########################################################
    ### Below, we're going to build these dict and arrays ###
    #########################################################
    # This dict helps showing the subnet names in the logs instead of ugly subnet_id
    subnet_id_to_name_dict = {}
    # Map subnet names from conf to subnet IDs
    subnet_name_to_id_dict = {}
    # and subnet_id to their next HOP
    subnet_id_to_nexthop_dict = {}
    # List all ports that needs IPv6 advertizing.
    # This is temporary to this function only.
    ipv6_ports = []
    # All ports and their associated IPv6
    # Note: currently, this code only supports a single IPv6 per port.
    # This is the dict we're aiming to maintain during the lifetime of this daemon.
    port_id_to_ipv6_dict = {}
    # All IPv6 we're interested (ie: these in the subnet_list in our
    # configuration file) in and their next HOP, so we can do their BGP announce.
    ipv6_to_next_hop_dict = {}
    # Build a map: subnet_id -> reserved IPv6s
    subnet_id_to_reserved_ips = {}
    
    try:
        # We iterate on all configure subnet of the subnet_list
        # configured in our configuration file. Doing so, we
        # build the subnet_id_to_nexthop_dict dict, and we also
        # convert subnet names into IDs.
        subnet_names = conf.subnet_list
        for subnet in conn.network.subnets():
            if subnet.name in subnet_names:
                LOG.info(f"🔍 Looking-up subnet: {subnet.name}.")
                subnet_name_to_id_dict[subnet.name] = subnet.id
                subnet_id_to_name_dict[subnet.id] = subnet.name
                # Find the next HOP for the subnet.
                # This done like this:
                # router_port_id = openstack port list --device-owner 'network:ha_router_replicated_interface' --fixed-ip subnet=bdb1-vps-net1-subnet2 --format value -c ID
                # router_id = openstack port show --format value -c device_id $router_port_id
                # openstack router show $router_id --format json -c external_gateway_info | jq '.external_gateway_info.external_fixed_ips[].ip_address' -r | grep 2001
                LOG.info(f"🔍 Searching for router gateway IP for subnet: {subnet.id}: calling get_router_gateway_ip_from_subnet().")
                next_hop_for_subnet = get_router_gateway_ip_from_subnet(conn, subnet.id)

                # This other version asks neutron-dynamic-routing what's the next HOP for a subnet_id instead:
                #next_hop_for_subnet = get_next_hop_for_subnet(conn, conf.bgp_speaker_id, subnet.id)
                if next_hop_for_subnet == None:
                    LOG.error(f"⧱ Could not find next HOP for subnet: {subnet.id} (neutron replied None)")
                    LOG.error(f"⧱ Please make sure that this subnet is connected to a router configured with an external gateway.")
                    LOG.error(f"⧱ Will exist then...")
                    exit(1)
                else:
                    LOG.info(f"↪ Found next HOP for {subnet.name}: {next_hop_for_subnet}")
                    subnet_id_to_nexthop_dict[subnet.id] = next_hop_for_subnet

        if not subnet_name_to_id_dict:
            LOG.error(f"⧱ No matching subnets found for names: {subnet_names}")
            return {}, {}, {}, {}

    except Exception as e:
        import traceback
        traceback.print_exc()
        LOG.error(f"⧱ Error while fetching next HOP for subnets: {e}")

    try:
        # Add all ports of this subnet_id to the
        # ipv6_ports array.
        for subnet_id in subnet_name_to_id_dict.values():
            LOG.info(f"🔍 Fetching all ports for subnet {subnet_id}.")
            for port in conn.network.ports(fixed_ips=f"subnet_id={subnet_id}"):
                ipv6_ports.append(port)
    except Exception as e:
        import traceback
        traceback.print_exc()
        LOG.error(f"⧱ Error while fetching all ports for all configured subnets: {e}")

    try:
        # For each port, only add IPv6s if not reserved
        # Build a map: subnet_id -> reserved IPv6s
        for subnet_id in subnet_name_to_id_dict.values():
            subnet_id_to_reserved_ips[subnet_id] = get_reserved_ipv6_for_subnet(conn, subnet_id)

        # For all the ports collected above, add all of
        # their (eventual) IPv6 to the ipv6_to_next_hop_dict.
        LOG.info(f"🔍 Extracting all IPv6 from port list.")
        for port in ipv6_ports:
            for ip_info in port.fixed_ips:
                ipv6_addr = ip_info["ip_address"]
                ipv6_subnet = ip_info["subnet_id"]
                if ipaddress.ip_address(ipv6_addr).version == 6 and ipv6_addr not in subnet_id_to_reserved_ips.get(ipv6_subnet, set()):
                    if ipv6_subnet in subnet_id_to_nexthop_dict:
                        # Note: ipv6_to_next_hop_dict is for initial sync, not used in PortEventEndpoint
                        ipv6_to_next_hop_dict[ipv6_addr] = subnet_id_to_nexthop_dict[ipv6_subnet]
                        port_id_to_ipv6_dict.setdefault(port.id, set()).add(ipv6_addr)
                        subnet_name_for_log = subnet_id_to_name_dict.get(ipv6_subnet, "unknown-subnet-name")
                        LOG.info(f"↪ Found port: {port.id} IPv6 addresses: {ipv6_addr}, subnet: {subnet_name_for_log} ({ipv6_subnet})")
    except Exception as e:
        import traceback
        traceback.print_exc()
        LOG.error(f"⧱ Error while parsing ip_info of all ports: {e}")

    return ipv6_to_next_hop_dict, subnet_id_to_nexthop_dict, port_id_to_ipv6_dict, subnet_id_to_name_dict


def periodic_sync_worker(conf, bgp_manager, subnet_id_to_nexthop_dict,
                         port_id_to_ipv6_dict, stop_event):
    """
    Periodically re-sync with Neutron to ensure all IPv6 routes are injected,
    including ports that gained/lost IPv6 addresses.
    """
    while not stop_event.is_set():
        time.sleep(conf.periodic_sync_interval)
        LOG.info("⚙ Periodic sync: refreshing Neutron ports...")

        try:
            with sync_lock:
                ipv6_to_next_hop_dict, _, new_port_map, subnet_id_to_name_dict = query_neutron_ipv6_next_hop(conf)
                # NOTE: make query_neutron_ipv6_next_hop() return:
                #   port_id -> set([ipv6a, ipv6b,...])
                # instead of single string.

                # Withdraw missing or changed addresses
                for port_id, old_ips in list(port_id_to_ipv6_dict.items()):
                    current_ips = new_port_map.get(port_id, set())
                    for ip in old_ips - current_ips:
                        LOG.info(f"⚙ Periodic sync: withdrawing {ip}")
                        bgp_manager.withdraw_route(ip + "/128")
                    if not current_ips:
                        del port_id_to_ipv6_dict[port_id]
                    else:
                        port_id_to_ipv6_dict[port_id] = current_ips

                # Inject newly added addresses
                for port_id, new_ips in new_port_map.items():
                    old_ips = port_id_to_ipv6_dict.get(port_id, set())
                    for ip in new_ips - old_ips:
                        nh = ipv6_to_next_hop_dict.get(ip)
                        if nh:
                            LOG.info(f"⚙ Periodic sync: injecting {ip}")
                            bgp_manager.inject_route(ip + "/128", nh)
                    port_id_to_ipv6_dict[port_id] = new_ips
        except Exception as e:
            LOG.warning(f"⚠ Periodic sync failed: {e}")

        LOG.info("⚙ Periodic sync is done.")


class BGPSpeakerManager:

    # This __init__ does:
    # * create a new BGPSpeaker object.
    # * parse the bgp_peer_list dict from config file.
    # * add all BGP peers listed in bgp_peer_list to the BGPSpeaker.
    def __init__(self, conf: cfg.ConfigOpts):
        self.conf = conf
        self.speaker = BGPSpeaker(
            as_number=conf.bgp_our_as,
            router_id=conf.bgp_our_router_id,
            bgp_server_port=0, # bgp_server_port=0 => listener disabled
        )
        self.injected_routes = {}  # to follow injected routes
        self.peers = []

        # --- robust parsing of bgp_peer_list ---
        # accept both comma-separated string or list from config
        raw_peers = conf.bgp_peer_list
        if isinstance(raw_peers, list):
            # join into one string, just in case
            raw_peers = ','.join(raw_peers)

        # split on commas, strip whitespace, ignore empty
        for entry in (e.strip() for e in raw_peers.split(',') if e.strip()):
            # entry must contain exactly one '@'
            if '@' not in entry:
                LOG.warning(f"⚠ Ignoring invalid bgp_peer_list entry: '{entry}' (missing '@')")
                continue
            asn_str, ip = entry.split('@', 1)
            try:
                remote_as = int(asn_str)
            except ValueError:
                LOG.warning(f"⚠ Ignoring invalid ASN '{asn_str}' in bgp_peer_list entry '{entry}'")
                continue

            self.speaker.neighbor_add(
                address=ip,
                remote_as=remote_as,
                enable_ipv6=True,
                connect_mode='active',
                local_address=self.conf.bgp_our_router_id,
            )
            self.peers.append((ip, remote_as))
            LOG.info(f"⚙ Peer added : {ip} (AS: {remote_as})")

        # Initialize the stop event for reconnect thread
        self._stop_reconnect = threading.Event()

        # Start the background reconnect thread
        self._reconnect_thread = threading.Thread(target=self._reconnect_loop, daemon=True)
        self._reconnect_thread.start()

        # Session startup delay
        time.sleep(5)

    def _send_route_to_all_peers(self, prefix, next_hop, neighbor=None):
        try:
            if neighbor:
                self.speaker.prefix_add(prefix=prefix, next_hop=next_hop, neighbor=neighbor)
            else:
                self.speaker.prefix_add(prefix=prefix, next_hop=next_hop)
        except Exception as e:
            target = neighbor if neighbor else "all peers"
            LOG.warning(f"⚠ Failed to inject {prefix} via {next_hop} to {target}: {e}")

    def _reconnect_loop(self):
        while not self._stop_reconnect.is_set():
            for ip, remote_as in self.peers:
                try:
                    neighbor_info = self.speaker.neighbor_state_get(address=ip, format='json')
                    if isinstance(neighbor_info, dict):
                        state = neighbor_info.get('bgp_state')
                    else:
                        state = str(neighbor_info)
                    if state in ('Idle', 'Connect', None):
                        LOG.warning(f"⚠ Neighbor {ip} is {state}, attempting reconnect")
                        self.speaker.neighbor_del(address=ip)
                        time.sleep(5)  # small pause
                        self.speaker.neighbor_add(
                            address=ip,
                            remote_as=remote_as,
                            enable_ipv6=True,
                            connect_mode='active',
                            local_address=self.conf.bgp_our_router_id,
                        )
                        LOG.info(f"⚙ Re-added neighbor {ip}")
                        # Re-inject all routes for this neighbor
                        for prefix, next_hop in self.injected_routes.items():
                            self._send_route_to_all_peers(prefix, next_hop, neighbor=ip)
                            LOG.info(f"⚙ Re-injected {prefix} via {next_hop} to {ip}")
                except Exception as e:
                    LOG.warning(f"⚠ Error checking/reconnecting neighbor {ip}: {e}")
            time.sleep(self.conf.bgp_peer_reconnect_interval)

#    def list_neighbors(self, format='json'):
#        """List all BGP neighbors"""
#        all_neighbors = {}
#        for ip in self.peers:
#            try:
#                neighbor_info = self.speaker.neighbor_state_get(address=ip, format=format)
#                all_neighbors[ip] = neighbor_info
#            except Exception as e:
#                LOG.warning(f"⚠ Failed to get neighbor state for {ip}: {e}")
#        return all_neighbors
#
    def inject_route(self, prefix, next_hop):
        """Inject a BGP route"""
        route = (prefix, next_hop)
        if prefix in self.injected_routes:
            LOG.warning(f"⚠ Route already injected: {prefix} via {next_hop}")
            return

        self.injected_routes[prefix] = next_hop
        self._send_route_to_all_peers(prefix, next_hop)
        LOG.info(f"✔ Route injected: {prefix} (next HOP: {next_hop})")

#    def list_routes(self):
#        """Show injected routes"""
#        if not self.injected_routes:
#            LOG.info("No route injected.")
#            return []
#
#        LOG.info("📋 BGP routes injected:")
#        for prefix, next_hop in self.injected_routes.items():
#            LOG.info(f"  - {prefix} via {next_hop}")
#        return list(self.injected_routes.items())
#
    def withdraw_route(self, prefix):
        """Withdraw a BGP route from all peers"""
        if prefix not in self.injected_routes:
            LOG.warning(f"⚠ Route not found (not injected locally) : {prefix}")
            return

        next_hop = self.injected_routes[prefix]
        LOG.debug(f"🐛 Withdrawing route {prefix} via {next_hop} from all peers")
        self.speaker.prefix_del(prefix=prefix)
        del self.injected_routes[prefix]
        LOG.info(f"𐄂 Route removed: {prefix}")

    def shutdown(self):
        """Stop background reconnect thread and shut down BGP speaker"""
        self._stop_reconnect.set()
        if self._reconnect_thread.is_alive():
            # join() by itself does not stop the thread. It only blocks the calling
            # thread (the main thread) until the target thread finishes.
            self._reconnect_thread.join()
        """BGP speaker shutdown"""
        LOG.info("⚙ BGP Speaker shutdown...")
        self.speaker.shutdown()


# ----------------------------------------------------
# Get notified on new IPv6 from RabbitMQ notifications
# ----------------------------------------------------
class PortEventEndpoint(object):
    """Handler for port events from Neutron."""

    def __init__(self, conf, bgp_manager, subnet_id_to_nexthop_dict, port_id_to_ipv6_dict, subnet_id_to_name_dict, transport, peer_internal_queue_names, our_unique_fwd_topic):
        self.conf = conf
        self.bgp_manager = bgp_manager
        self.subnet_id_to_nexthop_dict = subnet_id_to_nexthop_dict
        self.port_id_to_ipv6_dict = port_id_to_ipv6_dict
        self.subnet_id_to_name_dict = subnet_id_to_name_dict
        self.ip_to_subnet_id = {} # {ip_address: subnet_id}
        # Components for message forwarding
        self.transport = transport
        self.hostname = socket.getfqdn()
        self.publisher_id = f"nibi.{self.hostname}"
        self.peer_internal_queue_names = peer_internal_queue_names
        self.our_unique_fwd_topic = our_unique_fwd_topic # Store our unique topic for sending

    def info(self, ctxt, publisher_id, event_type, payload, metadata):
        LOG.debug(f"🐛 Got event {event_type} from {publisher_id}, with payload: {payload}")

        # Check if this is a forwarded message
        is_forwarded = payload.get('_nibi_forwarded', False)
        originating_host = payload.get('_nibi_originating_host')

        # Prevent processing our own forwarded messages
        if is_forwarded and originating_host == self.hostname:
            LOG.warning(f"⚠ Ignoring forwarded message from ourselves (host: {originating_host})")
            return

        # Only care about port create/update/delete events
        if event_type not in ("port.create.end", "port.update.end", "port.delete.end"):
            return

        port = payload.get("port")
        if not port:
            return

        # Process the event (either local or forwarded)
        # Pass the 'is_forwarded' flag to modify logging behavior
        self._process_port_event(event_type, port, is_forwarded)

        # After successfully processing a local Neutron event, forward it
        if not is_forwarded and self.transport and self.conf.other_nibi_hostnames:
            self.forward_notification(event_type, payload)


    def _process_port_event(self, event_type, port, is_forwarded):
        """Process the core logic for port events, local or forwarded."""
        log_prefix = "🔁 Received (forwarded) notification of" if is_forwarded else "🐰 Received (Neutron) notification of"

        # Do a sync_lock, so that we don't conflict with the periodic worker.
        with sync_lock:
            port_id = port.get("id")
            fixed_ips = port.get("fixed_ips", [])

            ### Handle port.delete.end ###
            if event_type == "port.delete.end":
                # The port is gone, withdraw all IPv6 routes previously associated with it.
                # Use subnet_id from the 'fixed_ips' data in the delete event payload.
                if port_id in self.port_id_to_ipv6_dict:
                    ips_to_withdraw = list(self.port_id_to_ipv6_dict[port_id]) # Get IPs before deleting the key
                    # Iterate through fixed_ips from the event payload to get subnet_id for logging
                    for ip_info in fixed_ips:
                        address = ip_info.get("ip_address")
                        subnet_id = ip_info.get("subnet_id")

                        # Process only if it's an IPv6 we were tracking for this port
                        if address and ipaddress.ip_address(address).version == 6 and address in ips_to_withdraw:
                            LOG.info(f"{log_prefix} Port delete: Port: {port_id}, IPv6 addresses: {address}, Subnet: {subnet_id}.")
                            # Update internal IP mapping (not strictly necessary for delete, but consistent)
                            self.ip_to_subnet_id[address] = subnet_id
                            self.bgp_manager.withdraw_route(address + "/128")
                            # Note: We don't need to modify self.port_id_to_ipv6_dict here anymore
                            # as we delete the whole entry for the port afterwards.

                    # Regardless of whether we found the exact ip_info in the event payload,
                    # clear all tracked IPs for this port as the port itself is deleted.
                    # The payload might not always contain the full previous state.
                    del self.port_id_to_ipv6_dict[port_id] # Delete the port's entry entirely
                    # Also clear IP mapping for this port
                    for ip in ips_to_withdraw: self.ip_to_subnet_id.pop(ip, None) # Safely remove IPs

            ### Handle port.update.end ###
            # Note: it should be possible to receive such event with removal and addition
            # of IPv6 at the same time in a single operation. The current code handles it.
            elif event_type == "port.update.end":
                # Make temp removed_ips and added_ips dicts to be used below.
                old_ips = self.port_id_to_ipv6_dict.get(port_id, set())
                new_ips = {
                    ip_info.get("ip_address")
                    for ip_info in fixed_ips
                    if ipaddress.ip_address(ip_info.get("ip_address", "")).version == 6
                }
                removed_ips = old_ips - new_ips
                added_ips = new_ips - old_ips

                # Needed for subnet name logging and ip_to_subnet_id
                current_ips_with_subnet = {} # Temporary dict for IPs in this event
                for ip_info in fixed_ips:
                    addr = ip_info.get("ip_address")
                    sid = ip_info.get("subnet_id")
                    if addr and ipaddress.ip_address(addr).version == 6:
                        current_ips_with_subnet[addr] = sid # Map IP to its subnet for this event
                        self.ip_to_subnet_id[addr] = sid    # Update persistent mapping

                actual_new_ipv6_set = set(current_ips_with_subnet.keys())
                removed_ips = old_ips - actual_new_ipv6_set
                added_ips = actual_new_ipv6_set - old_ips

                ### Handle IP removals ###
                # Withdraw routes for removed IPs
                for ip in removed_ips:
                    LOG.debug(f"🐛 REMOVING_IP: Checking {ip}")
                    raw_stored_value = self.ip_to_subnet_id.get(ip)
                    LOG.debug(f"🐛 REMOVING_IP: Raw stored value for {ip} is '{raw_stored_value}' (type: {type(raw_stored_value)})")

                    # Defensive retrieval handling potential None
                    subnet_id_for_log = raw_stored_value if raw_stored_value is not None else "unknown-subnet-id"
                    subnet_name_for_log = self.subnet_id_to_name_dict.get(subnet_id_for_log, "unknown-subnet-name")
                    LOG.info(f"{log_prefix} Port update (removal of IPv6): withdrawing IPv6 {ip} for Port: {port_id}, Subnet: {subnet_name_for_log} ({subnet_id_for_log})")
                    self.bgp_manager.withdraw_route(ip + "/128")

                    # Remove from tracking dict and internal IP map
                    self.port_id_to_ipv6_dict[port_id].discard(ip)
                    self.ip_to_subnet_id.pop(ip, None) # Remove mapping for withdrawn IP

                # Clean up port entry if no IPs remain
                if port_id in self.port_id_to_ipv6_dict and not self.port_id_to_ipv6_dict[port_id]:
                    del self.port_id_to_ipv6_dict[port_id]

                ### Handle IP additions ###
                for ip_info in fixed_ips:
                    address = ip_info.get("ip_address")
                    subnet_id = ip_info.get("subnet_id")

                    # Filter for subnets we manage (using ID)
                    if subnet_id not in self.subnet_id_to_nexthop_dict:
                        continue

                    if address and ipaddress.ip_address(address).version == 6 and address in added_ips:
                        next_hop = self.subnet_id_to_nexthop_dict[subnet_id]
                        # Lookup subnet name for logging
                        subnet_name_for_log = self.subnet_id_to_name_dict.get(subnet_id, "unknown-subnet-name")
                        LOG.info(f"{log_prefix} Port update (addition of IPv6): injecting IPv6 {address} for Port: {port_id}, Subnet: {subnet_name_for_log} ({subnet_id})")
                        self.bgp_manager.inject_route(address + '/128', next_hop)
                        # Add to tracking dict (setdefault handles case where key was deleted due to 0 IPs)
                        self.port_id_to_ipv6_dict.setdefault(port_id, set()).add(address)
                        # Note: self.ip_to_subnet_id already updated at start of this block

            ### Handle port.create.end ###
            elif event_type == "port.create.end":
                # Inject routes for all new IPv6 addresses on the port
                for ip_info in fixed_ips:
                    address = ip_info.get("ip_address")
                    subnet_id = ip_info.get("subnet_id")

                    # Filter for subnets we manage (using ID, not name)
                    if subnet_id not in self.subnet_id_to_nexthop_dict:
                        continue

                    if address and ipaddress.ip_address(address).version == 6:
                         next_hop = self.subnet_id_to_nexthop_dict[subnet_id]
                         # Update internal IP mapping for consistency
                         self.ip_to_subnet_id[address] = subnet_id
                         subnet_name_for_log = self.subnet_id_to_name_dict.get(subnet_id, "unknown-subnet-name")
                         LOG.info(f"{log_prefix} Port create: Port: {port_id}, IPv6 address: {address}, Subnet: {subnet_name_for_log} ({subnet_id})")
                         self.bgp_manager.inject_route(address + '/128', next_hop)
                         # Add to tracking dict
                         self.port_id_to_ipv6_dict.setdefault(port_id, set()).add(address)


    def forward_notification(self, event_type, payload):
        """Forward a processed notification to peer NIBI instances."""
        if not self.transport or not self.conf.other_nibi_hostnames:
            LOG.debug("Forwarding: No transport or peers configured.")
            return

        try:
            # Add originating host to payload for loop prevention on receipt
            enriched_payload = payload.copy()
            enriched_payload['_nibi_forwarded'] = True
            enriched_payload['_nibi_originating_host'] = self.hostname

            # Send the message to each peer's unique forwarding topic
            for peer_fqdn in self.conf.other_nibi_hostnames:
                peer_sanitized_hostname = _get_sanitized_hostname(peer_fqdn)
                # Derive the target topic for this specific peer
                peer_target_topic = f"nibi_fwd_to_{peer_sanitized_hostname}"

                # Prevent sending to our own forwarding topic
                if peer_target_topic == self.our_unique_fwd_topic:
                     LOG.debug(f"🐛 Preventing self-send to fwd topic {peer_target_topic}")
                     continue

                LOG.info(f"🠊 Forwarding {event_type} to peer topic: {peer_target_topic} (derived from {peer_fqdn})")

                # Create a new notifier specifically for this peer's topic.
                # Passing topics=[peer_target_topic] configures this notifier
                # to publish to that specific topic.
                peer_notifier = oslo_messaging.Notifier(
                    self.transport,
                    publisher_id=self.publisher_id,
                    topics=[peer_target_topic] # List of topics to publish to
                )
                # A standard info call on this notifier will go to the peer's topic.
                peer_notifier.info(ctxt={}, event_type=event_type, payload=enriched_payload)

        except Exception as e:
            LOG.error(f"⧱ Failed to forward notification: {e}", exc_info=True)


def run_inject0r():
    #####################################################################
    ### /etc/neutron-ipv6-bgp-injector/neutron-ipv6-bgp-injector.conf ###
    #####################################################################
    # Create the conf object.
    conf = cfg.ConfigOpts()

    # Register "normal" OPTs from common_opts.
    conf.register_opts(common_opts)

    # Load oslo messaging opts.
    for group, opts in messaging_opts.list_opts():
        conf.register_opts(opts, group=group)
#    oslo_messaging.set_transport_defaults(conf)

    # Register the [neutron] section stuff.
    ks_loading.register_session_conf_options(conf, "neutron")
    ks_loading.register_auth_conf_options(conf, "neutron")
    ks_loading.register_adapter_conf_options(conf, "neutron")

    # Register logs opts.
    logging.register_options(conf)
 
    # Do the actual loading of the configuration
    conf(args=sys.argv[1:], project='neutron-ipv6-bgp-injector')
    logging.setup(conf, conf.project)

    LOG.info(f"⚙ Neutron IPv6 BGP Injector is starting...")

    # Silence the BGPSpeaker logging a little bit.
    if not conf.debug:
        import logging as pylogging
        pylogging.getLogger("bgpspeaker.api.base").setLevel(pylogging.WARNING)

    ##################################################
    ### Get a list of all IPs of subnets listed in ###
    ##################################################
    # neutron-ipv6-bgp-injector.conf [DEFAULT]/subnet_list
    LOG.info("⚙ Fetching all IPv6 next HOP of configured subnets...")
    ipv6_to_next_hop_dict, subnet_id_to_nexthop_dict, port_id_to_ipv6_dict, subnet_id_to_name_dict = query_neutron_ipv6_next_hop(conf)

    #####################################
    ### BGP advertizer initialization ###
    #####################################
    LOG.info("⚙ Preparing BGP advertizer...")
    bgp_manager = BGPSpeakerManager(conf=conf)

    for ip6, next_hop in ipv6_to_next_hop_dict.items():
        bgp_manager.inject_route(prefix = ip6 + '/128', next_hop=next_hop)

    ######################################################
    ### RabbitMQ notifications for new / deleted ports ###
    ######################################################
    # Sanitize our hostname
    our_sanitized_hostname = _get_sanitized_hostname(conf.my_hostname)
    notif_queue = f"nibi-notif-{our_sanitized_hostname}"
    # Compute our unique topic for receiving forwarded messages
    our_unique_fwd_topic = f"nibi_fwd_to_{our_sanitized_hostname}"

    # Compute other hosts queue names (context for old mechanism)
    peer_internal_queue_names = []
    for peer_fqdn in conf.other_nibi_hostnames:
        sh = _get_sanitized_hostname(peer_fqdn)
        peer_internal_queue_names.append(f"nibi-notif-{sh}")

    LOG.info(f"⚙ Preparing RabbitMQ notification listener (Direct Queue: {notif_queue}, Fwd Topic: {our_unique_fwd_topic})...")
    # Get transport from the config (RabbitMQ connection, vhost, credentials)
    transport = oslo_messaging.get_notification_transport(conf)
    targets = [
        oslo_messaging.Target(
            topic=conf.neutron_notifications_topic,
            exchange=conf.neutron_notifications_exchange_name,
            fanout=True
        ),
        oslo_messaging.Target( # Forwarded Message Target (listen on specific queue - old way)
            topic=notif_queue # Misuse 'topic' to specify the queue name
        ),
        oslo_messaging.Target( # New Forwarded Message Target (listen on unique topic)
            topic=our_unique_fwd_topic,
            exchange=conf.neutron_notifications_exchange_name # Use the same exchange
        )
    ]

    # Endpoint instance
    endpoints = [PortEventEndpoint(conf, bgp_manager, subnet_id_to_nexthop_dict, port_id_to_ipv6_dict, subnet_id_to_name_dict, transport, peer_internal_queue_names, our_unique_fwd_topic)]

    # Create the notification listener
    server = oslo_messaging.get_notification_listener(
        transport,
        targets,
        endpoints,
        executor='eventlet',  # or 'eventlet' if you prefer
    )

    # Start periodic sync thread
    stop_event = threading.Event()
    sync_thread = threading.Thread(
        target=periodic_sync_worker,
        args=(conf, bgp_manager, subnet_id_to_nexthop_dict,
              port_id_to_ipv6_dict, stop_event),
        daemon=True,
    )
    sync_thread.start()

    # --- Signal handler ---
    def shutdown_handler(signum, frame):
        LOG.info(f"⚙ Received signal {signum}, shutting down gracefully...")

        def shutdown_task():
            try:
                LOG.info(f"⚙ Stopping periodic sync thread...")
                stop_event.set()        # stop periodic sync
                sync_thread.join(timeout=10)
            except Exception:
                pass

            try:
                LOG.info(f"⚙ Stopping RabbitMQ listen...")
                server.stop()  # stop the notification listener
            except Exception as e:
                LOG.error(f"⧱ Error stopping server: {e}")

            try:
                LOG.info(f"⚙ Stopping BGP advertizer...")
                bgp_manager.shutdown()  # shutdown BGP speaker
            except Exception as e:
                LOG.error(f"⧱ Error shutting down BGP speaker: {e}")

            LOG.info(f"⚙ Finished shutting down: bye bye!")
            sys.exit(0)

        eventlet.spawn(shutdown_task)

    # Register signal handler
    signal.signal(signal.SIGTERM, shutdown_handler)
    signal.signal(signal.SIGINT, shutdown_handler)

    LOG.info("⚙ Starting port event listener...")
    # Notify systemd the daemon is ready.
    sd_notify("READY=1")
    server.start()
    LOG.info("⚙ Startup finished: ready for operations.")
    server.wait()  # blocks, listening for messages

    # If wait returns, also shutdown gracefully
    shutdown_handler(signal.SIGTERM, None)


if __name__ == "__main__":
    run_inject0r()
    exit(0)
