# Copyright (C) 2026 Linuxfabrik <info@linuxfabrik.ch>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# On Debian systems, the complete text of the GNU General Public License
# version 2 can be found in /usr/share/common-licenses/GPL-2.
#
# SPDX-License-Identifier: GPL-2.0-or-later

"""PrintRule_nft: nftables rule syntax generation from compiled CompRules.

Generates nft rule statements for the `nft -f` batch format.
Unlike iptables, nftables rules are expressed as:

    ip saddr 10.0.0.0/8 tcp dport { 22, 80, 443 } accept

This module handles filter (policy) rules. NAT rules are handled
separately in _nat_print_rule.py.
"""

from __future__ import annotations

import ipaddress
import re
from typing import TYPE_CHECKING, ClassVar, cast

from firewallfabrik.compiler._interval_helpers import (
    DOW_NAMES_FULL,
    is_any_interval,
    parse_interval_data,
)
from firewallfabrik.compiler._rule_processor import PolicyRuleProcessor
from firewallfabrik.core.objects import (
    Address,
    AddressRange,
    Direction,
    DNSName,
    Host,
    ICMP6Service,
    ICMPService,
    Interface,
    IPService,
    Network,
    NetworkIPv6,
    PolicyAction,
    TCPService,
    UDPService,
)

if TYPE_CHECKING:
    from firewallfabrik.compiler._comp_rule import CompRule
    from firewallfabrik.platforms.nftables._policy_compiler import PolicyCompiler_nft


