"""configuration.py

Created by Thomas Mangin on 2009-08-25.
Copyright (c) 2009-2017 Exa Networks. All rights reserved.
License: 3-clause BSD. (See the COPYRIGHT file)
"""

from __future__ import annotations

import os
import re
from typing import TYPE_CHECKING, Any, cast

from exabgp.bgp.message.refresh import RouteRefresh
from exabgp.protocol.family import FamilyTuple

if TYPE_CHECKING:
    from exabgp.bgp.message.operational import OperationalFamily
    from exabgp.configuration.settings import ConfigurationSettings
    from exabgp.rib.route import Route

from exabgp.configuration.announce import AnnounceIPv4, AnnounceIPv6, AnnounceL2VPN, SectionAnnounce
from exabgp.configuration.announce.flow import AnnounceFlow  # noqa: F401,E261,E501

# for registration
from exabgp.configuration.announce.ip import AnnounceIP  # noqa: F401,E261,E501
from exabgp.configuration.announce.label import AnnounceLabel  # noqa: F401,E261,E501
from exabgp.configuration.announce.mup import AnnounceMup  # noqa: F401,E261,E501
from exabgp.configuration.announce.mvpn import AnnounceMVPN  # noqa: F401,E261,E501
from exabgp.configuration.announce.sr_policy import sr_policy_ipv4, sr_policy_ipv6  # noqa: F401,E261,E501
from exabgp.configuration.announce.path import AnnouncePath  # noqa: F401,E261,E501
from exabgp.configuration.announce.vpls import AnnounceVPLS  # noqa: F401,E261,E501
from exabgp.configuration.announce.vpn import AnnounceVPN  # noqa: F401,E261,E501
from exabgp.configuration.capability import ParseCapability
from exabgp.configuration.core import Error, Parser, Scope, Section, Tokeniser
from exabgp.configuration.tcpao import ParseTCPAO
from exabgp.configuration.flow import ParseFlow, ParseFlowMatch, ParseFlowRoute, ParseFlowScope, ParseFlowThen
from exabgp.configuration.l2vpn import ParseL2VPN, ParseVPLS
from exabgp.configuration.neighbor import ParseNeighbor
from exabgp.configuration.neighbor.api import ParseAPI, ParseReceive, ParseSend
from exabgp.configuration.neighbor.family import ParseAddPath, ParseFamily
from exabgp.configuration.neighbor.nexthop import ParseNextHop
from exabgp.configuration.operational import ParseOperational
from exabgp.configuration.process import ParseProcess
from exabgp.configuration.static import ParseStatic, ParseStaticRoute
from exabgp.configuration.template import ParseTemplate
from exabgp.configuration.template.neighbor import ParseTemplateNeighbor
from exabgp.environment import getenv
from exabgp.logger import lazymsg, log

# Mapping for config keywords that don't match parser section names
# Format: (parent_section_name, keyword) -> target_section_name
# Only needed for exceptions where keyword != parser.name
_KEYWORD_TO_SECTION: dict[tuple[str, str], str] = {
    ('template', 'neighbor'): 'template-neighbor',
    ('neighbor', 'l2vpn'): 'L2VPN',
    ('template-neighbor', 'l2vpn'): 'L2VPN',
    ('flow', 'route'): 'flow/route',
    ('flow/route', 'match'): 'flow/match',
    ('flow/route', 'then'): 'flow/then',
    ('flow/route', 'scope'): 'flow/scope',
    ('L2VPN', 'vpls'): 'l2vpn/vpls',
    ('api', 'send'): 'api/send',
    ('api', 'receive'): 'api/receive',
    ('static', 'route'): 'static/route',
}