class PrintRule_nft(PolicyRuleProcessor):
    """Generates nftables rule statements from compiled policy rules.

    This is the final processor in the policy pipeline that converts
    the internal CompRule representation to nft rule syntax.
    """

    def __init__(self, name: str = 'generate nftables rules') -> None:
        super().__init__(name)
        # Track per-chain: rules go to separate chain blocks, so label
        # dedup must be independent per chain.
        self._chain_labels: dict[str, str] = {}

    def initialize(self) -> None:
        """Initialize after compiler context is set."""
        pass

    def process_next(self) -> bool:
        rule = self.get_next()
        if rule is None:
            return False

        self.tmp_queue.append(rule)

        chain = rule.ipt_chain or 'forward'

        label_str = self._print_rule_label(rule, chain)
        cmd = self._build_rule(rule)

        text = ''
        if label_str:
            text += label_str
        text += cmd

        # Write to per-chain collection if available (nftables),
        # otherwise to the output stream (fallback).
        nft_comp = cast('PolicyCompiler_nft', self.compiler)
        if hasattr(nft_comp, 'chain_rules') and chain in nft_comp.chain_rules:
            nft_comp.chain_rules[chain].append(text)
        else:
            nft_comp.output.write(text)
        return True

    def policy_rule_to_string(self, rule: CompRule) -> str:
        """Generate rule string for dedup."""
        return self._build_rule(rule)

    def _build_rule(self, rule: CompRule) -> str:
        """Build a complete nftables rule line.

        nft rule format:
            [ip|ip6] [saddr <addr>] [daddr <addr>] [<proto> [sport/dport]] \
            [ct state new] [counter] [log ...] [<verdict>]
        """
        parts: list[str] = []

        # Protocol and service
        srv = rule.srv[0] if rule.srv and not rule.is_srv_any() else None

        # Address family prefix for matching
        af_prefix = self._get_af_prefix(rule, srv)

        # Interface matching
        iface_match = self._print_interface(rule)
        if iface_match:
            parts.append(iface_match)

        # Source address
        src_match = self._print_src_addr(rule, af_prefix)
        if src_match:
            parts.append(src_match)

        # Destination address
        dst_match = self._print_dst_addr(rule, af_prefix)
        if dst_match:
            parts.append(dst_match)

        # Protocol + service matching
        srv_match = self._print_service(rule, srv)
        if srv_match:
            parts.append(srv_match)

        # State matching
        state_match = self._print_state(rule)
        if state_match:
            parts.append(state_match)

        # Time matching
        time_match = self._print_time_interval(rule)
        if time_match:
            parts.append(time_match)

        # Logging
        log_match = self._print_log(rule)
        if log_match:
            parts.append(log_match)

        # Verdict/target
        verdict = self._print_verdict(rule)
        if verdict:
            parts.append(verdict)

        if not parts:
            return ''

        line = '        ' + ' '.join(parts) + '\n'

        # Add error comments inline
        errors = self.compiler.get_errors_for_rule(rule)
        if errors:
            line = f'        # {errors}\n' + line

        return line

    def _get_af_prefix(self, rule: CompRule, srv) -> str:
        """Get the address family prefix (ip/ip6) for matching."""
        if self.compiler.ipv6_policy:
            return 'ip6'
        return 'ip'

    def _print_rule_label(self, rule: CompRule, chain: str = '') -> str:
        """Print rule label as nft comment.

        Tracks labels per chain since nftables rules are written to
        separate chain blocks (unlike iptables where -A CHAIN is inline).
        """
        label = rule.label
        current = self._chain_labels.get(chain, '')
        if not label or label == current:
            if label:
                self._chain_labels[chain] = label
            return ''

        res = []
        if not self.compiler.single_rule_compile_mode:
            res.append('        # ')
            res.append(f'        # Rule {label}')
            res.append('        # ')

        comment = rule.comment
        if comment:
            for line in comment.split('\n'):
                if line:
                    res.append(f'        # {line}')

        self._chain_labels[chain] = label
        if res:
            return '\n'.join(res) + '\n'
        return ''

    def _print_interface(self, rule: CompRule) -> str:
        """Print interface matching: iifname/oifname."""
        if rule.iface_label == 'nil':
            return ''

        if rule.is_itf_any():
            return ''

        iface_obj = rule.itf[0] if rule.itf else None
        if iface_obj is None or not isinstance(iface_obj, Interface):
            return ''

        iface_name = iface_obj.name
        if not iface_name:
            return ''

        # nftables uses iifname/oifname for wildcard matching
        # and iif/oif for exact interface matching.
        # Use iif/oif for loopback — index-based is faster and safe
        # (loopback is always present with a stable index).
        neg = '!= ' if rule.itf_single_object_negation else ''
        is_loopback = iface_obj.is_loopback()

        direction = rule.direction
        if direction == Direction.Inbound:
            keyword = 'iif' if is_loopback else 'iifname'
            return f'{keyword} {neg}"{iface_name}"'
        elif direction == Direction.Outbound:
            keyword = 'oif' if is_loopback else 'oifname'
            return f'{keyword} {neg}"{iface_name}"'

        return ''

    def _print_src_addr(self, rule: CompRule, af_prefix: str) -> str:
        """Print source address matching."""
        if rule.is_src_any():
            return ''

        if not rule.src:
            return ''

        neg = '!= ' if rule.src_single_object_negation else ''
        addrs = [self._print_addr(obj, rule) for obj in rule.src]
        addrs = [a for a in addrs if a]
        if not addrs:
            self.compiler.error(rule, 'Could not resolve any source addresses')
            return ''
        if len(addrs) == 1:
            return f'{af_prefix} saddr {neg}{addrs[0]}'
        return f'{af_prefix} saddr {neg}{{ {", ".join(addrs)} }}'

    def _print_dst_addr(self, rule: CompRule, af_prefix: str) -> str:
        """Print destination address matching."""
        if rule.is_dst_any():
            return ''

        if not rule.dst:
            return ''

        neg = '!= ' if rule.dst_single_object_negation else ''
        addrs = [self._print_addr(obj, rule) for obj in rule.dst]
        addrs = [a for a in addrs if a]
        if not addrs:
            self.compiler.error(rule, 'Could not resolve any destination addresses')
            return ''
        if len(addrs) == 1:
            return f'{af_prefix} daddr {neg}{addrs[0]}'
        return f'{af_prefix} daddr {neg}{{ {", ".join(addrs)} }}'

    def _print_addr(self, obj, rule: CompRule) -> str:
        """Print an address object in nftables format."""
        if isinstance(obj, AddressRange):
            start = obj.get_start_address()
            end = obj.get_end_address()
            if start and end:
                return f'{start}-{end}'

        if isinstance(obj, Interface):
            if obj.is_dynamic():
                self.compiler.error(
                    rule,
                    f'Dynamic interface address not yet supported by nftables compiler'
                    f' (interface: {obj.name})',
                )
                return ''
            for addr in getattr(obj, 'addresses', []):
                return self._print_addr_basic(addr, rule)
            self.compiler.error(rule, f'Interface "{obj.name}" has no addresses')
            return ''

        if isinstance(obj, Host):
            for iface in getattr(obj, 'interfaces', []):
                if iface.is_loopback():
                    continue
                for addr in getattr(iface, 'addresses', []):
                    addr_str = addr.get_address()
                    if addr_str:
                        return addr_str
            self.compiler.error(rule, f'Host "{obj.name}" has no addresses')
            return ''

        return self._print_addr_basic(obj, rule)

    def _print_addr_basic(self, obj, rule: CompRule) -> str:
        """Print basic address in CIDR notation."""
        if isinstance(obj, DNSName):
            # Runtime DNSName — use the DNS record directly as address
            return (obj.data or {}).get('dnsrec', obj.name)

        if not isinstance(obj, Address):
            self.compiler.error(
                rule,
                f'Cannot resolve address for object type {type(obj).__name__}',
            )
            return ''

        addr_str = obj.get_address()
        if not addr_str:
            return ''

        if isinstance(obj, (Network, NetworkIPv6)):
            mask_str = obj.get_netmask()
            if mask_str:
                try:
                    net = ipaddress.ip_network(f'{addr_str}/{mask_str}', strict=False)
                    length = net.prefixlen
                    if length != 32 and length != 128:
                        return f'{addr_str}/{length}'
                except ValueError:
                    pass

        return addr_str

    def _print_service(self, rule: CompRule, srv) -> str:
        """Print protocol + port/ICMP matching."""
        if rule.merged_tcp_udp:
            return self._print_merged_tcp_udp_service(rule)

        if srv is None:
            return ''

        if isinstance(srv, TCPService):
            return self._print_tcp_udp_service(rule, srv, 'tcp')
        elif isinstance(srv, UDPService):
            return self._print_tcp_udp_service(rule, srv, 'udp')
        elif isinstance(srv, (ICMPService, ICMP6Service)):
            return self._print_icmp_service(srv)
        elif isinstance(srv, IPService):
            parts = []
            proto = srv.get_protocol_number()
            if proto >= 0:
                parts.append(f'meta l4proto {proto}')
            data = srv.data or {}
            tos = data.get('tos', '')
            dscp = data.get('dscp', '')
            if dscp:
                parts.append(f'ip dscp {dscp}')
            elif tos:
                parts.append(f'ip tos {tos}')
            if parts:
                return ' '.join(parts)

        self.compiler.error(
            rule,
            f'Service type {type(srv).__name__} not yet supported by nftables compiler',
        )
        return ''

    def _print_tcp_udp_service(self, rule: CompRule, srv, proto: str) -> str:
        """Print TCP/UDP service matching.

        For multiple services (multiport), nftables uses sets natively:
            tcp dport { 22, 80, 443 }
        """
        parts = []

        neg = '!= ' if rule.srv_single_object_negation else ''

        # Source ports
        src_start = srv.src_range_start or 0
        src_end = srv.src_range_end or 0
        src_ports = self._format_port_range(src_start, src_end)

        # Destination ports
        dst_start = srv.dst_range_start or 0
        dst_end = srv.dst_range_end or 0

        # Handle multiport: collect all service destination ports
        if len(rule.srv) > 1:
            all_dst_ports = []
            for s in rule.srv:
                if isinstance(s, (TCPService, UDPService)):
                    ds = s.dst_range_start or 0
                    de = s.dst_range_end or 0
                    p = self._format_port_range(ds, de)
                    if p:
                        all_dst_ports.append(p)

            if src_ports:
                parts.append(f'{proto} sport {neg}{src_ports}')

            if all_dst_ports:
                if len(all_dst_ports) == 1:
                    parts.append(f'{proto} dport {neg}{all_dst_ports[0]}')
                else:
                    parts.append(f'{proto} dport {neg}{{ {", ".join(all_dst_ports)} }}')
        else:
            dst_ports = self._format_port_range(dst_start, dst_end)

            if src_ports:
                parts.append(f'{proto} sport {neg}{src_ports}')
            if dst_ports:
                parts.append(f'{proto} dport {neg}{dst_ports}')
            elif not src_ports:
                # Just the protocol, no ports
                parts.append(f'meta l4proto {proto}')

        return ' '.join(parts)

    def _print_merged_tcp_udp_service(self, rule: CompRule) -> str:
        """Print merged TCP+UDP service using transport header (th) matcher.

        Emits: meta l4proto { tcp, udp } th dport 53
        Or:    meta l4proto { tcp, udp } th dport { 53, 80 }
        """
        parts = ['meta l4proto { tcp, udp }']

        # Collect unique port ranges from all TCP/UDP services
        src_ports: list[str] = []
        dst_ports: list[str] = []
        seen_src: set[tuple[int, int]] = set()
        seen_dst: set[tuple[int, int]] = set()

        for s in rule.srv:
            if not isinstance(s, (TCPService, UDPService)):
                continue
            src_key = (s.src_range_start or 0, s.src_range_end or 0)
            dst_key = (s.dst_range_start or 0, s.dst_range_end or 0)
            if src_key not in seen_src:
                seen_src.add(src_key)
                p = self._format_port_range(src_key[0], src_key[1])
                if p:
                    src_ports.append(p)
            if dst_key not in seen_dst:
                seen_dst.add(dst_key)
                p = self._format_port_range(dst_key[0], dst_key[1])
                if p:
                    dst_ports.append(p)

        if src_ports:
            if len(src_ports) == 1:
                parts.append(f'th sport {src_ports[0]}')
            else:
                parts.append(f'th sport {{ {", ".join(src_ports)} }}')

        if dst_ports:
            if len(dst_ports) == 1:
                parts.append(f'th dport {dst_ports[0]}')
            else:
                parts.append(f'th dport {{ {", ".join(dst_ports)} }}')

        return ' '.join(parts)

    @staticmethod
    def _format_port_range(start: int, end: int) -> str:
        """Format a port range for nftables."""
        if start <= 0 and end <= 0:
            return ''
        if start == end or end <= 0:
            return str(start)
        return f'{start}-{end}'

    _ICMP_TYPE_NAMES: ClassVar[dict[int, str]] = {
        0: 'echo-reply',
        3: 'destination-unreachable',
        4: 'source-quench',
        5: 'redirect',
        8: 'echo-request',
        9: 'router-advertisement',
        10: 'router-solicitation',
        11: 'time-exceeded',
        12: 'parameter-problem',
        13: 'timestamp-request',
        14: 'timestamp-reply',
        15: 'info-request',
        16: 'info-reply',
        17: 'address-mask-request',
        18: 'address-mask-reply',
    }

    _ICMPV6_TYPE_NAMES: ClassVar[dict[int, str]] = {
        1: 'destination-unreachable',
        2: 'packet-too-big',
        3: 'time-exceeded',
        4: 'parameter-problem',
        128: 'echo-request',
        129: 'echo-reply',
        130: 'mld-listener-query',
        131: 'mld-listener-report',
        132: 'mld-listener-done',
        133: 'nd-router-solicit',
        134: 'nd-router-advert',
        135: 'nd-neighbor-solicit',
        136: 'nd-neighbor-advert',
        137: 'nd-redirect',
        138: 'router-renumbering',
        141: 'ind-neighbor-solicit',
        142: 'ind-neighbor-advert',
        143: 'mld2-listener-report',
    }

    def _print_icmp_service(self, srv) -> str:
        """Print ICMP type/code matching."""
        codes = getattr(srv, 'codes', None) or srv.data or {}
        raw_type = codes.get('type', -1)
        raw_code = codes.get('code', -1)
        icmp_type = -1 if raw_type is None else int(raw_type)
        icmp_code = -1 if raw_code is None else int(raw_code)

        proto = 'icmpv6' if self.compiler.ipv6_policy else 'icmp'
        type_names = (
            self._ICMPV6_TYPE_NAMES
            if self.compiler.ipv6_policy
            else self._ICMP_TYPE_NAMES
        )
        type_str = type_names.get(icmp_type, str(icmp_type))

        if icmp_type < 0:
            return f'meta l4proto {proto}'
        if icmp_code < 0:
            return f'{proto} type {type_str}'
        return f'{proto} type {type_str} {proto} code {icmp_code}'

    def _print_state(self, rule: CompRule) -> str:
        """Print connection tracking state matching."""
        stateless = rule.get_option('stateless', False)
        force_state = rule.force_state_check

        if not stateless or force_state:
            return 'ct state new'
        return ''

    def _print_time_interval(self, rule: CompRule) -> str:
        """Print nftables time/weekday matching.

        Uses ``meta hour`` for time-of-day and ``meta day`` for weekday
        constraints.
        """
        if not rule.when:
            return ''

        interval = rule.when[0]
        data = interval.data or {}

        if is_any_interval(data):
            return ''

        start_h, start_m, end_h, end_m, days = parse_interval_data(data)

        parts = []
        parts.append(
            f'meta hour "{start_h:02d}:{start_m:02d}"-"{end_h:02d}:{end_m:02d}"'
        )

        if sorted(days) != list(range(7)):
            day_names = ', '.join(f'"{DOW_NAMES_FULL[d]}"' for d in days)
            parts.append(f'meta day {{ {day_names} }}')

        return ' '.join(parts)

    def _print_log(self, rule: CompRule) -> str:
        """Print log expression.

        In nftables, log is an inline statement, not a separate target.
        It can be combined with a verdict: `log prefix "..." accept`

        Handles two cases:
        - ipt_target == 'LOG': standalone log rule (Continue action)
        - nft_log flag: inline log before verdict (e.g. `log prefix "..." accept`)

        When ``use_NFLOG`` is enabled, generates ``log group N`` instead of
        plain ``log``, which routes packets via netlink to a userspace
        logging daemon (ulogd2, rsyslog, syslog-ng).
        """
        if rule.ipt_target != 'LOG' and not rule.nft_log:
            return ''

        use_nflog = self.compiler.fw.get_option('use_NFLOG')

        parts = ['log']

        if use_nflog:
            nlgroup = self.compiler.fw.get_option('ulog_nlgroup')
            try:
                nlgroup = int(nlgroup)
            except (TypeError, ValueError):
                nlgroup = 1
            parts.append(f'group {nlgroup}')

        log_prefix = self._get_log_prefix(rule)
        if log_prefix:
            parts.append(f'prefix "{log_prefix}"')

        if not use_nflog:
            log_level = rule.get_option('log_level', '')
            if not log_level:
                log_level = self.compiler.fw.get_option('log_level')
            if log_level:
                parts.append(f'level {log_level}')

        return ' '.join(parts)

    def _get_log_prefix(self, rule: CompRule) -> str:
        """Get log prefix, expanding macros."""
        log_prefix = rule.get_option('log_prefix', '')
        if not log_prefix:
            log_prefix = self.compiler.fw.get_option('log_prefix')
        if not log_prefix:
            return ''

        log_prefix = str(log_prefix)

        action = rule.stored_action.upper()
        pos = str(rule.position)
        chain = rule.ipt_chain or ''

        iface_name = ''
        if rule.itf:
            obj = rule.itf[0]
            if isinstance(obj, Interface):
                iface_name = obj.name
        if not iface_name or iface_name == 'Any':
            iface_name = 'global'

        ruleset_name = 'Policy'
        if self.compiler.source_ruleset:
            ruleset_name = self.compiler.source_ruleset.name

        result = log_prefix.replace('%N', pos)
        result = result.replace('%A', action)
        result = result.replace('%I', iface_name)
        result = result.replace('%C', chain)
        result = result.replace('%R', ruleset_name)
        return result[:63]  # nftables limit

    def _print_verdict(self, rule: CompRule) -> str:
        """Print the nftables verdict."""
        target = rule.ipt_target

        # LOG target is printed via _print_log, no separate verdict
        if target == 'LOG':
            return ''

        # Handle iptables target names mapped to nftables verdicts
        verdict_map = {
            'ACCEPT': 'accept',
            'DROP': 'drop',
            'REJECT': 'reject',
            'RETURN': 'return',
        }

        if target:
            if target.startswith('.'):
                return ''
            verdict = verdict_map.get(target)
            if verdict:
                if verdict == 'reject':
                    return self._print_reject(rule)
                return verdict
            # Custom chain jump
            self.compiler.warning(
                rule,
                f'Custom chain jump not yet supported by nftables compiler: {target}',
            )
            return f'jump {target}'

        # Fall back to action
        action_map = {
            PolicyAction.Accept: 'accept',
            PolicyAction.Deny: 'drop',
            PolicyAction.Reject: 'reject',
            PolicyAction.Return: 'return',
            PolicyAction.Continue: '',
        }

        action = rule.action
        verdict = action_map.get(action, '') if isinstance(action, PolicyAction) else ''
        if verdict == 'reject':
            return self._print_reject(rule)
        return verdict

    def _print_reject(self, rule: CompRule) -> str:
        """Print reject with specific type."""
        action_on_reject = rule.get_option('action_on_reject', '')

        if not action_on_reject:
            return 'reject'

        # Map reject type names to nftables syntax.
        # Values come from the GUI ("ICMP host unreachable"),
        # iptables syntax ("icmp-host-unreachable"), or legacy aliases.
        # The GUI names are defined in platforms.cpp:actionsOnReject.
        reject_map = {
            'ICMP host unreachable': 'reject with icmp host-unreachable',
            'ICMP net unreachable': 'reject with icmp net-unreachable',
            'ICMP port unreachable': 'reject with icmp port-unreachable',
            'ICMP protocol unreachable': 'reject with icmp prot-unreachable',
            'ICMP admin prohibited': 'reject with icmp admin-prohibited',
            'ICMP-unreachable': 'reject with icmp host-unreachable',
            'TCP RST': 'reject with tcp reset',
            'icmp-host-unreachable': 'reject with icmp host-unreachable',
            'icmp-net-unreachable': 'reject with icmp net-unreachable',
            'icmp-port-unreachable': 'reject with icmp port-unreachable',
            'icmp-proto-unreachable': 'reject with icmp prot-unreachable',
            'icmp-admin-prohibited': 'reject with icmp admin-prohibited',
            'tcp-reset': 'reject with tcp reset',
            'TCP-RST': 'reject with tcp reset',
        }

        nft_reject = reject_map.get(action_on_reject)
        if nft_reject:
            return nft_reject
        self.compiler.warning(
            rule,
            f'Unknown reject type "{action_on_reject}", falling back to generic reject',
        )
        return 'reject'


# ═══════════════════════════════════════════════════════════════════
# Post-processing optimization: merge consecutive rules into sets
# ═══════════════════════════════════════════════════════════════════

# Matches "ip saddr <addr>" or "ip6 saddr <addr>" anywhere in a rule line.
# The pattern uses search(), so it finds the first occurrence in the line.
# Group 1: "ip saddr " or "ip6 saddr " (with optional "!= ")
# Group 2: address family ("ip" or "ip6")
# Group 3: optional negation ("!= " or empty)
# Group 4: the address value (single addr, CIDR, range, or { set })
# Group 5: the rest of the rule after the address
_SADDR_RE = re.compile(
    r'((ip6?) saddr (!= )?)'  # af + saddr + optional negation
    r'(\{[^}]+\}|[^\s]+)'  # address: set or single value
    r'(.*)$',  # suffix: rest of the rule
)

# Same pattern for daddr.
_DADDR_RE = re.compile(
    r'((ip6?) daddr (!= )?)'
    r'(\{[^}]+\}|[^\s]+)'
    r'(.*)$',
)


def _split_entry(entry: str) -> tuple[str, str]:
    """Split a chain_rules entry into (comments, rule_line).

    Each entry may contain comment lines (starting with ``#``) followed
    by exactly one rule line, or may contain only a rule line.  Error
    comment lines (``# Error: ...``) immediately preceding a rule are
    kept attached to the rule via the comments portion.

    Returns ``('', '')`` if the entry contains no rule line.
    """
    lines = entry.split('\n')
    # Find the last non-empty, non-comment line — that is the rule.
    rule_idx = -1
    for i in range(len(lines) - 1, -1, -1):
        stripped = lines[i].strip()
        if stripped and not stripped.startswith('#'):
            rule_idx = i
            break

    if rule_idx < 0:
        return ('', '')

    comment_lines = lines[:rule_idx]
    rule_line = lines[rule_idx]
    # Trailing lines after the rule (usually just an empty string from
    # the final newline) are discarded — we reconstruct the newline on
    # output.
    comments = '\n'.join(comment_lines) + '\n' if comment_lines else ''
    return (comments, rule_line)