class _Configuration:
    def __init__(self) -> None:
        self.processes: dict[str, Any] = {}
        self.neighbors: dict[str, Any] = {}
        # Global route store: index -> Route (shared across neighbors)
        self._routes: dict[bytes, 'Route'] = {}

    def store_route(self, route: 'Route') -> bytes:
        """Store route in global store, incrementing refcount.

        Args:
            route: Route to store

        Returns:
            Route index (bytes)
        """
        index = route.index()
        if index in self._routes:
            # Route already exists, just increment refcount
            self._routes[index].ref_inc()
        else:
            # New route, add to store with refcount 1
            route.ref_inc()
            self._routes[index] = route
        return index

    def release_route(self, index: bytes) -> bool:
        """Release route reference, removing if refcount reaches zero.

        Args:
            index: Route index to release

        Returns:
            True if route was found and released, False otherwise
        """
        route = self._routes.get(index)
        if route is None:
            return False
        if route.ref_dec() <= 0:
            del self._routes[index]
        return True

    def get_route(self, index: bytes) -> 'Route | None':
        """Get route by index (O(1) lookup).

        Args:
            index: Route index

        Returns:
            Route if found, None otherwise
        """
        return self._routes.get(index)

    def announce_route(self, peers: list[str], route: 'Route') -> bool:
        """Announce route to matching peers.

        Args:
            peers: List of peer names to announce to
            route: Route to announce

        Returns:
            True if route was announced to at least one peer
        """
        result = False
        for neighbor_name in self.neighbors:
            if neighbor_name in peers:
                neighbor = self.neighbors[neighbor_name]
                if route.nlri.family().afi_safi() in neighbor.families():
                    # resolve_self creates a copy with resolved nexthop
                    neighbor.rib.outgoing.add_to_rib(neighbor.resolve_self(route))
                    result = True
                else:
                    log.error(
                        lazymsg(
                            'route.family.unconfigured family={family} neighbor={neighbor}',
                            family=route.nlri.short(),
                            neighbor=neighbor_name,
                        ),
                        'configuration',
                    )
        return result

    def withdraw_route(self, peers: list[str], route: 'Route') -> bool:
        """Withdraw route from matching peers.

        Args:
            peers: List of peer names to withdraw from
            route: Route to withdraw

        Returns:
            True if route was withdrawn from at least one peer
        """
        result = False
        for neighbor_name in self.neighbors:
            if neighbor_name in peers:
                neighbor = self.neighbors[neighbor_name]
                if route.nlri.family().afi_safi() in neighbor.families():
                    # resolve_self creates a copy with resolved nexthop
                    neighbor.rib.outgoing.del_from_rib(neighbor.resolve_self(route))
                    result = True
                else:
                    log.error(
                        lazymsg(
                            'route.family.unconfigured family={family} neighbor={neighbor}',
                            family=route.nlri.short(),
                            neighbor=neighbor_name,
                        ),
                        'configuration',
                    )
        return result

    def announce_route_indexed(self, peers: list[str], route: 'Route') -> tuple[bytes, bool]:
        """Announce route and store in global index for API access.

        Args:
            peers: List of peer names to announce to
            route: Route to announce

        Returns:
            Tuple of (route_index, success) where success is True if
            route was announced to at least one peer
        """
        # Store in global store for index-based lookup
        index = self.store_route(route)
        # Announce to peers
        success = self.announce_route(peers, route)
        return index, success

    def withdraw_route_by_index(self, peers: list[str], index: bytes) -> bool:
        """Withdraw route by its index.

        Args:
            peers: List of peer names to withdraw from
            index: Route index (from announce_route_indexed or route.index())

        Returns:
            True if route was found and withdrawn from at least one peer
        """
        route = self.get_route(index)
        if route is None:
            return False

        # del_from_rib handles withdraws - no need to set action on route
        result = False
        for neighbor_name in self.neighbors:
            if neighbor_name in peers:
                neighbor = self.neighbors[neighbor_name]
                if route.nlri.family().afi_safi() in neighbor.families():
                    neighbor.rib.outgoing.del_from_rib(neighbor.resolve_self(route))
                    result = True

        # Release from global store
        if result:
            self.release_route(index)

        return result

    def inject_eor(self, peers: list[str], family: object) -> bool:
        result = False
        for neighbor in self.neighbors:
            if neighbor in peers:
                result = True
                self.neighbors[neighbor].eor.append(family)
        return result

    def inject_operational(self, peers: list[str], operational: 'OperationalFamily') -> bool:
        result = True
        for neighbor in self.neighbors:
            if neighbor in peers:
                family = operational.family()
                if family in self.neighbors[neighbor].families():
                    if operational.name == 'ASM':
                        self.neighbors[neighbor].asm[family] = operational
                    self.neighbors[neighbor].messages.append(operational)
                else:
                    neighbor_err: str = neighbor
                    family_err = family

                    def _log_err(neighbor: str = neighbor_err, family: FamilyTuple = family_err) -> str:
                        return f'the route family {family} is not configured on neighbor {neighbor}'

                    log.error(_log_err, 'configuration')
                    result = False
        return result

    def inject_refresh(self, peers: list[str], refreshes: list[RouteRefresh]) -> bool:
        result = True
        for neighbor in self.neighbors:
            if neighbor in peers:
                for refresh in refreshes:
                    family = (refresh.afi, refresh.safi)
                    if family in self.neighbors[neighbor].families():
                        self.neighbors[neighbor].refresh.append(
                            RouteRefresh.make_route_refresh(refresh.afi, refresh.safi)
                        )
                    else:
                        family_err = family
                        neighbor_err: str = neighbor

                        def _log_refresh_err(family: FamilyTuple = family_err, neighbor: str = neighbor_err) -> str:
                            return f'the route family {family} is not configured on neighbor {neighbor}'

                        log.error(_log_refresh_err, 'configuration')
                        result = False
        return result


class Configuration(_Configuration):
    def __init__(self, configurations: list[str], text: bool = False) -> None:
        _Configuration.__init__(self)
        self.api_encoder: str = getenv().api.encoder

        self._configurations: list[str] = configurations
        self._text: bool = text

        self.error: Error = Error()
        self.scope: Scope = Scope()

        self.parser: Parser = Parser(self.scope, self.error)

        params = (self.parser, self.scope, self.error)
        self.section = Section(*params)
        self.process = ParseProcess(*params)
        self.template = ParseTemplate(*params)
        self.template_neighbor = ParseTemplateNeighbor(*params)
        self.neighbor = ParseNeighbor(*params)
        self.family = ParseFamily(*params)
        self.addpath = ParseAddPath(*params)
        self.nexthop = ParseNextHop(*params)
        self.capability = ParseCapability(*params)
        self.tcpao = ParseTCPAO(*params)
        self.api = ParseAPI(*params)
        self.api_send = ParseSend(*params)
        self.api_receive = ParseReceive(*params)
        self.static = ParseStatic(*params)
        self.static_route = ParseStaticRoute(*params)
        self.announce = SectionAnnounce(*params)
        self.announce_ipv4 = AnnounceIPv4(*params)
        self.announce_ipv6 = AnnounceIPv6(*params)
        self.announce_l2vpn = AnnounceL2VPN(*params)
        self.flow = ParseFlow(*params)
        self.flow_route = ParseFlowRoute(*params)
        self.flow_match = ParseFlowMatch(*params)
        self.flow_then = ParseFlowThen(*params)
        self.flow_scope = ParseFlowScope(*params)
        self.l2vpn = ParseL2VPN(*params)
        self.vpls = ParseVPLS(*params)
        self.operational = ParseOperational(*params)

        # Build parser registry: section_name -> parser instance
        self._parsers: dict[str, Section] = {
            p.name: p
            for p in [
                self.process,
                self.template,
                self.template_neighbor,
                self.neighbor,
                self.family,
                self.addpath,
                self.nexthop,
                self.capability,
                self.tcpao,
                self.api,
                self.api_send,
                self.api_receive,
                self.static,
                self.static_route,
                self.announce,
                self.announce_ipv4,
                self.announce_ipv6,
                self.announce_l2vpn,
                self.flow,
                self.flow_route,
                self.flow_match,
                self.flow_then,
                self.flow_scope,
                self.l2vpn,
                self.vpls,
                self.operational,
            ]
        }

        # Build structure from schemas
        self._structure = self._build_structure()

        self._neighbors: dict[str, Any] = {}
        self._previous_neighbors: dict[str, Any] = {}

    @classmethod
    def from_settings(cls, settings: 'ConfigurationSettings') -> 'Configuration':
        """Create Configuration from validated settings.

        This factory method enables programmatic Configuration creation without
        parsing config files. Useful for testing and API-driven creation.

        Args:
            settings: ConfigurationSettings with neighbors and processes.

        Returns:
            Configured Configuration instance with neighbors ready.

        Raises:
            ValueError: If settings validation fails.
        """
        from exabgp.bgp.neighbor.neighbor import Neighbor

        error = settings.validate()
        if error:
            raise ValueError(error)

        # Create Configuration with empty configuration list
        config = cls(configurations=[])

        # Set processes
        config.processes = dict(settings.processes)

        # Create neighbors from settings
        for neighbor_settings in settings.neighbors:
            neighbor = Neighbor.from_settings(neighbor_settings)
            config.neighbors[neighbor.name()] = neighbor

        return config

    def _build_structure(self) -> dict[str, dict[str, Any]]:
        """Build the configuration structure from parser schemas.

        Returns a dict mapping section names to their configuration:
        - 'class': parser instance
        - 'commands': valid commands (from known.keys() or explicit list)
        - 'sections': mapping of keywords to child section names
        """
        # Special command lists for parsers that don't use known.keys()
        _SPECIAL_COMMANDS: dict[str, list[str]] = {
            'ipv4': [
                'unicast',
                'multicast',
                'nlri-mpls',
                'labeled-unicast',
                'mpls-vpn',
                'mcast-vpn',
                'flow',
                'flow-vpn',
                'mup',
                'sr-policy',
            ],
            'ipv6': [
                'unicast',
                'multicast',
                'nlri-mpls',
                'labeled-unicast',
                'mpls-vpn',
                'mcast-vpn',
                'flow',
                'flow-vpn',
                'mup',
                'sr-policy',
            ],
            'l2vpn': ['vpls'],
            'static': ['route', 'attributes', 'sr-policy'],
        }

        # Build sections dict from schema Container children
        def get_sections(parser: Section) -> dict[str, str]:
            sections: dict[str, str] = {}
            for keyword in parser.get_subsection_keywords():
                # Look up target section name from override mapping or use keyword as-is
                target = _KEYWORD_TO_SECTION.get((parser.name, keyword), keyword)
                sections[keyword] = target
            return sections

        # Build structure entry for a parser
        def build_entry(parser: Section) -> dict[str, Any]:
            # Get commands from special list, known dict, or schema
            if parser.name in _SPECIAL_COMMANDS:
                commands = _SPECIAL_COMMANDS[parser.name]
            else:
                # Combine commands from known dict and schema Leaf children
                # Filter to only string keys (known may have tuple keys for special cases)
                commands = [k for k in parser.known.keys() if isinstance(k, str)]
                if parser.schema:
                    from exabgp.configuration.schema import Leaf, LeafList

                    for name, child in parser.schema.children.items():
                        if isinstance(child, (Leaf, LeafList)) and name not in commands:
                            commands.append(name)
            return {
                'class': parser,
                'commands': commands,
                'sections': get_sections(parser),
            }

        # Start with root entry (special case - no parser)
        structure: dict[str, dict[str, Any]] = {
            'root': {
                'class': self.section,
                'commands': [],
                'sections': {
                    'process': 'process',
                    'neighbor': 'neighbor',
                    'template': 'template',
                },
            },
        }

        # Add entries for all registered parsers
        for name, parser in self._parsers.items():
            structure[name] = build_entry(parser)

        # Special case: l2vpn/vpls uses l2vpn.known (commands from parent)
        structure['l2vpn/vpls']['commands'] = list(self.l2vpn.known.keys())

        return structure

    @property
    def tokeniser(self) -> Tokeniser:
        """Convenience accessor for parser.tokeniser"""
        return self.parser.tokeniser

    def _clear(self) -> None:
        self.processes = {}
        self._previous_neighbors = self.neighbors
        self.neighbors = {}
        self._neighbors = {}

    # clear the parser data (ie: free memory)
    def _cleanup(self) -> None:
        self.error.clear()
        self.parser.clear()
        self.scope.clear()

        self.process.clear()
        self.template.clear()
        self.template_neighbor.clear()
        self.neighbor.clear()
        self.family.clear()
        self.capability.clear()
        self.tcpao.clear()
        self.api.clear()
        self.api_send.clear()
        self.api_receive.clear()
        self.announce_ipv6.clear()
        self.announce_ipv4.clear()
        self.announce_l2vpn.clear()
        self.announce.clear()
        self.static.clear()
        self.static_route.clear()
        self.flow.clear()
        self.flow_route.clear()
        self.flow_match.clear()
        self.flow_then.clear()
        self.flow_scope.clear()
        self.l2vpn.clear()
        self.vpls.clear()
        self.operational.clear()

    def _rollback_reload(self) -> None:
        self.neighbors = self._previous_neighbors
        self.processes = self.process.processes
        self._neighbors = {}
        self._previous_neighbors = {}

    def _commit_reload(self) -> None:
        self.neighbors = self.neighbor.neighbors
        # Process change detection is handled in Processes.start() which compares
        # old vs new config and only restarts processes that actually changed.
        self.processes = self.process.processes
        self._neighbors = {}

        # Add the changes prior to the reload to the neighbor to correct handling of deleted routes
        for neighbor in self.neighbors:
            if neighbor in self._previous_neighbors:
                self.neighbors[neighbor].previous = self._previous_neighbors[neighbor]

        self._previous_neighbors = {}
        self._cleanup()

    def reload(self) -> bool:
        try:
            return self._reload()
        except KeyboardInterrupt:
            return self.error.set('configuration reload aborted by ^C or SIGINT')
        except Error as exc:
            if getenv().debug.configuration:
                raise
            return self.error.set(
                f'problem parsing configuration file line {self.parser.index_line}\nerror message: {exc}',
            )
        except Exception as exc:
            if getenv().debug.configuration:
                raise
            return self.error.set(
                f'problem parsing configuration file line {self.parser.index_line}\nerror message: {exc}',
            )

    def _reload(self) -> bool:
        # If created via from_settings(), no configurations to reload
        # but neighbors are already set up - return success
        if not self._configurations and self.neighbors:
            return True

        # taking the first configuration available (FIFO buffer)
        fname = self._configurations.pop(0)
        self._configurations.append(fname)

        # clearing the current configuration to be able to re-parse it
        self._clear()

        if self._text:
            if not self.parser.set_text(fname):
                return False
        else:
            # resolve any potential symlink, and check it is a file
            target = os.path.realpath(fname)
            if not os.path.isfile(target):
                return False
            if not self.parser.set_file(target):
                return False

        self.process.add_api()

        if self.parse_section('root') is not True:
            self._rollback_reload()
            line_str = ' '.join(self.parser.line)
            return self.error.set(
                f'\nsyntax error in section {self.scope.location()}\nline {self.parser.number}: {line_str}\n\n{self.error!s}',
            )

        self._commit_reload()
        self._link()

        check = self.validate()
        if check:
            return check

        return True

    def validate(self) -> bool:
        for neighbor in self.neighbors.values():
            has_procs = 'processes' in neighbor.api and neighbor.api['processes']
            has_match = 'processes-match' in neighbor.api and neighbor.api['processes-match']
            if has_procs and has_match:
                return self.error.set(
                    "\n\nprocesses and processes-match are mutually exclusive, verify neighbor '{}' configuration.\n\n".format(
                        neighbor.session.peer_address
                    ),
                )

            for notification in neighbor.api:
                errors = []
                for api in neighbor.api[notification]:
                    if notification == 'processes':
                        if not self.processes[api].get('run', False):
                            return self.error.set(
                                f"\n\nan api called '{api}' is used by neighbor '{neighbor.session.peer_address}' but not defined\n\n",
                            )
                    elif notification == 'processes-match':
                        if not any(v.get('run', False) for k, v in self.processes.items() if re.match(api, k)):
                            errors.append(
                                f"\n\nAny process match regex '{api}' for neighbor '{neighbor.session.peer_address}'.\n\n",
                            )

                # matching mode is an "or", we test all rules and check
                # if any of rule had a match
                if len(errors) > 0 and len(errors) == len(neighbor.api[notification]):
                    return self.error.set(
                        ' '.join(errors),
                    )
        return True

    def _link(self) -> None:
        for neighbor in self.neighbors.values():
            api = neighbor.api
            processes = []
            if api.get('processes', []):
                processes = api['processes']
            elif api.get('processes-match', []):
                processes = [k for k in self.processes.keys() for pm in api['processes-match'] if re.match(pm, k)]

            for process in processes:
                self.processes.setdefault(process, {})['neighbor-changes'] = api['neighbor-changes']
                self.processes.setdefault(process, {})['negotiated'] = api['negotiated']
                self.processes.setdefault(process, {})['fsm'] = api['fsm']
                self.processes.setdefault(process, {})['signal'] = api['signal']
                for way in ('send', 'receive'):
                    for name in ('parsed', 'packets', 'consolidate'):
                        key = f'{way}-{name}'
                        if api[key]:
                            self.processes[process].setdefault(key, []).append(neighbor.session.router_id)
                    for name in ('open', 'update', 'notification', 'keepalive', 'refresh', 'operational'):
                        key = f'{way}-{name}'
                        if api[key]:
                            self.processes[process].setdefault(key, []).append(neighbor.session.router_id)

    def partial(self, section: str, text: str, action: str = 'announce') -> bool:
        self._cleanup()  # this perform a big cleanup (may be able to be smarter)
        self._clear()
        self.parser.set_api(text if text.endswith(';') or text.endswith('}') else text + ' ;')
        self.parser.set_action(action)

        if self.parse_section(section) is not True:
            self._rollback_reload()
            line_str = ' '.join(self.parser.line)
            error_msg = (
                f'\n'
                f'syntax error in api command {self.scope.location()}\n'
                f'line {self.parser.number}: {line_str}\n'
                f'\n{self.error}'
            )
            log.debug(lazymsg('configuration.parse.error message={error_msg}', error_msg=error_msg), 'configuration')
            return False
        return True

    def parse_route_text(self, route_text: str, action: str = 'announce') -> list['Route']:
        """Parse route text into Route objects without clearing neighbors.

        Unlike partial(), this preserves existing neighbors and just parses
        the route text. Useful for programmatic configuration building.

        Args:
            route_text: Route specification (e.g., "route 10.0.0.0/24 next-hop 1.2.3.4")
            action: Action for routes - 'announce' or 'withdraw'

        Returns:
            List of parsed Route objects, empty list if parsing failed.

        Example:
            config = create_minimal_configuration(families='ipv4 unicast')
            routes = config.parse_route_text('route 10.0.0.0/24 next-hop 1.2.3.4')
            for route in routes:
                neighbor.rib.outgoing.add_to_rib(neighbor.resolve_self(route))
        """
        # Save neighbors before partial() clears them
        saved_neighbors = self.neighbors.copy()

        # Parse the route text
        self.static.clear()
        if not self.partial('static', route_text, action):
            # Restore neighbors on failure
            self.neighbors = saved_neighbors
            return []

        # Get parsed routes
        self.scope.to_context()
        routes = self.scope.pop_routes()

        # Restore neighbors
        self.neighbors = saved_neighbors

        return routes

    def _enter(self, name: str) -> bool | str:
        location = self.parser.tokeniser()
        log.debug(
            lazymsg(
                'configuration.enter location={location} params={params}',
                location=location,
                params=self.parser.params(),
            ),
            'configuration',
        )

        if location not in self._structure[name]['sections']:
            return self.error.set(f'section {location} is invalid in {name}, {self.scope.location()}')

        self.scope.enter(location)
        self.scope.to_context()

        class_name = self._structure[name]['sections'][location]
        instance = self._structure[class_name].get('class', None)
        if not instance:
            raise RuntimeError('This should not be happening, debug time !')

        if not instance.pre():
            return False

        if not self.dispatch(self._structure[name]['sections'][location]):
            return False

        if not instance.post():
            return False

        left = self.scope.leave()
        if not left:
            return self.error.set('closing too many parenthesis')
        self.scope.to_context()

        log.debug(
            lazymsg('configuration.leave section={left} params={params}', left=left, params=self.parser.params()),
            'configuration',
        )
        return True

    def _run(self, name: str) -> bool:
        command = self.parser.tokeniser()
        log.debug(
            lazymsg(
                'configuration.run command={command} params={params}', command=command, params=self.parser.params()
            ),
            'configuration',
        )

        if not self.run(name, command):
            return False
        return True

    def dispatch(self, name: str) -> bool | str:
        while True:
            self.parser()

            if self.parser.end == ';':
                if self._run(name):
                    continue
                return False

            if self.parser.end == '{':
                if self._enter(name):
                    continue
                return False

            if self.parser.end == '}':
                return True

            if not self.parser.end:  # finished
                return True

            return self.error.set('invalid syntax line %d' % self.parser.index_line)
        return False

    def parse_section(self, name: str) -> bool | str:
        if name not in self._structure:
            return self.error.set('option {} is not allowed here'.format(name))

        if not self.dispatch(name):
            return False

        instance = self._structure[name].get('class', None)
        if instance is not None:
            instance.post()
        return True

    def run(self, name: str, command: str) -> bool | str:
        # restore 'anounce attribute' to provide backward 3.4 compatibility
        if name == 'static' and command == 'attribute':
            command = 'attributes'
        if command not in self._structure[name]['commands']:
            return self.error.set('invalid keyword "{}"'.format(command))

        return cast(bool | str, self._structure[name]['class'].parse(name, command))

    def to_dict(self) -> dict[str, Any]:
        """Export parsed configuration as a serializable dict.

        Returns a dictionary containing:
        - neighbors: dict mapping peer addresses to neighbor configuration
        - processes: dict of configured processes

        The returned dict can be serialized to JSON using ConfigEncoder.
        """
        return {
            'neighbors': {name: self._neighbor_to_dict(neighbor) for name, neighbor in self.neighbors.items()},
            'processes': self.processes,
        }

    def _neighbor_to_dict(self, neighbor: Any) -> dict[str, Any]:
        """Convert Neighbor object to serializable dict.

        Args:
            neighbor: Neighbor instance to convert

        Returns:
            Dictionary with all neighbor configuration fields
        """
        return {
            'description': neighbor.description,
            'hold_time': neighbor.hold_time,
            'rate_limit': neighbor.rate_limit,
            'host_name': neighbor.host_name,
            'domain_name': neighbor.domain_name,
            'group_updates': neighbor.group_updates,
            'auto_flush': neighbor.auto_flush,
            'adj_rib_in': neighbor.adj_rib_in,
            'adj_rib_out': neighbor.adj_rib_out,
            'manual_eor': neighbor.manual_eor,
            'session': neighbor.session,
            'capability': neighbor.capability,
            'api': neighbor.api,
            'families': [(afi, safi) for afi, safi in neighbor.families()],
            'nexthops': [(afi, safi, nhafi) for afi, safi, nhafi in neighbor.nexthops()],
            'addpaths': [(afi, safi) for afi, safi in neighbor.addpaths()],
            'routes': [route for route in neighbor.routes],
        }