def _parse_addr(
    rule_line: str,
) -> tuple[str, str, str, str] | None:
    """Try to extract an address field from a rule line.

    Returns ``(prefix, addr, suffix, field)`` where *field* is
    ``'saddr'`` or ``'daddr'``, or ``None`` if no address field is
    found.

    *prefix* and *suffix* together form the "signature" of the rule —
    everything except the varying address.  Two rules can be merged if
    their prefix, suffix, and field are identical.
    """
    for pattern, field in ((_SADDR_RE, 'saddr'), (_DADDR_RE, 'daddr')):
        m = pattern.search(rule_line)
        if m:
            before_match = rule_line[: m.start()]
            prefix = before_match + m.group(1)
            addr = m.group(4)
            suffix = m.group(5)
            return (prefix, addr, suffix, field)
    return None


def _addrs_from_value(value: str) -> list[str]:
    """Extract individual addresses from a value that may be a set.

    ``"10.0.0.1"`` -> ``["10.0.0.1"]``
    ``"{ 10.0.0.1, 10.0.0.2 }"`` -> ``["10.0.0.1", "10.0.0.2"]``
    """
    value = value.strip()
    if value.startswith('{') and value.endswith('}'):
        inner = value[1:-1]
        return [a.strip() for a in inner.split(',') if a.strip()]
    return [value]


def _build_addr_value(addrs: list[str]) -> str:
    """Build an address value, using set syntax if multiple."""
    if len(addrs) == 1:
        return addrs[0]
    return '{ ' + ', '.join(addrs) + ' }'


def optimize_chain_rules(chain_rules: dict[str, list[str]]) -> None:
    """Merge consecutive rules that differ only in source or destination address.

    Operates in-place on the per-chain rule lists produced by
    :class:`PrintRule_nft`.  Two adjacent entries are merged when their
    rule lines are structurally identical except for the ``ip saddr`` or
    ``ip daddr`` value.  The merged rule uses nftables anonymous set
    syntax: ``ip saddr { addr1, addr2 } ...``.

    Rules are never merged across different comment blocks (i.e. across
    different original firewall rules).  Only entries whose rule lines
    share the same "signature" (everything except the address value) are
    candidates.
    """
    for chain, entries in chain_rules.items():
        chain_rules[chain] = _optimize_entries(entries)


def _optimize_entries(entries: list[str]) -> list[str]:
    """Merge consecutive entries that differ only in one address field."""
    if len(entries) <= 1:
        return entries

    result: list[str] = []
    i = 0

    while i < len(entries):
        comments_i, rule_i = _split_entry(entries[i])
        parsed_i = _parse_addr(rule_i) if rule_i else None

        if parsed_i is None:
            # Not a mergeable rule — emit as-is.
            result.append(entries[i])
            i += 1
            continue

        prefix_i, addr_i, suffix_i, field_i = parsed_i
        signature = (prefix_i, suffix_i, field_i)
        merged_addrs = list(_addrs_from_value(addr_i))
        merged_comments = comments_i

        # Look ahead for consecutive entries with the same signature.
        # Stop at comment boundaries — a non-empty comment block marks
        # a new original firewall rule and must not be merged.
        j = i + 1
        while j < len(entries):
            comments_j, rule_j = _split_entry(entries[j])
            if not rule_j:
                break

            # A comment block signals a different original rule.
            if comments_j:
                break

            parsed_j = _parse_addr(rule_j)
            if parsed_j is None:
                break

            prefix_j, addr_j, suffix_j, field_j = parsed_j
            if (prefix_j, suffix_j, field_j) != signature:
                break

            # Same signature, same original rule — collect addresses.
            merged_addrs.extend(_addrs_from_value(addr_j))
            j += 1

        # Build the merged entry.
        addr_value = _build_addr_value(merged_addrs)
        merged_rule = f'{prefix_i}{addr_value}{suffix_i}'
        merged_text = merged_comments + merged_rule + '\n'
        result.append(merged_text)
        i = j

    return result
