#!/usr/bin/python3
# pylint: disable=too-many-lines

"""Foomuuri - Multizone bidirectional nftables firewall.

Copyright 2023, Kim B. Heino, Foobar Oy <b@bbbs.net>

License: GPL-2.0-or-later
"""

import concurrent.futures
import datetime
import ipaddress
import itertools
import json
import os
import pathlib
import re
import select
import shlex
import signal
import socket
import subprocess
import sys
import time
import unicodedata
import dbus
import dbus.mainloop.glib
import dbus.service
import requests
from gi.repository import GLib


# SystemD notify support is optional
try:
    from systemd.daemon import notify
    HAVE_NOTIFY = True
except ImportError:
    HAVE_NOTIFY = False


VERSION = '0.27'

CONFIG = {
    # Parsed foomuuri{} from config files
    'log_rate': '1/second burst 3',
    'log_input': 'yes',
    'log_output': 'yes',
    'log_forward': 'yes',
    'log_rpfilter': 'yes',
    'log_invalid': 'no',
    'log_smurfs': 'no',
    'log_level': 'level info flags skuid',
    'localhost_zone': 'localhost',
    'dbus_zone': 'public',
    'rpfilter': 'yes',
    'counter': 'no',
    'set_size': '65535',
    'recursion_limit': '65535',
    'priority_offset': '5',
    'dbus_firewalld': 'no',
    'nft_bin': 'nft',

    # Directories and files. Files are relative to state_dir.
    'etc_dir': '/etc/foomuuri',
    'share_dir': '/usr/share/foomuuri',
    'state_dir': '/var/lib/foomuuri',
    'run_dir': '/run/foomuuri',
    'good_file': 'good.fw',
    'next_file': 'next.fw',
    'dbus_file': 'dbus.fw',
    'resolve_file': 'resolve.fw',
    'iplist_file': 'iplist.fw',
    'iplist_manual_file': 'iplist-manual.fw',
    'zone_file': 'zone',
    'monitor_statistics_file': 'monitor.statistics',

    # Parsed command line parameters - used internally
    'command': '',
    'parameters': [],
    'root_power': True,
    'verbose': 0,
    'force': 0,
}

OUT = []       # Generated nftables ruleset / commands
LOGRATES = {}  # Lograte names and limits
HELPERS = []   # List of helpers: (helper-object, protocol, ports)


def fail(error=None):
    """Exit with error message."""
    if error:
        print(f'Error: {error}')
    sys.exit(1)


def warning(text):
    """Print warning message."""
    if CONFIG['verbose'] >= 0:
        print(f'Warning: {text}', flush=True)


def verbose(line, level=1):
    """Print line if --verbose was given in command line."""
    if CONFIG['verbose'] >= level:
        print(line, flush=True)


def out(line):
    """Add single line to ruleset."""
    OUT.append(line)


def run_program_rc(args, *, env=None, print_output=True, quiet=False):
    """Run external program and return its errorcode. Print its output."""
    if not args:
        return 0
    verbose(' '.join(map(str, args)))
    try:
        proc = subprocess.run(args, check=False, stdout=subprocess.PIPE,
                              stderr=subprocess.STDOUT, encoding='utf-8',
                              env=env, timeout=60)
    except (OSError, subprocess.TimeoutExpired) as error:
        print(f'Error: Failed to run command "{shlex.join(args)}": {error}',
              flush=True)
        return 1
    output = proc.stdout.rstrip()
    if (
            output and
            (proc.returncode or print_output or CONFIG['verbose'] > 0) and
            not quiet
    ):
        print(output, flush=True)
    return proc.returncode


def run_program_output(args, fileline):
    """Run external program as shell command and return its output.

    Command failure is fatal. Parameter args must be a string, not list.
    """
    if not args:
        return ''
    try:
        proc = subprocess.run(args, check=False, stdout=subprocess.PIPE,
                              stderr=subprocess.STDOUT, encoding='utf-8',
                              shell=True, timeout=60)
    except (OSError, subprocess.TimeoutExpired) as error:
        fail(f'{fileline}Failed to run command "{args}": {error}')
    if proc.returncode:
        fail(f'{fileline}Failed to run command "{args}": '
             f'return code {proc.returncode}')
    return proc.stdout.rstrip()


def nft_command(cmd, **kwargs):
    """Run "nft cmd", wrapper to run_program_rc()."""
    return run_program_rc(CONFIG['nft_bin'] + [cmd], **kwargs)


def nft_json(cmd):
    """Run "nft --json cmd", return its output as json, ignore errors."""
    if not cmd:
        return {}
    args = CONFIG['nft_bin'] + ['--json', cmd]
    verbose(' '.join(map(str, args)), 2)
    try:
        proc = subprocess.run(args, check=False, stdout=subprocess.PIPE,
                              stderr=subprocess.STDOUT, encoding='utf-8',
                              timeout=60)
    except (OSError, subprocess.TimeoutExpired):
        return None
    if proc.returncode:
        return None
    try:
        verbose(proc.stdout, 2)
        return json.loads(proc.stdout)
    except json.decoder.JSONDecodeError:
        return None


def shell_expansion(content, fileline):
    """Expand $(shell command) in configuration file.

    This is the first expansion done. Failure in command is fatal.
    """
    while '$(shell ' in content:
        prefix, postfix = content.split('$(shell ', 1)
        if ')' not in postfix:
            postfix = postfix.splitlines()[0]
            fail(f'{fileline}"$(shell" without ")" in command: {postfix}')
        shell, postfix = postfix.split(')', 1)
        content = f'{prefix}{run_program_output(shell, fileline)}{postfix}'
    return content


def read_config():
    """Read att config files to config dict: section -> lines[].

    Files are read in alphabetical order, ignoring backup and hidden files.
    """
    # pylint: disable=too-many-branches
    # pylint: disable=too-many-statements
    # pylint: disable=no-member  # rglob
    configfiles = (sorted(list(CONFIG['share_dir'].rglob('*.conf'))) +
                   sorted(list(CONFIG['etc_dir'].rglob('*.conf'))))
    configfiles = [name for name in configfiles
                   if name.name[0] not in ('.', '#')]

    # There characters will combine to single word in shlex
    wordchars = ''.join(chr(letter) for letter in range(33, 256)
                        # excludes: " # ' ; { }
                        if letter not in (34, 35, 39, 59, 123, 125))

    # Read all config files
    config = {}        # Final config dict
    section = None     # Currently open section name
    section_line = {}  # Section_name -> filename_line for error messages
    for filename in configfiles:
        try:
            content = filename.read_text(encoding='utf-8')
        except PermissionError as error:
            fail(f'File {filename}: Can\'t read: {error}')

        # Expand $(shell in configuration file. Do this for whole file instead
        # of single line so that command can return multiple lines.
        content = shell_expansion(content, f'File {filename}: ')

        # Parse single config file content
        continuation = ''
        for linenumber, line in enumerate(content.splitlines()):
            if line == '# foomuuri: not-conf':
                break  # sysctl's 50-foomuuri.conf is not my config

            # Combine lines if there is \ at end of line
            if line.endswith('\\'):
                continuation += line[:-1] + ' '
                continue
            line = continuation + line
            continuation = ''

            # Parse single line to list of words. Keep " as is, it can be
            # used to avoid macro expansion.
            fileline = f'File {filename} line {linenumber + 1}: '
            try:
                lexer = shlex.shlex(line, punctuation_chars=';{')
                lexer.wordchars = wordchars
                tokens = list(lexer)
            except ValueError as error:
                fail(f'{fileline}Can\'t parse line: {error}')
            if not tokens:
                continue

            # "}" is end of section
            if len(tokens) == 1 and tokens[0] == '}':  # End of section
                if not section:
                    fail(f'{fileline}Extra "}}"')
                section = None

            # "foo {" is section start
            elif len(tokens) == 2 and tokens[1] == '{':
                if section:
                    fail(f'{fileline}New "{" ".join(tokens)}" while section '
                         f'"{section}" is still open')
                section = tokens[0]
                if section.startswith('_'):  # _name is protected
                    fail(f'{fileline}Unknown section: {section}')
                if section not in config:
                    config[section] = []
                    section_line[section] = fileline

            # "template foo {" / "target foo" / "group foo" is section start
            elif (
                    len(tokens) == 3 and
                    tokens[0] in ('template', 'target', 'group') and
                    tokens[2] == '{'
            ):
                if section:
                    fail(f'{fileline}New "{" ".join(tokens)}" while section '
                         f'"{section}" is still open')
                section = f'{tokens[0]} {tokens[1]}'
                if section not in config:
                    config[section] = []
                    section_line[section] = fileline

            # "foo" which is not inside section
            elif not section:
                fail(f'{fileline}Unknown line: {" ".join(tokens)}')

            # "foo" inside section
            else:
                config[section].append((fileline, tokens))

        # End of file checks
        if continuation:
            fail(f'File {filename}: Continuation "\\" at end of file')
        if section:
            fail(f'File {filename}: Section "{section}" is missing "}}" at '
                 f'end of file')

    # Include section_name -> filename_line to config for error messages
    config['_section_line'] = section_line
    return config


def config_to_pathlib(fix_files):
    """Convert str paths in CONFIG{} to pathlib.Paths."""
    # "*_dir" are needed for reading config files
    keys = list(CONFIG)
    for key in keys:
        if key.endswith('_dir'):
            CONFIG[key] = pathlib.Path(CONFIG[key])

    # "*_file" are needed to save current state. "state_dir" can be changed
    # in command line and in foomuuri section in config. Fix "*_file" only
    # after reading minimal config.
    if fix_files:
        for key in keys:
            if key.endswith('_file'):
                CONFIG[key] = CONFIG['state_dir'] / CONFIG[key]

    # "*_bin" are binaries with optional arguments
    for key in keys:
        if key.endswith('_bin') and isinstance(CONFIG[key], str):
            CONFIG[key] = shlex.split(CONFIG[key])


def parse_config_macros(config):
    """Parse macro{} from config. Recursively expand macro in macro{}."""
    # Parse macro{} to dict
    macros = {}
    macroline = {}
    for fileline, macro in config.pop('macro', []):
        key = macro[0]
        value = macro[1:]
        if not value:
            fail(f'{fileline}Macro "{key}" does not have value')
        macroline[key] = fileline
        if value[0] == '+':  # append
            macros[key] = macros.get(key, []) + value[1:]
        else:
            if CONFIG['command'] == 'check' and macros.get(key):
                warning(f'{fileline}Overwriting macro "{key}" '
                        f'with value "{" ".join(value)}"')
            macros[key] = value  # overwrite

    # Expand macro in macro{}. Keep going as long as there was some expansion
    # done.
    while True:
        found = False
        for check, cvalue in macros.items():
            for macro, mvalue in macros.items():
                try:
                    pos = mvalue.index(check)  # Full word expansion only
                except ValueError:
                    continue
                if check == macro:  # Macro "foo" expands to "foo bar"
                    fail(f'{macroline[macro]}Macro "{macro}" expands to '
                         f'itself: {" ".join(mvalue)}')
                # Expand macro
                macros[macro] = mvalue[:pos] + cvalue + mvalue[pos + 1:]
                found = True
        if not found:  # No new expansion was done
            return macros


def macro_isdigit(word, separator):
    """Check if word contains number after separator."""
    if word.count(separator) != 1:
        return False
    return word.split(separator)[1].isdigit()


def expand_single_line(fileline, line, macros):
    """Expand first macro in line. Repeat call to expand all.

    Expansion can return multiple lines.
    Returns None if no expansion was done.
    """
    # Iterate words in line
    for pos, word in enumerate(line):
        # Cleanup "-macro", "macro/24", "[macro]:123" and "macro:123"
        # to prefix/macro/suffix parts
        word_prefix = word_suffix = ''
        if word[0] == '-':  # Negative IP address
            word = word[1:]
            word_prefix = '-'
        if macro_isdigit(word, '/'):  # Netmask
            word, word_suffix = word.split('/')
            word_suffix = f'/{word_suffix}'
        if (
                word[0] == '[' and
                macro_isdigit(word, ']:') and
                not word_prefix and
                not word_suffix
        ):  # [IPv6]:port
            word, word_suffix = word[1:].split(']:')
            word_prefix = '['
            word_suffix = f']:{word_suffix}'
        if macro_isdigit(word, ':') and not word_suffix:  # IPv4:port
            word, word_suffix = word.split(':')
            word_suffix = f':{word_suffix}'

        # Check cleaned value
        if word not in macros:
            continue

        # Found, add prefix/suffix to all macro's value
        mvalue = []
        for item in macros[word]:
            if item == ';':
                mvalue.append(item)
            else:
                mvalue.append(f'{word_prefix}{item}{word_suffix}')

        # Expand mvalue to line and return list of expanded lines
        prefix = line[:pos]
        suffix = line[pos + 1:]
        return [(fileline, prefix + list(group) + suffix)
                for is_split, group in itertools.groupby(
                        mvalue, lambda spl: spl == ';') if not is_split]
    return None


def expand_macros(config):
    """Expand all macros in all config sections."""
    macros = parse_config_macros(config)
    for section, orig_lines in config.items():
        if section in ('foomuuri', 'zone') or section.startswith('_'):
            continue  # Don't expand in these sections

        new_lines = []
        while orig_lines:
            # Get next line and expand macros there
            fileline, line = orig_lines.pop(0)
            expanded = expand_single_line(fileline, line, macros)

            # Repeat call if some expansion was done
            if expanded:
                orig_lines = expanded + orig_lines
            else:  # Not found, go to next line
                new_lines.append((fileline, line))
        config[section] = new_lines


def remove_quotes(config):
    """Change "foo" to foo in config entries.

    This is called after macro expansion so that '"ssh"' is 'ssh', not
    'tcp 22'.
    """
    for section, lines in config.items():
        if section.startswith('_'):
            continue
        for _dummy_fileline, line in lines:
            for index, item in enumerate(line):
                if item.startswith('"') and item.endswith('"'):  # "foo"
                    line[index] = item[1:-1]
                elif item.startswith("'") and item.endswith("'"):  # 'foo'
                    line[index] = item[1:-1]


def parse_config_foomuuri(config):
    """Parse foomuuri{} from config to CONFIG{}."""
    for fileline, line in config.pop('foomuuri', []):
        name = line[0]
        value = ' '.join(line[1:])
        if name not in CONFIG:
            fail(f'{fileline}Unknown foomuuri{{}} option: {" ".join(line)}')
        if value.startswith('+ '):  # append
            CONFIG[name] = f'{CONFIG[name]} {value[2:]}'
        else:
            CONFIG[name] = value  # overwrite
    config_to_pathlib(True)  # Paths can be changed in foomuuri{}

    # Convert chain priority offset to nft. It is already converted on D-Bus
    # handler reload.
    if not CONFIG['priority_offset']:
        priority = 0
    else:
        try:
            priority = int(CONFIG['priority_offset'].replace(' ', ''))
        except ValueError:
            fail(f'Invalid foomuuri{{}} priority_offset: '
                 f'{CONFIG["priority_offset"]}')
    if priority == 0:
        CONFIG['priority_offset'] = ''
    elif priority > 0:
        CONFIG['priority_offset'] = f' + {priority}'
    else:
        CONFIG['priority_offset'] = f' - {-priority}'

    # Add "packets" to log rates
    for key in list(CONFIG):
        if (
                key.startswith('log_') and
                re.match(r'^\d+/(second|minute|hour) burst \d+$', CONFIG[key])
        ):
            CONFIG[key] += ' packets'


def check_name(name, fileline, prefix=''):
    """Check that name starts with letter, not number."""
    if not re.fullmatch(r'[a-zA-Z_]', name[:1]):
        fail(f'{fileline}Invalid name: {prefix}{name}')


def parse_config_zones(config):
    """Parse zone{} from config."""
    zones = {}
    for fileline, line in config.pop('zone', []):
        check_name(line[0], fileline)
        if line[0] in zones:
            fail(f'{fileline}Zone is already defined: {line[0]}')
        zones[line[0]] = {'interface': line[1:]}
    return zones


def parse_config_zonemap(config):
    """Parse zonemap{} rules from config."""
    zonemap = []
    for fileline, line in config.pop('zonemap', []):
        rule = parse_rule_line((fileline, line))
        if not rule['new_dzone'] and not rule['new_szone']:
            fail(f'{fileline}Zonemap without "new_dzone" or "new_szone" '
                 f'is a no-op: {" ".join(line)}')
        zonemap.append(rule)
    return zonemap


def parse_config_rule_section(config, section):
    """Parse snat{}, dnat{}, prerouting{} etc. rules from config."""
    lines = config.pop(section, [])
    rules = [parse_rule_line(line) for line in lines]
    return rules


def parse_resolve(config, section, timeout=None, refresh=None):
    """Parse resolve{} and iplist{} from config.

    Line syntax for resolve:
      @name fqdn fqdn2 fqdn3 ipv4 ipv6 ip/mask

    Line syntax for iplist:
      @name url filename ipv4 ipv6 ip/mask

    Entry "timeout 10h 30m" (defaults to 24h if "resolve", 10d if "iplist")
    is how long found IP addresses are remembered.

    Entry "refresh 3h" is how often iplist entries will be fetched.
    """
    ret = {
        'timeout': timeout,
        'refresh': refresh,
    }
    for fileline, line in config.pop(section, []):
        if line[0] in ('timeout', 'refresh') and len(line) > 1:
            ret[line[0]] = ''.join(line[1:])
        else:
            if not line[0].startswith('@') or len(line[0]) == 1:
                fail(f'{fileline}Invalid {section} name: {" ".join(line)}')
            check_name(line[0][1:], fileline, '@')
            for ipv in (4, 6):  # "@foo" to "@foo_4" and "@foo_6"
                name = f'{line[0]}_{ipv}'
                if len(line) == 1 or line[1:] == ['-']:
                    ret[name] = []
                elif line[1] == '+':  # append
                    ret[name] = ret.get(name, []) + line[2:]
                else:  # overwrite
                    ret[name] = line[1:]
    return ret


def parse_config_hook(config):
    """Parse hook{} from config."""
    for fileline, line in config.pop('hook', []):
        if line[0] not in (
                'pre_start', 'post_start',
                'pre_stop', 'post_stop',
        ):
            fail(f'{fileline}Unknown hook: {" ".join(line)}')
        CONFIG[line[0]] = line[1:]


def minimal_config():
    """Read and parse minimal config."""
    config = read_config()
    expand_macros(config)
    remove_quotes(config)
    parse_config_foomuuri(config)
    return config


def is_ipv4_address(value):
    """Is value IPv4 address, network or interval."""
    if value.count('-') == 1:  # Interval "IP-IP"
        addr_from, addr_to = value.split('-')
        return is_ipv4_address(addr_from) and is_ipv4_address(addr_to)
    try:  # Address "IP"
        return isinstance(ipaddress.ip_address(value), ipaddress.IPv4Address)
    except ValueError:
        try:  # Network "IP/mask"
            return isinstance(ipaddress.ip_network(value, strict=False),
                              ipaddress.IPv4Network)
        except ValueError:
            return False


def is_ipv6_address(value):
    """Is value IPv6 address, network or interval."""
    if value.count('/-') == 1:  # Suffix mask "IP/-mask"
        addr, maskstr = value.split('/-')
        try:
            mask = int(maskstr)
            return 0 <= mask <= 128 and is_ipv6_address(addr)
        except ValueError:
            return False

    if value.count('-') == 1:  # Interval "IP-IP"
        addr_from, addr_to = value.split('-')
        return is_ipv6_address(addr_from) and is_ipv6_address(addr_to)

    # Python's ipaddress library doesn't handle "[ipv6]" notation.
    # Strip [] before validating the address. nft handles [] fine, it will
    # strip them.
    if value.startswith('['):
        if value.endswith(']'):  # "[ipv6]"
            value = value[1:-1]
        elif ']/' in value:      # "[ipv6]/56"
            value = value[1:].replace(']/', '/')

    try:  # Address "IP"
        return isinstance(ipaddress.ip_address(value), ipaddress.IPv6Address)
    except ValueError:
        try:  # Network "IP/mask"
            return isinstance(ipaddress.ip_network(value, strict=False),
                              ipaddress.IPv6Network)
        except ValueError:
            return False


def is_ip_address(value):
    """Check if value is IPv4 or IPv6 address.

    Return 4, 6, or 0 if not detected.
    """
    if value.startswith('-'):  # Negative is handled in single_or_set()
        value = value[1:]
    if is_ipv4_address(value):
        return 4
    if is_ipv6_address(value):
        return 6
    return 0


def is_port(value, protocol):
    """Check if value is port: "1", "1-2" or "1,2", or any combination.

    This is used in parse_rule_line so protocol must specified.
    Allow special keywords for protocol icmp/icmpv6.
    """
    if not protocol:
        return False
    for item in value.split(','):
        if protocol in ('icmp', 'icmpv6') and (
                # See: "nft describe icmp type; nft describe icmpv6 type"
                item == 'redirect' or
                re.match(r'^[a-z2]{2,}-[-a-z]{5,}$', item)
        ):
            continue
        for number in item.split('-'):
            if not number.isnumeric():
                return False
    return True


def rule_item_only_one(rule, keyword, value):
    """Verify that keyword is set only once, or set to same value."""
    old = rule.get(f'_only_one_{keyword}', value)
    if old != value:
        fail(f'{rule["fileline"]}Rule\'s {keyword} is already set to '
             f'"{old}": {" ".join(rule["line"])}')
    rule[f'_only_one_{keyword}'] = value


def verify_rule_sanity(rule, fileline):
    """Do some basic verify that single rule is valid."""
    # pylint: disable=too-many-branches
    for key, value in rule.items():
        if value == '' and key not in ('queue', 'counter', 'log',
                                       'fileline', 'line'):
            fail(f'{fileline}"{key}" without value is not valid')

    for key in ('saddr_rate_name', 'daddr_rate_name', 'saddr_daddr_rate_name',
                'helper', 'counter', 'mss'):
        value = rule[key] or ''
        if ' ' in value:
            fail(f'{fileline}"{key}" must be single word: {value}')

    for key in ('global_rate', 'saddr_rate', 'daddr_rate', 'saddr_daddr_rate'):
        if not rule[key]:
            continue
        if re.match(r'^\d+/(second|minute|hour) burst \d+$', rule[key]):
            rule[key] += ' packets'
        if not (
                re.match(r'^\d+/(second|minute|hour)( burst \d+ packets)?$',
                         rule[key]) or
                re.match(r'^ct count (over )?\d+$', rule[key])
        ):
            fail(f'{fileline}Invalid "{key}" value: {rule[key]}')

    for basic, extra in (
            ('protocol', 'sport'),
            ('protocol', 'dport'),
            ('saddr_rate', 'saddr_rate_name'),
            ('saddr_rate', 'saddr_rate_mask'),
            ('daddr_rate', 'daddr_rate_name'),
            ('daddr_rate', 'daddr_rate_mask'),
            ('saddr_daddr_rate', 'saddr_daddr_rate_name'),
            ('saddr_daddr_rate', 'saddr_daddr_rate_mask'),
    ):
        if rule[extra] and not rule[basic]:
            fail(f'{fileline}"{extra}" without "{basic}" is not valid')

    if rule['statement'] in ('snat', 'dnat') and not rule['to']:
        fail(f'{fileline}"{rule["statement"]}" without "to" is not valid')

    if rule['statement'] == 'masquerade' and rule['to']:
        fail(f'{fileline}"{rule["statement"]}" with "to" is not valid')

    if rule['ct_status'] and rule['ct_status'] not in (
            'expected', 'seen-reply', 'assured', 'confirmed',
            'snat', 'dnat', 'dying',
    ):
        fail(f'{fileline}"Invalid "ct_status" value: {rule["ct_status"]}')


def parse_rule_line(fileline_line):
    """Parse single config section line to rule dict.

    This parser is quite relaxed. Words can be in almost any order. For
    example, all following entries are equal:
      tcp 22 log           <- preferred
      tcp 22 accept log
      accept tcp 22 log
      log tcp accept 22
    """
    # pylint: disable=too-many-branches
    # pylint: disable=too-many-statements
    fileline, line = fileline_line
    ret = {
        # Basic rules
        'statement': 'accept',
        'cast': 'unicast',
        'protocol': None,
        'saddr': None,
        'sport': None,
        'daddr': None,
        'dport': None,
        'oifname': None,
        'iifname': None,
        'mac_saddr': None,
        'mac_daddr': None,
        # Is this IPv4/6 specific rule?
        'ipv4': False,
        'ipv6': False,
        # Rate limits
        'global_rate': None,
        'saddr_rate': None,
        'saddr_rate_mask': None,
        'saddr_rate_name': None,
        'daddr_rate': None,
        'daddr_rate_mask': None,
        'daddr_rate_name': None,
        'saddr_daddr_rate': None,
        'saddr_daddr_rate_mask': None,
        'saddr_daddr_rate_name': None,
        # User limits
        'uid': None,
        'gid': None,
        # Zonemap specific rules
        'szone': None,
        'dzone': None,
        'new_szone': None,
        'new_dzone': None,
        # Misc rules
        'to': None,  # snat/dnat to
        'queue': None,  # optional queue flags
        'counter': None,
        'helper': None,
        'sipsec': None,
        'dipsec': None,
        'log': None,
        'log_level': None,
        'nft': None,
        'mss': None,
        'template': None,
        'mark_set': None,
        'mark_match': None,
        'priority_set': None,
        'priority_match': None,
        'cgroup': None,
        'ct_status': None,
        'time': None,
        'after_conntrack': True,

        # Internal housekeeping
        'plain': True,  # Plain "log" or "counter" without anything else
        'fileline': fileline,  # For error messages
        'line': line,  # Original line for error messages
    }

    keyword = None
    for item in line:
        if item == ';':
            fail(f'{fileline}";" is not supported in rule, split it to '
                 f'separate lines: {" ".join(line)}')

        # "tcp 22" is shortcut for "tcp dport 22"
        if not keyword and is_port(item, ret['protocol']):
            keyword = 'dport'
            ret['plain'] = False
            if ret[keyword] is None:
                ret[keyword] = ''

        # First item after start keyword is always a parameter for it, except
        # for "log" or "counter". Log will have good default value if not
        # defined. Counter without parameter will create anonymous counter.
        if keyword and not ret[keyword] and keyword not in ('log', 'counter'):
            ret[keyword] = item
            if keyword == 'protocol':  # Single word only
                keyword = None

        # Non-start keywords
        elif item in ('accept', 'drop', 'return', 'continue',
                      'masquerade', 'snat', 'dnat'):
            rule_item_only_one(ret, 'statement', item)
            ret['statement'] = item
            ret['plain'] = False
            keyword = None
        elif item == 'reject':
            rule_item_only_one(ret, 'statement', item)
            ret['statement'] = 'reject with icmpx admin-prohibited'
            ret['plain'] = False
            keyword = None
        elif item in ('multicast', 'broadcast'):
            rule_item_only_one(ret, 'cast', item)
            ret['cast'] = item
            ret['plain'] = False
            keyword = None
        elif item in ('tcp', 'udp', 'icmp', 'icmpv6', 'igmp', 'esp'):
            # "igmp" and "esp" are for backward compability (v0.21)
            rule_item_only_one(ret, 'protocol', item)
            ret['protocol'] = item
            ret['plain'] = False
            keyword = None
        elif item in ('ipv4', 'ipv6'):
            ret[item] = True
            ret['plain'] = False
            keyword = None
        elif item in ('sipsec', 'dipsec'):
            ret[item] = 'exists'
            ret['plain'] = False
            keyword = None
        elif item in ('-sipsec', '-dipsec'):
            ret[item[1:]] = 'missing'
            ret['plain'] = False
            keyword = None
        elif item in ('conntrack', '-conntrack'):
            rule_item_only_one(ret, 'conntrack', item)
            ret['after_conntrack'] = item == 'conntrack'
            ret['plain'] = False
            keyword = None

        # Start keywords
        elif item in ('protocol',
                      'saddr', 'sport',
                      'daddr', 'dport',
                      'oifname', 'iifname',
                      'mac_saddr', 'mac_daddr',
                      'global_rate',
                      'saddr_rate', 'saddr_rate_mask', 'saddr_rate_name',
                      'daddr_rate', 'daddr_rate_mask', 'daddr_rate_name',
                      'saddr_daddr_rate', 'saddr_daddr_rate_mask',
                      'saddr_daddr_rate_name',
                      'uid', 'gid',
                      'szone', 'dzone',
                      'new_szone', 'new_dzone',
                      'to',
                      'counter',
                      'helper',
                      'log',
                      'log_level',
                      'nft',
                      'mss',
                      'template',
                      'queue',
                      'mark_set',
                      'mark_match',
                      'priority_set',
                      'priority_match',
                      'cgroup',
                      'ct_status',
                      'time',
                      ):
            keyword = item
            if ret[keyword] is None:
                ret[keyword] = ''
            if item == 'queue':  # statement and start keyword
                rule_item_only_one(ret, 'statement', item)
                ret['statement'] = item
            if item not in ('counter', 'log', 'log_level'):
                ret['plain'] = False

        # More parameters for keyword
        elif keyword:
            if ret[keyword]:
                ret[keyword] += ' '
            ret[keyword] += item

        # Unknown word after non-start keyword
        else:
            fail(f'{fileline}Can\'t parse line: {" ".join(line)}')

    # Use no-op statement "continue" for plain "log" or "counter" rule,
    # everything else defaults to "accept". Also mark them as -conntrack
    # so that they really log/count everything.
    if ret['plain']:
        ret['statement'] = 'continue'
        ret['after_conntrack'] = False
    verify_rule_sanity(ret, fileline)
    return ret


def parse_config_templates(config):
    """Parse "template foo { ... }" rules from config."""
    templates = {}
    names = [item for item in config if item.startswith('template ')]
    for name in names:
        lines = config.pop(name)
        templates[name[9:]] = [parse_rule_line(line) for line in lines]
    return templates


def parse_config_rules(config):
    """Parse "zone-zone" rules from config.

    All other sections must be already parsed and removed from config.
    """
    rules = {}
    for section, lines in config.items():
        if section.startswith('_'):
            continue
        try:
            szone, dzone = section.split('-')
        except ValueError:
            fail(f'{config["_section_line"][section]}Unknown section: '
                 f'{section}')
        rules[(szone, dzone)] = [parse_rule_line(line) for line in lines]
    return rules


def filter_any_zonelist(rule, srcdst):
    """Parse rule[szone] list and return pos/neg boolean + zonelist."""
    if not rule[srcdst]:
        return False, []   # "not in empty list" == everything

    inside = True
    zonelist = []
    for item in rule[srcdst].split():
        if item.startswith('-'):
            if inside and zonelist:
                fail(f'{rule["fileline"]}Can\'t mix "+" and "-" items: '
                     f'{rule[srcdst]}')
            inside = False
            zonelist.append(item[1:])
        else:
            if not inside:
                fail(f'{rule["fileline"]}Can\'t mix "+" and "-" items: '
                     f'{rule[srcdst]}')
            zonelist.append(item)
    return inside, zonelist


def insert_single_any(any_rules, rules, szone, dzone):
    """Insert single any_rules to rules[(szone, dzone)]."""
    if szone == dzone == CONFIG['localhost_zone']:
        return  # Don't insert to localhost-localhost

    # Filter out "szone -public" when adding to zone "public-xxx"
    filtered = []
    for rule in any_rules:
        inside, zonelist = filter_any_zonelist(rule, 'szone')
        if (szone in zonelist) != inside:
            continue
        inside, zonelist = filter_any_zonelist(rule, 'dzone')
        if (dzone in zonelist) != inside:
            continue
        filtered.append(rule)

    # Insert filtered rules to beginning
    if filtered:
        rules[(szone, dzone)] = filtered + rules.get((szone, dzone), [])


def insert_any_zones(zones, rules):
    """Insert "any-zone", "zone-any" and "any-any" rules to "zone-zone" rules.

    These are inserted to beginning of zone-zone rules.
    """
    for zone in zones:
        any_rules = rules.pop(('any', zone), [])  # any-zone
        for szone in zones:
            insert_single_any(any_rules, rules, szone, zone)

        any_rules = rules.pop((zone, 'any'), [])  # zone-any
        for dzone in zones:
            insert_single_any(any_rules, rules, zone, dzone)

    any_rules = rules.pop(('any', 'any'), [])  # any-any
    for szone in zones:
        for dzone in zones:
            insert_single_any(any_rules, rules, szone, dzone)


def expand_templates(rules, templates):
    """Expand rule "template foo" in rules."""
    for zonepair in rules:
        index = 0
        while index < len(rules[zonepair]):
            # Is this rule "template foo"?
            template = rules[zonepair][index]['template']
            if not template:
                index += 1
                continue
            fileline = rules[zonepair][index]['fileline']

            # Replace current rule with template's content
            if template not in templates:
                fail(f'{fileline}Unknown template name: {template}')
            rules[zonepair] = (rules[zonepair][:index] +
                               templates[template] +
                               rules[zonepair][index + 1:])
            if len(rules[zonepair]) > int(CONFIG['recursion_limit']):
                fail(f'{fileline}Possible template loop: '
                     f'{zonepair[0]}-{zonepair[1]} template {template}')


def verify_config(config, zones, rules):
    """Verify config data."""
    if not zones:
        fail('No zones defined in section zone{}')

    localhost = CONFIG['localhost_zone']
    if localhost not in zones:
        zones[localhost] = {'interface': []}
        warning(f'{config["_section_line"]["zone"]}Zone "{localhost}" '
                f'is missing from zone{{}}, adding it')

    if zones[localhost]['interface']:
        fail(f'{config["_section_line"]["zone"]}Zone "{localhost}" has '
             f'interfaces "{" ".join(zones[localhost]["interface"])}", '
             f'it must be empty')

    if CONFIG['dbus_zone'] not in zones:
        warning(f'Config option dbus_zone value '
                f'"{CONFIG["dbus_zone"]}" is missing from zone{{}}')

    # All zone-zone pairs must be known
    for szone, dzone in rules:
        if szone not in zones or dzone not in zones:
            fileline = config['_section_line'][f'{szone}-{dzone}']
            fail(f'{fileline}Unknown zone-zone: {szone}-{dzone}')

    # Make sure all zone-zone pairs are defined. They are needed for
    # "ct established" return packets and for dynamic interface-to-zone
    # binding via D-Bus.
    # Add final rule to all zone-zone pairs, even if there already is
    # one. It will be optimized out later.
    for szone in zones:
        for dzone in zones:
            if (szone, dzone) not in rules:
                rules[(szone, dzone)] = []
            if szone == dzone == localhost:
                rule = ['accept']  # localhost-localhost is accept
            elif szone == localhost:
                rule = ['reject', 'log']  # localhost-foo is reject
            else:
                rule = ['drop', 'log']  # everything else is drop
            rules[(szone, dzone)].append(parse_rule_line(('', rule)))


def output_rate_names(rules):
    """Output empty saddr_rate sets to ruleset."""
    counter = 1
    already_added = set()
    for rulelist in rules.values():
        for rule in rulelist:
            for rate in ('saddr_rate', 'daddr_rate', 'saddr_daddr_rate'):
                if not rule[rate]:
                    continue

                # Rule with rate found. It can be pre-named or anonymous.
                setname = rule[f'{rate}_name']
                if setname in already_added:
                    continue  # Pre-named and already added
                if not setname:  # Anonymous - invent a name for it
                    setname = rule[f'{rate}_name'] = f'_rate_set_{counter}'
                    counter += 1
                already_added.add(setname)

                # Output empty sets for IPv4 and IPv6. These will have
                # one minute timeout.
                for ipv in (4, 6):
                    out(f'set {setname}_{ipv} {{')
                    if rate == 'saddr_daddr_rate':
                        out(f'type ipv{ipv}_addr . ipv{ipv}_addr')
                    else:
                        out(f'type ipv{ipv}_addr')
                    out(f'size {CONFIG["set_size"]}')
                    if rule[rate].startswith('ct '):
                        out('flags dynamic')
                    else:
                        timeout = '1h' if 'hour' in rule[rate] else '1m'
                        out('flags dynamic,timeout')
                        out(f'timeout {timeout}')
                    out('}')


def suffix_mask(value, compare):
    """Convert suffix mask "IP/-mask" to nft."""
    if not compare:
        compare = '== '
    addr, mask = value.split('/-')
    mask = hex(pow(2, int(mask)) - 1)[2:]  # As long hex
    splitted = ''  # Convert to ":1234" parts
    while mask:
        splitted = f':{mask[-4:]}{splitted}'
        mask = mask[:-4]
    return f'& :{splitted} {compare}{addr}'


def single_or_set(data, fileline='', quote=False):
    """Convert data to single item or set if multiple values."""
    # Convert to list
    if isinstance(data, list):
        values = data
    else:
        values = data.split()

    # Handle negative: add "!=" to final rule
    neg = ''
    for index, value in enumerate(values):
        if value.startswith('-'):
            if index and not neg:
                fail(f'{fileline}Can\'t mix "+" and "-" items: '
                     f'{" ".join(values)}')
            neg = '!= '
        elif neg:
            fail(f'{fileline}Can\'t mix "+" and "-" items: {" ".join(values)}')
        if neg:
            values[index] = value[1:]

    # Quote interface names and similar items. "inet" is a reserved word
    # but can also be an interface name. It must be quoted.
    if quote:
        values = [value if value.isnumeric() else f'"{value}"'
                  for value in values]

    # Single item
    if len(values) == 1 and ' ' not in values[0]:
        if '/-' in values[0]:
            return suffix_mask(values[0], neg)
        return neg + values[0]

    # nft doesn't support "saddr { @foo, @bar }"
    if any(value.startswith('@') for value in values):
        fail(f'{fileline}Only single @list can be used: {" ".join(values)}')

    # Multiple, use "{set}"
    return f'{neg}{{ {", ".join(sorted(set(values)))} }}'


def netmask_to_and(masklist, ipv, fileline):
    """Parse "masklist 24 56" rule and return "and 255.255.255.0 " string.

    First value is mask for IPv4 and second is for IPv6.
    """
    if not masklist:
        return ''
    masks = [int(item) for item in masklist.split() if item.isnumeric()]
    if len(masks) != 2 or masks[0] > 32 or masks[1] > 128:
        fail(f'{fileline}Invalid rate_mask: {masklist}')

    if ipv == 4:
        ipaddr = ipaddress.IPv4Network(f'0.0.0.0/{masks[0]}')
        return f'and {ipaddr.netmask} '

    ipaddr = ipaddress.IPv6Network(f'::/{masks[1]}')
    return f'and {ipaddr.netmask} '


def limit_rate_or_ct(rate):
    """Return "limit rate x" or "ct count x" according to rate."""
    if rate.startswith('ct '):
        return f'{rate} '
    return f'limit rate {rate} '


def rule_rate_limit(rule, ipv):
    """Return rule's rate limits as nft update-command."""
    if rule['global_rate']:
        return limit_rate_or_ct(rule['global_rate'])

    ret = ''
    for rate in ('saddr_rate', 'daddr_rate', 'saddr_daddr_rate'):
        rate_limit = rule[rate]
        if not rate_limit:
            continue
        rate_name = rule[f'{rate}_name']
        rate_mask = rule[f'{rate}_mask']

        # "update @foo { ip saddr "
        ret += 'add' if rate_limit.startswith('ct ') else 'update'
        ret += f' @{rate_name}_{ipv} {{ '
        if 'saddr' in rate:
            ret += f'{"ip" if ipv == 4 else "ip6"} saddr '
            ret += netmask_to_and(rate_mask, ipv, rule['fileline'])
        if rate == 'saddr_daddr_rate':
            ret += '. '
        if 'daddr' in rate:
            ret += f'{"ip" if ipv == 4 else "ip6"} daddr '
            ret += netmask_to_and(rate_mask, ipv, rule['fileline'])
        # "limit rate 3/second } "
        ret += f'{limit_rate_or_ct(rate_limit)}}} '
    return ret


def mark_set_argument(rule):
    """Convert "x" to "x", "x/y" to "mark and ~y or x"."""
    value = rule['mark_set']
    x_y = value.split('/')
    if len(x_y) > 2:
        fail(f'{rule["fileline"]}Invalid "mark_set" value: {value}')
    if len(x_y) == 1:
        return value

    # Convert "/0xff00" to "/0xffff00ff" so that same mask is used in
    # set and match operations in config. Nftables requires 0xffff00ff.
    try:
        mask = int(x_y[1], 0)  # dec or hex
    except ValueError:
        fail(f'{rule["fileline"]}Invalid "mark_set" value: {value}')
    return f'meta mark & {hex(0xffffffff ^ mask)} | {x_y[0]}'


def mark_match(rule):
    """Convert "x" to "== x", "x/y" to "and y == x".

    Negative "-x" can also be used to get "!= x".
    """
    value = rule['mark_match']
    if not value:
        return ''
    check = '=='
    if value.startswith('-'):
        check = '!='
        value = value[1:]
    x_y = value.split('/')
    if len(x_y) > 2:
        fail(f'{rule["fileline"]}Invalid "mark_match" value: '
             f'{rule["mark_match"]}')
    if len(x_y) == 1:
        return f'meta mark {check} {value} '
    return f'meta mark & {x_y[1]} {check} {x_y[0]} '


def time_only_one(rule, oldvalue, newvalue):
    """Return newvalue if oldvalue is not set, else fail."""
    if oldvalue or not newvalue:
        fail(f'{rule["fileline"]}Invalid "time" value: {rule["time"]}')
    return newvalue


def time_match_single(rule, compare, value):
    """Return single time compare+value as nft."""
    if not compare and not value:
        return ''
    time_only_one(rule, None, value)  # Check that value is set

    # Parse value to day/hour/time
    wday = None
    hour = None
    date = None
    for item in value:
        if item.lower() in ('monday', 'tuesday', 'wednesday', 'thursday',
                            'friday', 'saturday', 'sunday'):
            wday = time_only_one(rule, wday, item.capitalize())
        elif (
                re.fullmatch(r'\d\d:\d\d(:\d\d)?', item) or  # hh:mm:ss, hh:mm
                re.fullmatch(r'\d\d:\d\d-\d\d:\d\d', item)   # hh:mm-hh:mm
        ):
            hour = time_only_one(rule, hour, item)
        elif re.fullmatch(r'\d\d\d\d-\d\d-\d\d', item):  # yyyy-mm-dd
            date = time_only_one(rule, date, item)
        else:
            fail(f'{rule["fileline"]}Invalid "time" value: {rule["time"]}')

    # Output as nft
    if date and hour:  # Combine if both set
        date += f' {hour}'
        hour = None
    ret = ''
    if wday:
        ret += f'day {compare}"{wday}" '
    if date:
        ret += f'time {compare}"{date}" '
    if hour:
        if '-' in hour:  # hh:mm-hh:mm may not be in ", others must
            ret += f'hour {compare}{hour} '
        else:
            ret += f'hour {compare}"{hour}" '
    return ret


def time_match(rule):
    """Convert "time Saturday" to nft, supporting time/day/hour."""
    if not rule['time']:
        return ''
    compare = ''
    value = []
    ret = ''
    for item in rule['time'].split():
        if item in ('==', '!=', '<', '>', '<=', '>='):
            ret += time_match_single(rule, compare, value)
            compare = '' if item == '==' else f'{item} '
            value = []
        else:
            value.append(item)
    ret += time_match_single(rule, compare, value)
    return ret


def cgroup_match(rule):
    """Parse cgroup to nft."""
    cgroup = rule['cgroup']
    if not cgroup:
        return ''

    # cgroupv2: non-numeric, single value only
    if not re.match(r'[-0-9]', cgroup[0]) and ' ' not in cgroup:
        level = cgroup.count('/') + 1
        return f'socket cgroupv2 level {level} "{cgroup}" '

    # cgroup
    return f'meta cgroup {single_or_set(cgroup, rule["fileline"])} '


def rule_statement(szone, dzone, rule, ipv, *, force_statement=None,
                   do_lograte=True):
    """Return rule's rate, log and statement as nft command."""
    # pylint: disable=too-many-arguments
    statement = force_statement or rule['statement']

    # queue can have optional flags
    if statement == 'queue' and rule[statement]:
        statement = f'{statement} {rule[statement]}'

    # Rate, counter and log goes before statement
    prefix = rule_rate_limit(rule, ipv)

    # "counter" in single rule line adds counters to it.
    #
    # "foomuuri { counter xxx }" can be used to add counters to all rules:
    #   yes        - add to all rules in all zone-zone
    #   zone-zone  - add to all rules in single zone-zone
    #   zone-any   - add to all rules in all zone-*
    #   any-zone   - add to all rules in all *-zone
    # Multiple zone-pairs can be defined
    counterlist = CONFIG['counter'].split()
    if not rule['nft'] and (  # pylint: disable=too-many-boolean-expressions
            rule['counter'] is not None or  # "counter" in this rule
            'yes' in counterlist or  # global "yes"
            f'{szone}-{dzone}' in counterlist or  # matching zone-zone
            f'{szone}-any' in counterlist or
            f'any-{dzone}' in counterlist
    ):
        prefix += 'counter '
        if rule['counter']:
            prefix += f'name "{rule["counter"]}" '

    # "log" in rule will log all packets matching this rule. This is usually
    # used in final "drop log" rule. Default log text is "zone-zone STATEMENT".
    if rule['log'] is not None or rule['log_level'] is not None:
        log_prefix = rule['log'] or (f'{szone}-{dzone} '
                                     f'{statement.split()[0].upper()}')
        log_level = (CONFIG['log_level'] if rule['log_level'] is None else
                     rule['log_level'])
        log_nft = f'log prefix "{log_prefix} " {log_level}'
        if do_lograte and CONFIG['log_rate']:
            # Limit maximum amount of logging to "foomuuri { log_rate }".
            # This is important to avoid overlogging (DoS or filesystem full).
            rate = (f'update @_lograte_set_{ipv} '
                    f'{{ {"ip" if ipv == 4 else "ip6"} saddr '
                    f'limit rate {CONFIG["log_rate"]} }} ')
            if statement == 'continue':
                return f'{prefix}{rate}{log_nft}'
            logname = f'lograte_{len(LOGRATES) + 1}'
            LOGRATES[logname] = (f'{rate}{log_nft}', statement)
            return f'{prefix}jump {logname}'
        prefix += f'{log_nft} '

    return prefix + statement


def output_icmp(szone, dzone, rules, ipv):
    """Find and parse icmp and icmpv6 rules.

    These must be handled before ct as "ct established" would accept
    ping floods. Default is to drop pings.
    """
    has_ping_rule = False
    has_match_all = False
    if ipv == 4:
        icmp = 'icmp'
        ping = '8'
    else:
        icmp = 'icmpv6'
        ping = '128'
    for rule in rules:
        if rule['protocol'] != icmp:
            continue

        match = rule_matchers(rule, ipv, skip_icmp=False)
        if match is None:
            continue
        statement = rule_statement(szone, dzone, rule, ipv)

        proto_ports = parse_protocol_ports(rule, ipv, skip_icmp=False)
        out(f'{proto_ports}{match}{statement}')
        if (
                rule['dport'] and
                ping not in rule['dport'].split() and
                'echo-request' not in rule['dport'].split()
        ):
            continue  # Continue to next rule if this wasn't ping or match all

        # This rule was for ping, usually accepting non-flood pings. Add
        # explicit rule to drop overflow and all other pings.
        has_ping_rule = True
        if (match + statement).startswith(
                ('accept', 'drop', 'reject', 'jump', 'queue')):
            has_match_all = True
        elif has_match_all:  # Specific ping rule after match-all rule
            warning(f'{rule["fileline"]}Unreachable ping rule')

    # Overflow-pings must be dropped before ct
    if has_ping_rule and not has_match_all:
        out(f'{icmp} type echo-request drop')

    # Allow needed icmp
    out(f'jump allow_icmp_{ipv}')


def parse_iplist(rule, direction, ipv):
    """Parse IP address list in rule[direction] to nft rule."""
    iplist = rule[direction]
    if not iplist:
        return ''

    ips = []
    for item in iplist.split():
        if item.startswith(('@', '-@')):  # "@foo" to "@foo_4", used by resolve
            ips.append(f'{item}_{ipv}')
        else:
            ipv_addr = is_ip_address(item)
            if ipv_addr == ipv:  # Address for this ipv - add to list
                ips.append(item)
            elif not ipv_addr:  # Invalid IP address
                fail(f'{rule["fileline"]}Invalid IP address "{item}" in: '
                     f'{iplist}')

    # No matching addresses for this ipv family
    if not ips:
        raise ValueError

    # Return "ip saddr 10.2.3.4 " string
    return (f'{"ip" if ipv == 4 else "ip6"} {direction} '
            f'{single_or_set(ips, rule["fileline"])} ')


def parse_maclist(rule, direction):
    """Parse MAC address list in rule[direction] to nft rule."""
    maclist = rule[direction]
    if not maclist:
        return ''

    macs = []
    for item in maclist.split():
        item = item.lower()
        if re.match(r'^(-)?([0-9a-f]{2}:){5}[0-9a-f]{2}$', item):
            macs.append(item)
        else:
            fail(f'{rule["fileline"]}Invalid MAC address "{item}" in: '
                 f'{maclist}')

    # Return "ether saddr 0a:00:27:00:00:00 " string
    return f'ether {direction[4:]} {single_or_set(macs, rule["fileline"])} '


def parse_interface_names(rule):
    """Parse iifname/oifname to nft rule."""
    iifname = ''
    if rule['iifname']:
        iifname = f'iifname "{rule["iifname"]}" '
    oifname = ''
    if rule['oifname']:
        oifname = f'oifname "{rule["oifname"]}" '
    return iifname + oifname


def parse_protocol_ports(rule, ipv, skip_icmp=True):
    """Parse tcp/udp sport/dport to nft rule.

    This can also handle rules like:
    - "tcp" without dport to nft "protocol tcp"
    - "protocol esp" to nft "protocol esp"
    - "protocol esp 123" to nft "esp spi 123"
    - "protocol vlan 123" to nft "vlan id 123"
    """
    protocol = rule['protocol']
    if not protocol or (skip_icmp and protocol in ('icmp', 'icmpv6')):
        # Protocol is empty for rules like "drop log" and "dnat"
        # icmp is handled in output_icmp()
        return ''
    if ipv == 6 and protocol == 'igmp':
        return None  # IPv6 uses Multicast Listener Discovery ICMP

    ports = ''
    for key in ('sport', 'dport'):
        if rule[key]:
            protokey = key
            if key == 'dport':  # Change "dport" to protocol-specific key
                protokey = {
                    'ip': 'protocol',
                    'ip6': 'nexthdr',
                    'ah': 'spi',
                    'esp': 'spi',
                    'comp': 'nexthdr',
                    'icmp': 'type',
                    'icmpv6': 'type',
                    'dst': 'nexthdr',
                    'frag': 'nexthdr',
                    'hbh': 'nexthdr',
                    'mh': 'nexthdr',
                    'rt': 'nexthdr',
                    'vlan': 'id',
                    'arp': 'htype',
                }.get(protocol, protokey)
            ports += (f'{protocol} {protokey} '
                      f'{single_or_set(rule[key], rule["fileline"])} ')
    if ports:
        return ports
    return f'{"ip protocol" if ipv == 4 else "ip6 nexthdr"} {protocol} '


def parse_to(rule, ipv):
    """Parse snat/dnat "to" rule to nft rule."""
    if not rule['to']:
        return ''
    if rule['statement'] == 'queue':  # "to 3", "to 1-3", "to numgen", ...
        return f' to {rule["to"]}'

    # "to" can be IPv4+IPv6, find correct one
    target = []
    for check in rule['to'].split():
        if check.count(':') == 1 and is_ip_address(check.split(':')[0]) == 4:
            check_ipv = 4  # IPv4 address with port
        elif (
                check.startswith('[') and
                ']:' in check and
                is_ip_address(check[1:].split(']:')[0]) == 6
        ):
            check_ipv = 6  # IPv6 address with port
        else:
            check_ipv = is_ip_address(check)
        if check_ipv == 0:
            fail(f'{rule["fileline"]}Invalid IP address in "to": {check}')
        if check_ipv == ipv:
            target.append(check)
    if not target:  # Nothing found for this ipv, don't generate rule
        return None
    if len(target) > 1:
        fail(f'{rule["fileline"]}Multiple "to" targets: {" ".join(target)}')
    return f' {"ip" if ipv == 4 else "ip6"} to {target[0]}'


def rule_matchers(rule, ipv, *, cast=None, skip_options=True, skip_icmp=True):
    """Parse rule's matchers to nft rule."""
    # pylint: disable=too-many-branches
    # pylint: disable=too-many-return-statements
    if cast is not None and rule['cast'] != cast:
        return None  # multi/broadcast doesn't match
    if ipv == 6 and rule['cast'] == 'broadcast':
        return None  # broadcast is ipv4 only
    if skip_icmp and rule['protocol'] in ('icmp', 'icmpv6'):
        return None
    if skip_options and rule['mss']:
        return None

    # IPv4/6 specific rule?
    if ipv == 4 and rule['ipv6'] and not rule['ipv4']:
        return None
    if ipv == 6 and rule['ipv4'] and not rule['ipv6']:
        return None

    # Convert matchers to nft
    castmeta = ''
    if cast and rule['cast'] != 'unicast':
        castmeta = f'meta pkttype {rule["cast"]} '

    ipsecmeta = ''
    if rule['sipsec']:
        ipsecmeta += f'meta ipsec {rule["sipsec"]} '
    if rule['dipsec']:
        ipsecmeta += f'rt ipsec {rule["dipsec"]} '

    ct_status = ''
    if rule['ct_status']:
        ct_status = f'ct status {rule["ct_status"]} '

    priority_match = ''
    if rule['priority_match']:
        priority_match = f'meta priority "{rule["priority_match"]}" '

    uid = ''
    if rule['uid']:
        uid = (f'meta skuid '
               f'{single_or_set(rule["uid"], rule["fileline"], quote=True)} ')
    gid = ''
    if rule['gid']:
        gid = (f'meta skgid '
               f'{single_or_set(rule["gid"], rule["fileline"], quote=True)} ')

    meta_set = ''
    if rule['mark_set']:
        meta_set += (f'meta mark set {mark_set_argument(rule)} '
                     f'ct mark set meta mark ')
    if rule['priority_set']:
        meta_set += f'meta priority set "{rule["priority_set"]}" '

    ifname = parse_interface_names(rule)

    addrlist = ''
    try:
        addrlist += parse_iplist(rule, 'saddr', ipv)
        addrlist += parse_iplist(rule, 'daddr', ipv)
        addrlist += parse_maclist(rule, 'mac_saddr')
        addrlist += parse_maclist(rule, 'mac_daddr')
    except ValueError:
        return None

    proto_ports = parse_protocol_ports(rule, ipv)
    if proto_ports is None:
        return None

    # Return matcher string
    return (f'{ipsecmeta}{ct_status}{castmeta}{ifname}{addrlist}{proto_ports}'
            f'{cgroup_match(rule)}{time_match(rule)}{mark_match(rule)}'
            f'{priority_match}{uid}{gid}{meta_set}')


def output_cast(cast, szone, dzone, rules, ipv, *, after_conntrack=True):
    """Output all uni/multi/broadcast rules for single zone-zone."""
    # pylint: disable=too-many-arguments
    has_mark_restore = False
    for rule in rules:
        if rule['after_conntrack'] != after_conntrack:
            continue
        match = rule_matchers(rule, ipv, cast=cast)
        if match is None:
            continue
        if match == '' and cast is None and rule['cast'] != 'unicast':
            continue  # Don't convert "multicast accept" to nft "accept"
        statement = rule_statement(szone, dzone, rule, ipv,
                                   force_statement=rule['nft'])

        # Automatically restore mark from conntrack before mark match or set
        # is used. Mark set needs it too for OR-operations.
        if not has_mark_restore and (rule['mark_match'] or rule['mark_set']):
            has_mark_restore = True
            out('meta mark set ct mark')

        out(f'{match}{statement}')

        # Does this rule need kernel helper?
        if rule['helper']:
            if rule['helper'].count('-') != 1:
                fail(f'{rule["fileline"]}Invalid helper name: '
                     f'{rule["helper"]}')
            HELPERS.append((rule['helper'], rule['protocol'], rule['dport']))
            kernelname = rule['helper'].split('-')[0].replace('_', '-')
            out(f'ct helper \"{kernelname}\" {rule["statement"]}')


def output_zonemap(zonemap, szone, dzone, ipv):
    """Output zonemap{} rules for this szone-dzone."""
    for rule in zonemap:
        if rule['szone'] and szone not in rule['szone'].split():
            continue
        if rule['dzone'] and dzone not in rule['dzone'].split():
            continue
        new_szone = rule['new_szone'] or szone
        new_dzone = rule['new_dzone'] or dzone
        if new_szone == szone and new_dzone == dzone:
            continue

        match = rule_matchers(rule, ipv)
        if match is None:
            continue
        out(f'{match}jump {new_szone}-{new_dzone}_{ipv}')


def output_options(rules, ipv):
    """Output "mss" etc options."""
    for rule in rules:
        if rule['mss'] and ipv == 4:
            match = rule_matchers(rule, ipv, skip_options=False)
            out(f'{match}tcp flags syn tcp option '
                f'maxseg size set {rule["mss"]}')


def output_zone(zonemap, szone, dzone, rules, ipv):
    """Output single zone-zone_ipv4 nft chain."""
    # Header + zonemap jumps + options
    out(f'chain {szone}-{dzone}_{ipv} {{')
    output_zonemap(zonemap, szone, dzone, ipv)
    output_options(rules, ipv)

    # Rules with "-conntrack" or plain "log"/"counter" rule. Output these
    # before conntrack and icmp so that they see all traffic.
    output_cast(None, szone, dzone, rules, ipv, after_conntrack=False)

    # ICMP is special, keep it before ct
    output_icmp(szone, dzone, rules, ipv)

    # Connection tracking
    out('ct state vmap {')
    out('established : accept,')
    out('related : accept,')
    out('invalid : jump invalid_drop,')
    out(f'new : jump smurfs_{ipv},')
    out(f'untracked : jump smurfs_{ipv}')
    out('}')

    # Allow outgoing IGMP multicast membership reports and incoming IGMP
    # multicast query.
    output_chain = szone == CONFIG['localhost_zone']
    input_chain = dzone == CONFIG['localhost_zone']
    if ipv == 4 and output_chain and not input_chain:
        out('ip protocol igmp ip daddr 224.0.0.22 accept')  # membership report
    if ipv == 4 and input_chain and not output_chain:
        out('ip protocol igmp ip daddr 224.0.0.1 accept')   # query

    # Broadcast and multicast.
    #
    # "meta pkttype" works only for incoming packets so skip these if
    # szone=localhost (outgoing).
    #
    # "meta pkttype" works also for forwarding packets so write those rules
    # too (see below for zonemap reason). Multicast can't really be forwarded
    # as it is local to ip/netmask. Proxying is ok, where software listens one
    # interface and writes it to another.
    if not output_chain:
        output_cast('multicast', szone, dzone, rules, ipv)
        output_cast('broadcast', szone, dzone, rules, ipv)
        if ipv == 4:
            out('meta pkttype { broadcast, multicast } drop')
        else:
            out('meta pkttype multicast drop')

    # Unicast.
    #
    # Broadcast/multicast is already handled above for incoming packets so
    # output only unicast here for incoming.
    #
    # For forward/output add multicast rules without multicast matcher. This
    # means that for forward packets both with and without "meta pkttype"
    # rules will be outputted. This is needed for complex zonemap mangling
    # where szone=myservice might actually be from localhost.
    output_cast('unicast' if input_chain else None, szone, dzone, rules, ipv)
    out('}')


def output_zone_vmaps(zones, rules):
    """Output interface verdict maps to jump to correct zone-zone."""
    # pylint: disable=too-many-branches

    # Vmap must have interval-flag if there is wildcard-interface.
    localhost = CONFIG['localhost_zone']
    has_wildcard = False
    for value in zones.values():
        for interface in value['interface']:
            if '*' in interface:
                has_wildcard = True

    # Incoming zones
    out('map input_zones {')
    out('type ifname : verdict')
    if has_wildcard:
        out('flags interval')
    out('elements = {')
    out('"lo" : accept,')
    for zone, value in zones.items():
        for interface in value['interface']:
            out(f'"{interface}" : jump {zone}-{localhost},')
    out('}')
    out('}')

    # Outgoing zones
    out('map output_zones {')
    out('type ifname : verdict')
    if has_wildcard:
        out('flags interval')
    out('elements = {')
    if len(rules[(localhost, localhost)]) > 1:  # Jump to lo-lo if it has rules
        out(f'"lo" : jump {localhost}-{localhost},')
    else:
        out('"lo" : accept,')
    for zone, value in zones.items():
        for interface in value['interface']:
            out(f'"{interface}" : jump {localhost}-{zone},')
    out('}')
    out('}')

    # Forwarding zones
    out('map forward_zones {')
    out('type ifname . ifname : verdict')
    if has_wildcard:
        out('flags interval')
    out('elements = {')
    out('"lo" . "lo" : accept,')
    for szone, svalue in zones.items():
        for dzone, dvalue in zones.items():
            for sinterface in svalue['interface']:
                for dinterface in dvalue['interface']:
                    out(f'"{sinterface}" . "{dinterface}" : '
                        f'jump {szone}-{dzone},')
    out('}')
    out('}')


def output_zone2zone_rules(rules, zonemap):
    """Output all zone-zone rules for both IPv4 and IPv6."""
    for szone, dzone in rules:
        # Split to zone-zone_4 and zone-zone_6
        out(f'chain {szone}-{dzone} {{')
        out('meta nfproto vmap {')
        out(f'ipv4 : jump {szone}-{dzone}_4,')
        out(f'ipv6 : jump {szone}-{dzone}_6')
        out('}')
        out('}')

        # IPv4 and IPv6 chains
        output_zone(zonemap, szone, dzone, rules[(szone, dzone)], 4)
        output_zone(zonemap, szone, dzone, rules[(szone, dzone)], 6)


def output_rule_section_no_header(rules, section):
    """Output snat, dnat, prerouting, postrouting, etc. rules inside chain."""
    if not rules:
        return

    has_mark_restore = False
    ip_merger = set()
    for rule in rules:
        for ipv in (4, 6):
            to_rule = parse_to(rule, ipv)
            if to_rule is None:
                continue
            match = rule_matchers(rule, ipv)
            if match is None:
                continue
            statement = rule_statement(section.upper(), '', rule, ipv,
                                       force_statement=rule['nft'],
                                       do_lograte=False)

            # Automatically restore mark from conntrack when needed
            if (
                    not has_mark_restore and
                    (rule['mark_match'] or rule['mark_set'])
            ):
                has_mark_restore = True
                out('meta mark set ct mark')

            # There are no separate IPv4/IPv6 chains so merge possible rules
            full_rule = f'{match}{statement}{to_rule}'
            if full_rule in ip_merger:
                continue
            ip_merger.add(full_rule)
            out(full_rule)


def output_rule_section(rules, section):
    """Output snat, dnat, prerouting postrouting, etc. chain + rules."""
    # Output-chain must restore mark from conntrack if mark_set was used
    # for locally generated packets (usually in multi-ISP's prerouting).
    #
    # Simply restore mark if mark_set was used in any chain. For that reason
    # output-chain should be outputted last.
    need_mark_restore = section == 'output' and any(
        'ct mark set' in line for line in OUT)

    if not rules and not need_mark_restore:
        return

    hooktype = {
        'snat': 'type nat hook postrouting priority srcnat',
        'dnat': 'type nat hook prerouting priority dstnat',
        'prerouting': 'type filter hook prerouting priority mangle',
        'postrouting': 'type filter hook postrouting priority mangle',
        'forward': 'type filter hook forward priority mangle',
        'output': 'type route hook output priority mangle',
    }.get(section)
    hooktype += CONFIG['priority_offset']

    chain_name = hooktype.split()
    out(f'chain {chain_name[1]}_{chain_name[3]}_{chain_name[5]} {{')
    out(hooktype)
    if need_mark_restore:
        out('meta mark set ct mark')
    output_rule_section_no_header(rules, section)
    out('}')


def output_static_chain_logging(chain, statement):
    """Output logging rules for input/output/forward/invalid/smurfs chains."""
    lograte = CONFIG[f'log_{chain}']  # Enabled in foomuuri{}
    if lograte.lower() == 'no':
        return
    if lograte.lower() == 'yes':  # "yes" means use standard log rate
        lograte = CONFIG['log_rate']
    flags = ' flags ip options' if chain == 'invalid' else ''
    flags += f' {CONFIG["log_level"]}'
    chain = chain.upper()
    statement = statement.upper()

    if not lograte:
        out(f'log prefix "{chain} {statement} "{flags}')
        return

    out(f'update @_lograte_set_4 {{ ip saddr limit rate {lograte} }} '
        f'log prefix "{chain} {statement} "{flags}')
    out(f'update @_lograte_set_6 {{ ip6 saddr limit rate {lograte} }} '
        f'log prefix "{chain} {statement} "{flags}')


def output_header(chain_rules):
    """Output generic nft header."""
    command_stop(False)  # Stop previous foomuuri
    out('')
    out('table inet foomuuri {')  # Add new foomuuri
    out('')

    # Insert include files
    # pylint: disable=no-member  # rglob, read_text
    for filename in (sorted(list(CONFIG['share_dir'].rglob('*.nft'))) +
                     sorted(list(CONFIG['etc_dir'].rglob('*.nft')))):
        try:
            lines = filename.read_text('utf-8').splitlines()
        except PermissionError as error:
            fail(f'File {filename}: Can\'t read: {error}')
        for line in lines:
            line = line.strip()
            if line:
                out(line)

    # Logging chains
    for chain in ('invalid', 'smurfs', 'rpfilter'):
        out(f'chain {chain}_drop {{')
        output_rule_section_no_header(chain_rules[chain], chain)
        if chain == 'rpfilter':
            out('udp sport 67 udp dport 68 return')
        output_static_chain_logging(chain, 'drop')
        out('drop')
        out('}')

    # input/output/forward jump chains
    out('chain input {')
    out(f'type filter hook input priority filter{CONFIG["priority_offset"]}')
    out('iifname vmap @input_zones')
    output_static_chain_logging('input', 'drop')
    out('drop')
    out('}')

    out('chain output {')
    out(f'type filter hook output priority filter{CONFIG["priority_offset"]}')
    out('oifname vmap @output_zones')
    # IGMP membership report and IPv6 equivalent must be allowed here too as
    # D-Bus interface change event might not be processed yet.
    out('ip protocol igmp ip daddr 224.0.0.22 accept')
    out('ip6 saddr :: icmpv6 type mld2-listener-report accept')
    output_static_chain_logging('output', 'reject')
    out('reject with icmpx admin-prohibited')
    out('}')

    out('chain forward {')
    out(f'type filter hook forward priority filter{CONFIG["priority_offset"]}')
    out('iifname . oifname vmap @forward_zones')
    output_static_chain_logging('forward', 'drop')
    out('drop')
    out('}')


MERGES = [  # Preferred order for rules
    'meta pkttype multicast udp dport',
    'meta pkttype broadcast udp dport',
    'udp dport',
    'udp sport',
    'tcp dport',
    'tcp sport',
    'ct helper',
]


def merge_accepts(accepts, linenum):
    """Sort and merge found accept rules."""
    merge = {key: [] for key in MERGES[::-1]}
    ret = 0
    for accept in accepts:
        for key, ports in merge.items():
            regex = f'^{key} (\\{{ )?([-\\d, ]+)( \\}})? accept$'
            match = re.match(regex, accept)
            if match:  # Add "22" from "tcp dport 22 accept" to merged
                ports.append(match.group(2))
                break
        else:  # Can't merge, output as is
            OUT.insert(linenum + ret, accept)
            ret += 1

    # Output merged
    for key, ports in merge.items():
        if ports:
            OUT.insert(linenum, f'{key} {single_or_set(ports)} accept')
            ret += 1
    return ret


def optimize_accepts():
    """Optimize ruleset accepts.

    This will change multiple accepts to single accept using set.
    """
    accepts = []
    linenum = 0
    while linenum < len(OUT):
        line = OUT[linenum]
        if line == 'continue':  # No-op line generated by plain "counter"
            del OUT[linenum]
        elif (
                line.endswith(' accept') and
                line.startswith(tuple(MERGES) + ('ip ', 'ip6 '))
        ):
            accepts.append(line)
            del OUT[linenum]
        else:
            linenum += merge_accepts(accepts, linenum) + 1
            accepts = []


def optimize_jumps():
    """Optimize lograte jumps in ruleset.

    This will change zone-zone's final "drop log" rule to optimized version.
    """
    linenum = 0
    while linenum < len(OUT):
        line = OUT[linenum]
        if line.startswith('jump lograte_'):
            logname = line.split()[1]
            log_nft, statement = LOGRATES.pop(logname)
            OUT[linenum] = log_nft
            OUT.insert(linenum + 1, statement)
        linenum += 1


def optimize_final_rules():
    """Remove unreachable rules from chain after "drop" without matcher."""
    linenum = 0
    while linenum < len(OUT):
        if OUT[linenum].startswith(('accept', 'drop', 'reject', 'queue')):
            while OUT[linenum + 1] != '}':
                del OUT[linenum + 1]
        linenum += 1


def output_logrates():
    """Output non-optimized lograte entries as chains."""
    for logname, (log_nft, statement) in LOGRATES.items():
        out(f'chain {logname} {{')
        out(log_nft)
        out(statement)
        out('}')

    # Output empty lograte sets used by "foomuuri { log_rate }".
    for ipv in (4, 6):
        out(f'set _lograte_set_{ipv} {{')
        out(f'type ipv{ipv}_addr')
        out(f'size {CONFIG["set_size"]}')
        out('flags dynamic,timeout')
        out('timeout 1m')
        out('}')


def output_resolve_sets(resolve, automerge=False):
    """Output empty resolve{} and iplist{} sets."""
    for name in resolve:
        if not name.startswith('@'):
            continue
        out(f'set {name[1:]} {{')
        out(f'type ipv{name[-1]}_addr')
        out('flags interval,timeout')
        if automerge:
            out('auto-merge')
        out('}')


def output_resolve_elements(resolve, statefile):
    """Add resolve{} and iplist{} elements from state file to ruleset."""
    # Read previous resolve results
    filename = CONFIG[statefile]
    try:
        # pylint: disable=no-member
        content = filename.read_text(encoding='utf-8')
    except FileNotFoundError:
        return
    except PermissionError as error:
        fail(f'File {filename}: Can\'t read: {error}')

    # Known resolve names in current config files
    known = [name[1:] for name in resolve if name.startswith('@')]
    if not known:
        return

    # Add previous result if resolve name is known
    out('')
    now = datetime.datetime.now(datetime.timezone.utc)
    for line in content.splitlines():
        if line.startswith('# '):  # Try "didn't exist in active ruleset" now
            line = line[2:]
        tokens = line.split()
        if (
                line.startswith('add element inet foomuuri ') and
                len(tokens) >= 10 and
                tokens[7] == 'timeout' and
                tokens[4] in known  # It is known name
        ):
            # Check optional expire timestamp, used in iplist-manual.fw
            if len(tokens) == 12 and tokens[10] == '#':
                try:
                    expire = datetime.datetime.strptime(tokens[11],
                                                        '%Y-%m-%dT%H:%M:%S')
                    expire = expire.replace(tzinfo=datetime.timezone.utc)
                except ValueError:
                    continue
                # Skip expired entries, update timeout for rest
                seconds = int((expire - now).total_seconds())
                if seconds <= 0:
                    continue
                line = f'{" ".join(tokens[:8])} {seconds}s }}'

            out(line)


def output_named_counters(rules, chain_rules):
    """Output named counters."""
    # Collect all counter names
    names = set()
    for rulelist in list(rules.values()) + list(chain_rules.values()):
        for rule in rulelist:
            if rule['counter']:
                names.add(rule['counter'])

    # Output counters
    for name in sorted(names):
        out(f'counter {name} {{')
        out('}')


def output_helpers():
    """Output helpers."""
    # Convert helper list to helper->proto->set(ports) dict
    helpers = {}
    for name, proto, ports in HELPERS:
        if name not in helpers:
            helpers[name] = {}
        if proto not in helpers[name]:
            helpers[name][proto] = set()
        for port in ports.split():
            helpers[name][proto].add(port)
    if not helpers:
        return

    # Output "ct helper" lines
    for name, protos in helpers.items():
        kernelname = name.split('-')[0].replace('_', '-')
        out(f'ct helper {name} {{')
        for proto in protos:
            out(f'type \"{kernelname}\" protocol {proto}')
        out('}')

    # Output prerouting
    out('chain helper {')
    out(f'type filter hook prerouting priority filter'
        f'{CONFIG["priority_offset"]}')
    for name, protos in helpers.items():
        for proto, ports in protos.items():
            out(f'{proto} dport {single_or_set(" ".join(ports))} '
                f'ct helper set \"{name}\"')
    out('}')


def output_rpfilter():
    """Prerouting chain to check rpfilter."""
    if CONFIG['rpfilter'] == 'no':
        return
    out('chain rpfilter {')
    out(f'type filter hook prerouting priority filter'
        f'{CONFIG["priority_offset"]}')
    interfaces = ''
    if CONFIG['rpfilter'] != 'yes':  # Specific interfaces?
        interfaces = (f'iifname '
                      f'{single_or_set(CONFIG["rpfilter"], quote=True)} ')
    out(f'{interfaces}fib saddr . mark . iif oif 0 meta ipsec missing '
        f'jump rpfilter_drop')
    out('}')


def output_footer():
    """Output generic ruleset footer."""
    out('}')


def save_file(filename, lines):
    """Write lines to file."""
    try:
        filename.unlink(missing_ok=True)
        filename.write_text('\n'.join(lines) + '\n', 'utf-8')
        filename.chmod(0o600)
    except PermissionError as error:
        fail(f'File {filename}: Can\'t write: {error}')
    except FileNotFoundError:  # Simultaneos saves and chmod gives error
        pass


def env_cleanup(text):
    """Allow only letters and numbers in text for environment variable."""
    # Convert ä->a as isalpha('ä') is true
    value = unicodedata.normalize('NFKD', text)
    value = value.encode('ASCII', 'ignore').decode('utf-8')

    # Remove non-alphanumeric chars
    return ''.join(char if char.isalnum() else '_' for char in value)


def save_final(filename):
    """Save final ruleset to file."""
    # Convert to indented lines
    indent = 0
    lines = []
    for line in OUT:
        if line.startswith('}'):
            indent -= 1
        if line:
            line = '\t' * indent + line
        lines.append(line)
        if line == '\t}':
            lines.append('')
        if line.endswith('{'):
            indent += 1

    # Save to "next" file
    save_file(filename, lines)


def signal_childs():
    """Signal foomuuri-dbus and foomuuri-monitor to reload."""
    for child in ('dbus', 'monitor'):
        # Read pid
        filename = CONFIG['run_dir'] / f'foomuuri-{child}.pid'
        try:
            pid = int(filename.read_text(encoding='utf-8'))
        except PermissionError as error:
            fail(f'File {filename}: Can\'t read: {error}')
        except (FileNotFoundError, ValueError):
            continue

        # Send reload-signal
        try:
            os.kill(pid, signal.SIGHUP)
        except OSError:
            pass


def apply_final():
    """Use final ruleset."""
    # Check config
    if CONFIG['command'] == 'check':
        if CONFIG['root_power']:  # "nft check" requires root
            ret = run_program_rc(CONFIG['nft_bin'] + ['--check', '--file',
                                                      CONFIG['next_file']])
        else:
            ret = 0
            warning('Not running as "root", skipping "nft check"')
        if ret:
            print(f'Error: Nftables failed to check ruleset, error code {ret}')
        else:
            print('check success')
        return ret

    # Run pre_start / pre_stop hook
    run_program_rc(CONFIG.get(f'pre_{CONFIG["command"]}'))

    # Load "next"
    ret = run_program_rc(CONFIG['nft_bin'] + ['--file', CONFIG['next_file']],
                         print_output=False)

    # Check failure
    if ret:
        print(f'Error: Failed to load ruleset to nftables, error code {ret}')
        return 1

    # Success. Rename "next" to "good", signal dbus to reload and run hook
    if CONFIG['command'] == 'start':
        # pylint: disable=no-member
        CONFIG['good_file'].unlink(missing_ok=True)
        CONFIG['next_file'].rename(CONFIG['good_file'])
    signal_childs()
    run_program_rc(CONFIG.get(f'post_{CONFIG["command"]}'))
    print(f'{CONFIG["command"]} success')
    return 0


def command_start():
    """Process "start" or "check" command."""
    # Read full config
    config = minimal_config()
    zones = parse_config_zones(config)
    zonemap = parse_config_zonemap(config)
    resolve = parse_resolve(config, 'resolve')
    iplist = parse_resolve(config, 'iplist')
    chain_rules = {}
    for name in ('snat', 'dnat',
                 'prerouting', 'postrouting',
                 'forward', 'output',
                 'invalid', 'rpfilter', 'smurfs'):
        chain_rules[name] = parse_config_rule_section(config, name)
    templates = parse_config_templates(config)
    parse_config_groups(config, parse_config_targets(config))
    parse_config_hook(config)
    rules = parse_config_rules(config)  # Also verify for unknown sections
    insert_any_zones(zones, rules)
    expand_templates(rules, templates)
    verify_config(config, zones, rules)

    # Generate output
    output_header(chain_rules)
    output_rate_names(rules)
    output_zone_vmaps(zones, rules)
    output_zone2zone_rules(rules, zonemap)
    for name in ('snat', 'dnat',
                 'prerouting', 'postrouting',
                 'forward', 'output'):
        output_rule_section(chain_rules[name], name)
    optimize_jumps()
    optimize_final_rules()
    optimize_accepts()
    output_logrates()
    output_resolve_sets(resolve)
    output_resolve_sets(iplist, automerge=True)
    output_named_counters(rules, chain_rules)
    output_helpers()
    output_rpfilter()
    output_footer()
    output_resolve_elements(resolve, 'resolve_file')
    output_resolve_elements(iplist, 'iplist_file')
    output_resolve_elements(iplist, 'iplist_manual_file')

    # Save known zones to file
    save_file(CONFIG['zone_file'], zones.keys())


def command_stop(parse_config=True):
    """Process "stop" command. This will remove all foomuuri rules."""
    if parse_config:  # Needed for pre_stop and post_stop hooks
        config = minimal_config()
        parse_config_hook(config)
    out('table inet foomuuri')
    out('delete table inet foomuuri')


def command_block():
    """Load "block all traffic" ruleset."""
    minimal_config()
    return run_program_rc(CONFIG['nft_bin'] + [
        '--file', CONFIG['share_dir'] / 'block.fw'])


def parse_active_interface_zone():
    """Parse current interface->zone mapping from active nft ruleset."""
    data = nft_json('list map inet foomuuri input_zones')
    if not data:
        return {}
    ret = {}
    for item in data['nftables']:
        if 'map' in item:
            for interface, rule in item['map']['elem']:
                if interface != 'lo':
                    ret[interface] = rule['jump']['target'].split('-')[0]
    return ret


def command_status():
    """Print if Foomuuri is running, zone<->mapping, etc."""
    # Get minimal config and interface status
    config = minimal_config()
    zones = parse_config_zones(config)
    zone_interface = parse_active_interface_zone()

    # Running
    ruleset = nft_json('list table inet foomuuri')
    if not ruleset:
        fail('Foomuuri is not running')
    print('Foomuuri is running')

    # D-Bus, Monitor
    for child in ('dbus', 'monitor'):
        filename = CONFIG['run_dir'] / f'foomuuri-{child}.pid'
        try:
            pid = int(filename.read_text(encoding='utf-8'))
            os.kill(pid, 0)
            print(f'Foomuuri-{child} is running, PID {pid}')
        except (FileNotFoundError, ValueError, PermissionError,
                ProcessLookupError):
            print(f'Foomuuri-{child} is not running')

    # Zones
    if not zone_interface:
        print()
        print('Warning: There are no interfaces assigned to any zones')

    print()
    print('zone {')
    for zone in zones:
        interfaces = [interface
                      for interface, int_zones in zone_interface.items()
                      if zone == int_zones]
        print(f'  {zone:15s} {" ".join(interfaces)}')
    print('}')
    return 0


class FoomuuriDbusException(dbus.DBusException):
    """Exception class for D-Bus interface."""

    _dbus_error_name = 'fi.foobar.Foomuuri1.exception'


class DbusCommon:
    """D-Bus server - Common Functions."""

    zones = None

    def set_data(self, zones):
        """Save config data: zone list is static."""
        self.zones = zones

    @staticmethod
    def clean_out():
        """Remove all entries from current OUT[] variable."""
        while OUT:
            del OUT[0]

    @staticmethod
    def apply_out():
        """Apply current OUT commands."""
        save_final(CONFIG['dbus_file'])
        run_program_rc(CONFIG['nft_bin'] + ['--file', CONFIG['dbus_file']])

    @staticmethod
    def remove_interface(interface_zone, interface):
        """Remove interface from all zones."""
        # Get interface's current zone
        zone = interface_zone.get(interface)
        if not zone:
            return ''

        # Remove from input and output
        out(f'delete element inet foomuuri input_zones '
            f'{{ "{interface}" : jump {zone}-{CONFIG["localhost_zone"]} }}')
        out(f'delete element inet foomuuri output_zones '
            f'{{ "{interface}" : jump {CONFIG["localhost_zone"]}-{zone} }}')

        # Remove from forward
        for other, otherzone in interface_zone.items():
            out(f'delete element inet foomuuri forward_zones '
                f'{{ "{other}" . "{interface}" : jump {otherzone}-{zone} }}')
            if other != interface:
                out(f'delete element inet foomuuri forward_zones '
                    f'{{ "{interface}" . "{other}" : '
                    f'jump {zone}-{otherzone} }}')
        return zone

    @staticmethod
    def add_interface(interface_zone, interface, zone):
        """Add interface to zone. It must be already removed from others."""
        # Add to input and output
        out(f'add element inet foomuuri input_zones '
            f'{{ "{interface}" : jump {zone}-{CONFIG["localhost_zone"]} }}')
        out(f'add element inet foomuuri output_zones '
            f'{{ "{interface}" : jump {CONFIG["localhost_zone"]}-{zone} }}')

        # Add to forward
        for other, otherzone in interface_zone.items():
            if other != interface:
                out(f'add element inet foomuuri forward_zones '
                    f'{{ "{other}" . "{interface}" : '
                    f'jump {otherzone}-{zone} }}')
                out(f'add element inet foomuuri forward_zones '
                    f'{{ "{interface}" . "{other}" : '
                    f'jump {zone}-{otherzone} }}')
        out(f'add element inet foomuuri forward_zones '
            f'{{ "{interface}" . "{interface}" : jump {zone}-{zone} }}')

    def change_interface_zone(self, interface, new_zone):
        """Change interface to new_zone, or delete if new_zone is empty."""
        interface, new_zone = str(interface), str(new_zone)
        if new_zone and new_zone not in self.zones:
            warning(f'Zone "{new_zone}" is unknown')
            raise FoomuuriDbusException(f'Zone "{new_zone}" is unknown')
        if interface == 'lo':
            # Interface "lo" must stay in "localhost" zone.
            # Other interfaces can be added to "localhost" only if
            # "localhost-localhost" section is defined.
            if new_zone != CONFIG['localhost_zone']:
                warning(f'Can\'t change interface "lo" to zone "{new_zone}"')
                raise FoomuuriDbusException(f'Can\'t change interface "lo" '
                                            f'to zone "{new_zone}"')
            return '', ''
        interface_zone = parse_active_interface_zone()
        self.clean_out()
        old_zone = self.remove_interface(interface_zone, interface)
        if new_zone:
            self.add_interface(interface_zone, interface, new_zone)
        self.apply_out()
        return old_zone, new_zone

    def parse_default_zone(self, interface, zone):
        """Return zone, or dbus_zone if empty."""
        interface, zone = str(interface), str(zone)
        if zone:
            return zone

        # Fallback to zones section, or to foomuuri.dbus_zone
        for key, value in self.zones.items():
            if interface in value['interface']:
                return key
        return CONFIG['dbus_zone']

    def method_get_zones(self):
        """Get list of available zones.

        "localhost" can't have any interfaces so don't include it.
        """
        return [name for name in self.zones
                if name != CONFIG['localhost_zone']]

    def method_remove_interface(self, zone, interface):
        """Remove interface from zone, or from all if zone is empty.

        This is currently always handled as "from all".
        """
        print(f'Interface "{interface}" remove from zone "{zone}"', flush=True)
        return self.change_interface_zone(interface, '')[0]

    def method_add_interface(self, zone, interface):
        """Add interface to zone.

        There can be only one zone per interface so it will be removed
        from previous zone if needed.
        """
        zone = self.parse_default_zone(interface, zone)
        print(f'Interface "{interface}" add to zone "{zone}"', flush=True)
        return self.change_interface_zone(interface, zone)[1]

    def method_change_zone_of_interface(self, zone, interface):
        """Change interface to zone."""
        zone = self.parse_default_zone(interface, zone)
        print(f'Interface "{interface}" change to zone "{zone}"', flush=True)
        return self.change_interface_zone(interface, zone)[0]


class DbusFoomuuri(dbus.service.Object, DbusCommon):
    """D-Bus server for Foomuuri."""

    # pylint: disable=invalid-name  # dbus method names

    @dbus.service.method('fi.foobar.Foomuuri1.zone',
                         in_signature='', out_signature='as')
    def getZones(self):
        """Get list of available zones."""
        return self.method_get_zones()

    @dbus.service.method('fi.foobar.Foomuuri1.zone',
                         in_signature='ss', out_signature='s')
    def removeInterface(self, zone, interface):
        """Remove interface from zone, or from all if zone is empty.

        Return: previous zone
        """
        return self.method_remove_interface(zone, interface)

    @dbus.service.method('fi.foobar.Foomuuri1.zone',
                         in_signature='ss', out_signature='s')
    def addInterface(self, zone, interface):
        """Add interface to zone.

        Return: new zone
        """
        return self.method_add_interface(zone, interface)

    @dbus.service.method('fi.foobar.Foomuuri1.zone',
                         in_signature='ss', out_signature='s')
    def changeZoneOfInterface(self, zone, interface):
        """Change interface to zone.

        Return: previous zone
        """
        return self.method_change_zone_of_interface(zone, interface)


class DbusFirewallD(dbus.service.Object, DbusCommon):
    """D-Bus server for FirewallD emulation."""

    # pylint: disable=invalid-name  # dbus method names

    @dbus.service.method('org.fedoraproject.FirewallD1.zone',
                         in_signature='', out_signature='as')
    def getZones(self):
        """Get list of available zones."""
        return self.method_get_zones()

    @dbus.service.method('org.fedoraproject.FirewallD1.zone',
                         in_signature='ss', out_signature='s')
    def removeInterface(self, zone, interface):
        """Remove interface from zone, or from all if zone is empty.

        Return: previous zone
        """
        return self.method_remove_interface(zone, interface)

    @dbus.service.method('org.fedoraproject.FirewallD1.zone',
                         in_signature='ss', out_signature='s')
    def addInterface(self, zone, interface):
        """Add interface to zone.

        Return: new zone
        """
        return self.method_add_interface(zone, interface)

    @dbus.service.method('org.fedoraproject.FirewallD1.zone',
                         in_signature='ss', out_signature='s')
    def changeZoneOfInterface(self, zone, interface):
        """Change interface to zone.

        Return: previous zone
        """
        return self.method_change_zone_of_interface(zone, interface)


def command_dbus():
    """Start D-Bus daemon."""
    CONFIG['keep_going'] = True
    while CONFIG['keep_going']:
        # Read minimal config
        config = minimal_config()
        zones = parse_config_zones(config)

        # Initialize D-Bus
        dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
        bus = dbus.SystemBus()

        # Foomuuri D-Bus calls
        try:
            foomuuri_name = dbus.service.BusName('fi.foobar.Foomuuri1', bus)
            foomuuri_name.get_name()  # Dummy call to get rid of pylint
        except dbus.exceptions.DBusException:
            fail('Can\'t bind to system D-Bus: fi.foobar.Foomuuri1')
        foomuuri_object = DbusFoomuuri(bus, '/fi/foobar/Foomuuri1')
        foomuuri_object.set_data(zones)

        # FirewallD emulation calls, if enabled in foomuuri{} config
        firewalld_object = None
        if CONFIG['dbus_firewalld'] == 'yes':
            try:
                firewalld_name = dbus.service.BusName(
                    'org.fedoraproject.FirewallD1', bus)
                firewalld_name.get_name()  # Dummy call to get rid of pylint
            except dbus.exceptions.DBusException:
                fail('Can\'t bind to system D-Bus: '
                     'org.federaproject.FirewallD1')
            firewalld_object = DbusFirewallD(bus,
                                             '/org/fedoraproject/FirewallD1')
            firewalld_object.set_data(zones)

        # Define reload/stop signal handler
        mainloop = GLib.MainLoop()

        def signal_handler(sig, _dummy_frame):
            if sig == signal.SIGINT:
                CONFIG['keep_going'] = False
                if HAVE_NOTIFY:
                    notify('STOPPING=1')
            elif HAVE_NOTIFY:
                notify('RELOADING=1')
            mainloop.quit()

        signal.signal(signal.SIGHUP, signal_handler)
        signal.signal(signal.SIGINT, signal_handler)
        save_file(CONFIG['run_dir'] / 'foomuuri-dbus.pid', [str(os.getpid())])

        # Start processing messages
        print('D-Bus handler ready', flush=True)
        if HAVE_NOTIFY:
            notify('READY=1')
        mainloop.run()

        # Reload signal received. Disconnect from D-Bus.
        foomuuri_object.remove_from_connection()
        del foomuuri_object
        del foomuuri_name
        if firewalld_object:
            firewalld_object.remove_from_connection()
            del firewalld_object
            del firewalld_name
        del bus


def command_set():
    """Modify runtime config by calling D-Bus methods."""
    if (
            len(sys.argv) != 6 or
            sys.argv[2] != 'interface' or
            sys.argv[4] != 'zone'
    ):
        command_help()
    try:
        bus = dbus.SystemBus()
        obj = bus.get_object('fi.foobar.Foomuuri1', '/fi/foobar/Foomuuri1')
        if sys.argv[5] in ('', '-'):
            obj.removeInterface('', sys.argv[3])
        else:
            obj.changeZoneOfInterface(sys.argv[5], sys.argv[3])
    except dbus.exceptions.DBusException as error:
        fail(error)


def resolve_one_hostname(hostname):
    """Resolve hostname and return its IP addresses."""
    if is_ip_address(hostname):  # Return as is if already IP or IP/mask
        return (hostname,)
    ret = set()
    try:
        for item in socket.getaddrinfo(hostname, None):
            if item[4] and item[4][0]:
                ret.add(item[4][0])
    except socket.gaierror:
        pass
    return ret


def resolve_all_hostnames(resolve):
    """Return dict of hostname -> set of IP addresses."""
    # Collect list of hostnames to resolve
    todo = set()
    for hostlist in resolve.values():
        todo.update(hostlist)

    # Resolve them
    hosts = {}
    with concurrent.futures.ThreadPoolExecutor() as executor:
        jobs = {executor.submit(resolve_one_hostname, hostname): hostname
                for hostname in todo}
        for future in concurrent.futures.as_completed(jobs):
            hosts[jobs[future]] = future.result()
    return hosts


def active_sets():
    """Return set names in currently active firewall.

    Return value is dict of "setname -> entries".
    """
    ret = {}
    data = nft_json('list sets table inet foomuuri')
    if not data:
        return ret
    for item in data['nftables']:
        if 'set' in item:
            ret[item['set']['name']] = item['set'].get('elem')
    return ret


def apply_resolve_iplist(filename, error_txt, pending_error):
    """Save and apply resolve/iplist update."""
    if not OUT:
        return pending_error
    save_final(CONFIG[filename])
    ret = run_program_rc(CONFIG['nft_bin'] + ['--file', CONFIG[filename]])
    if ret:
        print(f'Error: Failed to update {error_txt}, error code {ret}')
    return ret or pending_error


def config_resolve_to_iplist():
    """Parse config's resolve{} and return "setname_4 -> iplist" dict."""
    config = minimal_config()
    resolve = parse_resolve(config, 'resolve', '24h')
    timeout = resolve.pop('timeout')
    resolve.pop('refresh')
    hosts = resolve_all_hostnames(resolve)

    # Convert resolve "@setname_4 -> list of hostnames" to
    # "setname_4 -> list of IP addresses"
    current = {}
    warned = set()
    for setname, hostlist in resolve.items():
        iplist = set()
        ipv = int(setname[-1])
        for hostname in hostlist:
            if not hosts[hostname] and hostname not in warned:
                warned.add(hostname)
                warning(f'No IP address found for hostname '
                        f'"{hostname}" in resolve "{setname[:-2]}"')
            for ipaddr in hosts[hostname]:
                if is_ip_address(ipaddr) == ipv:
                    iplist.add(ipaddr)
        current[setname[1:]] = sorted(iplist)
    return current, timeout


def warn_once(setname, warned, error_pre, error_post):
    """Print warning for setname if not already warned."""
    if setname[:-2] in warned:
        return  # Already warned

    print(f'{error_pre} "@{setname[:-2]}" {error_post}', flush=True)
    warned.add(setname[:-2])


def command_resolve():
    """Resolve hostnames."""
    # Read config and resolve
    current, timeout = config_resolve_to_iplist()
    previous = read_previous_resolve_iplist('resolve_file')[0]
    known = active_sets()

    # Output entries
    warned = set()
    for setname, addresses in current.items():
        for address in addresses:
            # Is this set active in current ruleset? If not, add entry as
            # comment
            if setname not in known:
                comment = '# '
                warn_once(setname, warned, 'Warning: Resolve',
                          'does not exist in currently active firewall')
            else:
                # Success, update elements to ruleset.
                comment = ''
                out(f'add element inet foomuuri {setname} {{ {address} }}')
                out(f'delete element inet foomuuri {setname} {{ {address} }}')
            out(f'{comment}add element inet foomuuri {setname} '
                f'{{ {address} timeout {timeout} }}')

        # All lookups failed. Add previous entries as comments.
        if not addresses and setname in previous:
            for address in sorted(previous[setname]):
                out(f'# add element inet foomuuri {setname} '
                    f'{{ {address} timeout {timeout} }}')

    # Error if set in currently active firewall is / will be empty
    has_entries = set()
    for setname, addresses in current.items():
        if addresses or known.get(setname):
            has_entries.add(setname[:-2])
    error = 0
    warned = set()
    for setname in current:
        if setname[:-2] not in has_entries:
            error = 1
            warn_once(setname, warned, 'Error: Resolve', 'is empty')

    # Apply update
    return apply_resolve_iplist('resolve_file', 'resolve', error)


def get_url(url, setname):
    """Download URL and return it as text, or None if failure."""
    for retry in range(3):
        if retry:
            time.sleep(10)
        try:
            response = requests.get(url, timeout=50)
        except OSError as error:
            err = f'error: {error}'
        else:
            if response.status_code == 200:
                return response.text
            err = f'status code: {response.status_code}'
    warning(f'Iplist "@{setname}", can\'t download {url}, {err}')
    return None


def get_file(wildcard, setname):
    """Read all files and return them as single text."""
    wildpath = pathlib.Path(wildcard)
    filenames = sorted(wildpath.parent.glob(wildpath.name))
    if not filenames:
        warning(f'Iplist "@{setname}", can\'t read file '
                f'{wildcard}: No such file')
        return None
    text = ''
    for filename in filenames:
        try:
            content = filename.read_text(encoding='utf-8')
        except PermissionError as error:
            warning(f'Iplist "@{setname}", can\'t read file '
                    f'{filename}: {error}')
            return None
        text = text + '\n' + content
    return text


def parse_hour_min(timespec, fallback):
    """Parse 4h3m to seconds.

    This is a dummy parser without any good error checking.
    """
    if not timespec:
        return fallback
    timespec = timespec.replace(' ', '')
    hours = mins = 0
    if 'h' in timespec:
        hours, timespec = timespec.split('h', 1)
    if 'm' in timespec:
        mins, timespec = timespec.split('m', 1)
    if timespec:
        return fallback
    try:
        return int(hours) * 3600 + int(mins) * 60
    except ValueError:
        return fallback


def get_all_urls_and_files(setname, namelist, last_refresh_time,
                           default_refresh):
    """Read all URLs and filename-wildcards and return them as text."""
    # Is it time to update this list?
    refresh = parse_hour_min(default_refresh, 0)
    for item in namelist:
        if item.startswith('refresh='):
            refresh = parse_hour_min(item[8:], refresh)
    if not last_refresh_time or CONFIG['force'] >= 0:
        time_left = 0
    else:
        now = datetime.datetime.now(datetime.timezone.utc)
        time_left = int((last_refresh_time +
                         datetime.timedelta(seconds=refresh) -
                         now).total_seconds())
    if time_left > 0:
        verbose(f'Iplist "@{setname}" refresh skipped, {time_left} seconds '
                f'to next refresh')
        return '_refresh'  # Not yet

    # It is time. Fetch!
    text = ''
    for filename in namelist:
        if filename.startswith('refresh='):
            continue
        if filename.startswith(('https:', 'http:')):
            content = get_url(filename, setname)
        elif is_ip_address(filename):
            content = filename
        else:
            content = get_file(filename, setname)
        if content is None:
            return None  # Return None if any fails
        text = text + '\n' + content
    verbose(f'Iplist "@{setname} refreshed')
    return text


def read_previous_resolve_iplist(filename):
    """Read previous resolve/iplist state file and parse it to dict."""
    # Read state file, silently ignore if missing
    try:
        # pylint: disable=no-member
        content = CONFIG[filename].read_text(encoding='utf-8')
    except FileNotFoundError:
        return {}, {}
    except PermissionError as error:
        fail(f'File {CONFIG["iplist_file"]}: Can\'t read: {error}')

    # Parse lines to setname->set(ipaddrs)
    addrs = {}
    last_refresh = {}
    for line in content.splitlines():
        if line.startswith('# last_refresh'):
            try:
                last_refresh[line.split()[2]] = datetime.datetime.strptime(
                    line.split()[3], '%Y-%m-%dT%H:%M:%S').replace(
                        tzinfo=datetime.timezone.utc)
            except (IndexError, ValueError):
                pass
            continue
        tokens = line.replace('# ', '').split()
        if len(tokens) != 10 or tokens[7] != 'timeout':
            continue
        setname = tokens[4]
        if setname not in addrs:
            addrs[setname] = set()
        addrs[setname].add(tokens[6])
    return addrs, last_refresh


def read_all_iplists(iplist, last_refresh, default_refresh):
    """Read all iplist files and return dict setname_ipv->iplist."""
    ret = {}
    for setname, filenames in iplist.items():
        # iplist has "name_6" and "name_4" with same content, ignore "_6"
        if setname.endswith('_6'):
            continue
        setname = setname[1:-2]  # Strip "@" and "_4"

        # Get content
        text = get_all_urls_and_files(setname, filenames,
                                      last_refresh.get(setname),
                                      default_refresh)
        if text is None:  # Fetch failure
            continue
        if text == '_refresh':  # It wasn't time to refresh
            ret[setname] = text
            continue

        # Parse each line to IPv4/IPv6 lists
        addr_4 = set()
        addr_6 = set()
        for line in text.splitlines():
            # Strip comments and empty lines
            if '#' in line:
                line = line.split('#')[0]
            if ';' in line:
                line = line.split(';')[0]
            line = line.strip()
            if not line:
                continue

            # Parse single item per line
            if is_ipv4_address(line):
                addr_4.add(line)
            elif is_ipv6_address(line):
                addr_6.add(line)
            else:
                warning(f'Invalid content in iplist {{ @{setname} }}: {line}')
                break
        else:  # Include it to reply if all success, update its timestamp
            ret[f'{setname}_4'] = addr_4
            ret[f'{setname}_6'] = addr_6
            last_refresh[setname] = datetime.datetime.now(
                datetime.timezone.utc)
    return ret


def iterate_set_elements(data):
    """Iterate elements from "nft --json list set" output."""
    for toplevel in (data or {}).get('nftables', []):
        for elem in toplevel.get('set', {}).get('elem', []):
            if isinstance(elem, dict) and 'elem' in elem:
                ipaddr = elem['elem']['val']
                expire = elem['elem'].get('expires', 864000)  # 10 days
            else:
                ipaddr = elem
                expire = 864000
            if isinstance(ipaddr, str):
                yield ipaddr, expire
            elif 'range' in ipaddr:
                yield f'{ipaddr["range"][0]}-{ipaddr["range"][1]}', expire
            else:
                yield (f'{ipaddr["prefix"]["addr"]}/'
                       f'{ipaddr["prefix"]["len"]}', expire)


def iplist_manual_state(iplist):
    """Save manual iplist status to state file.

    There is no locking so simultaneous add/del can result one missing/extra
    IP address. Next add/del will fix it.
    """
    manual = []
    known = active_sets()
    for setname, filenames in iplist.items():
        # Skip if not manual list, has filenames
        if filenames or not setname.startswith('@'):
            continue
        setname = setname[1:]
        if setname not in known:
            continue

        # Get set entries
        data = nft_json(f'list set inet foomuuri {setname}')

        # Collect IP addresses
        now = datetime.datetime.now(datetime.timezone.utc)
        for ipaddr, expire in iterate_set_elements(data):
            stamp = (now + datetime.timedelta(seconds=expire)).strftime(
                '%Y-%m-%dT%H:%M:%S')
            manual.append(f'add element inet foomuuri {setname} '
                          f'{{ {ipaddr} timeout {expire}s }} # {stamp}')

    # Save manual list
    save_file(CONFIG['iplist_manual_file'], manual)


def command_iplist_list():
    """List iplist entries."""
    config = minimal_config()
    known = set()
    for item in (
            list(parse_resolve(config, 'iplist')) +
            list(parse_resolve(config, 'resolve'))
    ):
        if item.startswith('@'):
            known.add(item[:-2])

    ret = 1  # Default to no output, failure
    for setname in CONFIG['parameters'][1:] or sorted(known):
        if setname.startswith('@'):
            setname = setname[1:]
        if setname.endswith(('_4', '_6')):
            if f'@{setname[:-2]}' not in known:
                fail(f'Unknown iplist name: @{setname[:-2]}')
            data4 = nft_json(f'list set inet foomuuri {setname}')
            data6 = {}
        else:
            if f'@{setname}' not in known:
                fail(f'Unknown iplist name: @{setname}')
            data4 = nft_json(f'list set inet foomuuri {setname}_4')
            data6 = nft_json(f'list set inet foomuuri {setname}_6')
        elem4 = [ipaddr for ipaddr, _dummy in iterate_set_elements(data4)]
        elem6 = [ipaddr for ipaddr, _dummy in iterate_set_elements(data6)]
        elems = sorted(elem4) + sorted(elem6)
        if not elems:
            print(f'@{setname}')
        else:
            ret = 0  # Outputted something, return ok
            for elem in elems:
                print(f'@{setname:20s}  {elem}')
    return ret


def command_iplist_add():
    """Add entries to iplist."""
    config = minimal_config()
    iplist = parse_resolve(config, 'iplist', '10d')
    known = set(iplist) | set(parse_resolve(config, 'resolve'))
    timeout = iplist.pop('timeout')
    setname = CONFIG['parameters'][1]
    if setname.startswith('@'):
        setname = setname[1:]
    if f'@{setname}_4' not in known:
        fail(f'Unknown iplist name: @{setname}')

    ret = 0
    for address in CONFIG['parameters'][2:]:
        ipv = is_ip_address(address)
        if not ipv:
            if address[-1] in 'smhd':
                timeout = address.replace(' ', '')
                continue
            fail(f'Invalid IP address {address}')

        # nftables/kernel workaround to get entry's timeout+expire
        # values updated. This is not atomic update!
        # With "auto-merge" enabled this seems to be the only.
        nft_command(f'delete element inet foomuuri {setname}_{ipv} '
                    f'{{ {address} }}', quiet=True)
        ret += nft_command(f'add element inet foomuuri {setname}_{ipv} '
                           f'{{ {address} timeout {timeout} }}')
    iplist_manual_state(iplist)
    return ret


def command_iplist_del():
    """Delete entries from iplist."""
    config = minimal_config()
    iplist = parse_resolve(config, 'iplist')
    known = set(iplist) | set(parse_resolve(config, 'resolve'))
    setname = CONFIG['parameters'][1]
    if setname.startswith('@'):
        setname = setname[1:]
    if f'@{setname}_4' not in known:
        fail(f'Unknown iplist name: @{setname}')

    for address in CONFIG['parameters'][2:]:
        ipv = is_ip_address(address)
        if not ipv:
            fail(f'Invalid IP address {address}')
        nft_command(f'delete element inet foomuuri {setname}_{ipv} '
                    f'{{ {address} }}', quiet=True)
    iplist_manual_state(iplist)
    return 0


def command_iplist_flush():
    """Delete all entries from iplist."""
    config = minimal_config()
    iplist = parse_resolve(config, 'iplist')
    known = set(iplist) | set(parse_resolve(config, 'resolve'))
    ret = 0
    for setname in CONFIG['parameters'][1:]:
        if setname.startswith('@'):
            setname = setname[1:]
        if f'@{setname}_4' not in known:
            fail(f'Unknown iplist name: @{setname}')
        for ipv in (4, 6):
            ret += nft_command(f'flush set inet foomuuri {setname}_{ipv}')
    iplist_manual_state(iplist)
    return ret


def command_iplist_refresh():
    """Refresh iplist{} entries."""
    # pylint: disable=too-many-locals

    # Read minimal config and read iplist{} entries
    config = minimal_config()
    iplist = parse_resolve(config, 'iplist', '10d', '24h')
    timeout = iplist.pop('timeout')
    refresh = iplist.pop('refresh')
    previous, last_refresh = read_previous_resolve_iplist('iplist_file')
    for param in CONFIG['parameters'][1:]:  # Refresh parameters now
        if param.startswith('@'):
            param = param[1:]
        last_refresh[param] = None
    current = read_all_iplists(iplist, last_refresh, refresh)
    known = active_sets()

    # Output last_refresh entries
    for setname, timestamp in last_refresh.items():
        if f'@{setname}_4' in iplist and timestamp:
            out(f'# last_refresh {setname} {timestamp:%Y-%m-%dT%H:%M:%S}')

    # Output address entries
    warned = set()
    error = 0
    for setname, filenames in iplist.items():
        if not filenames:
            continue  # Don't touch sets without filenames

        setname = setname[1:]  # Strip "@"
        if current.get(setname[:-2]) == '_refresh':
            # It wasn't time to refresh
            comment = '# '
            addrlist = previous.get(setname, [])
        elif setname not in current:
            # Fetch for this set failed. Add previous fetch as comments
            error = 1
            comment = '# '
            addrlist = previous.get(setname, [])
            warn_once(setname, warned, 'Error: Iplist', 'failed to refresh')
        elif setname not in known:
            # Not active in current ruleset, add items as comments
            comment = '# '
            addrlist = current[setname]
            warn_once(setname, warned, 'Warning: Iplist',
                      'does not exist in currently active firewall')
        else:
            # Success, update elements to ruleset.
            comment = ''
            out(f'flush set inet foomuuri {setname}')
            addrlist = current[setname]

        for ipaddr in sorted(addrlist):
            out(f'{comment}add element inet foomuuri {setname} '
                f'{{ {ipaddr} timeout {timeout} }}')

    # Apply update
    return apply_resolve_iplist('iplist_file', 'iplist', error)


def command_iplist():
    """Parse "foomuuri iplist" subcommand."""
    if len(CONFIG['parameters']) >= 1 and CONFIG['parameters'][0] == 'list':
        return command_iplist_list()
    if len(CONFIG['parameters']) >= 3 and CONFIG['parameters'][0] == 'add':
        return command_iplist_add()
    if len(CONFIG['parameters']) >= 3 and CONFIG['parameters'][0] == 'del':
        return command_iplist_del()
    if len(CONFIG['parameters']) >= 2 and CONFIG['parameters'][0] == 'flush':
        return command_iplist_flush()
    if len(CONFIG['parameters']) >= 1 and CONFIG['parameters'][0] == 'refresh':
        return command_iplist_refresh()
    return command_help()


def command_list():
    """List currently active ruleset or other things."""
    # List all
    if not CONFIG['parameters']:
        return nft_command('list ruleset')

    # Parse list of possible zone-zone pairs
    config = minimal_config()
    zones = parse_config_zones(config)
    zonepairs = [f'{szone}-{dzone}' for szone in zones for dzone in zones]

    # Parse parameters
    list_type = CONFIG['parameters'][0]
    list_params = CONFIG['parameters'][1:]
    ret = 0

    if list_type == 'macro':
        # List macros. config{} doesn't have macro{} anymore so config
        # must be re-read.
        macros = parse_config_macros(read_config())
        print('macro {')
        for macro in sorted(macros):
            if (
                    not list_params or
                    macro in list_params or
                    any(item in list_params for item in macros[macro])
            ):
                print(f'  {macro:15s} {" ".join(macros[macro])}')
        print('}')
        return 0

    if list_type == 'counter':
        # List named counters
        if not list_params:
            ret |= nft_command('list counters table inet foomuuri')
        else:
            for counter in list_params:
                ret |= nft_command(f'list counter inet foomuuri {counter}')
        return ret

    # List zone-zone rules
    for zone in CONFIG['parameters']:
        if zone not in zonepairs:
            fail(f'Unknown zone-zone: {zone}')
        for ipv in (4, 6):
            ret += nft_command(f'list chain inet foomuuri {zone}_{ipv}')
    return ret


def command_reload():
    """Run start and refresh resolve and iplist."""
    # Use same args
    args = [sys.argv[0]]
    for arg in sys.argv[1:]:
        if arg.startswith('--'):
            args.append(arg)

    # Run commands
    for sub, fatal in ((['start'], True),
                       (['resolve', 'refresh'], False),
                       (['iplist', 'refresh'], False)):
        ret = run_program_rc(args + sub)
        if ret and fatal:
            return ret
    return 0


def seconds_to_human(seconds):
    """Convert seconds int to human readable "19 days, 18:37" format."""
    day = seconds // 86400
    hour = (seconds // 3600) % 24
    minute = (seconds // 60) % 60
    second = seconds % 60
    if day:
        return f'{day} days, {hour:02d}:{minute:02d}:{second:02d}'
    return f'{hour:02d}:{minute:02d}:{second:02d}'


def monitor_state_command(targets, groups, cfg, grouptarget, name):
    """Run command if group/target state changes."""
    # pylint: disable=too-many-locals

    # Log state change
    updown = 'up' if cfg['state'] else 'down'
    now = time.time()
    prev = cfg.get('state_time')
    history = None
    if prev:
        seconds = int(now - prev + 0.5)
        extra = f'previous change was {seconds_to_human(seconds)} ago'
        if grouptarget == 'target':
            for cons in range(0, cfg['history_size']):
                if cfg['history'][-1 - cons] != cfg['state']:
                    break
            historycount = cfg['history'].count(cfg['state'])
            extra = (f'{extra}, consecutive_{updown} {cons}, '
                     f'history_{updown} {historycount}')
            history = ''.join('.' if item else '!' for item in cfg['history'])
    else:
        extra = 'startup change'
    print(f'{grouptarget} {name} changed state to {updown}, {extra}',
          flush=True)
    if history:
        print(f'{grouptarget} {name} history: {history}', flush=True)
    cfg['state_time'] = now

    # Run external command if configured. It will receive current state change
    # event info and all states in environment variables.
    env = {
        # Change state event
        'FOOMUURI_CHANGE_TYPE': grouptarget,
        'FOOMUURI_CHANGE_NAME': env_cleanup(name),
        'FOOMUURI_CHANGE_STATE': updown,
        'FOOMUURI_CHANGE_LOG': extra,
        'FOOMUURI_CHANGE_HISTORY': history or '',
        # List of configured targets
        'FOOMUURI_ALL_TARGET': ' '.join(env_cleanup(item) for item in targets),
        # List of configured groups
        'FOOMUURI_ALL_GROUP': ' '.join(env_cleanup(item) for item in groups),
    }
    for target, icfg in targets.items():
        env[f'FOOMUURI_TARGET_{env_cleanup(target)}'] = (
            'up' if icfg['state'] else 'down')
    for group, icfg in groups.items():
        env[f'FOOMUURI_GROUP_{env_cleanup(group)}'] = (
            'up' if icfg.get('state', True) else 'down')
    run_program_rc(cfg[f'command_{updown}'], env=env)


def monitor_update_groups(targets, groups):
    """Update all group statuses.

    On startup make decision and send event for all groups after first reply
    from any target.
    """
    for group, cfg in groups.items():
        any_up = any(targets[target]['state'] for target in cfg['target'])
        state = cfg.get('state', not any_up)  # Undef in startup
        if not any_up and state:
            cfg['state'] = False
            monitor_state_command(targets, groups, cfg, 'group', group)
        elif any_up and not state:
            cfg['state'] = True
            monitor_state_command(targets, groups, cfg, 'group', group)


def monitor_update_target(targets, groups, target, state):
    """Add state to target's history and change its state."""
    # Add new state to end of history
    cfg = targets[target]
    startup_change = False
    if 'history' not in cfg:
        # First reply ever, fill history and force change event
        cfg['history'] = [True] * cfg['history_size']
        startup_change = True
    cfg['history'] = cfg['history'][1:] + [state]

    # Target state can't change if added state is same as current state
    if cfg['state'] == state and not startup_change:
        return

    # Check if target state is changed
    count_up = sum(cfg['history'])
    if cfg['state']:
        # Currently up. Target goes down if:
        # - history has too many downs
        # OR
        # - last n items were down
        if (
                cfg['history_size'] - count_up >= cfg['history_down'] or
                not any(cfg['history'][-cfg['consecutive_down']:])
        ):
            cfg['state'] = False
            monitor_state_command(targets, groups, cfg, 'target', target)
        elif startup_change:  # Always send an event on startup
            monitor_state_command(targets, groups, cfg, 'target', target)

    else:
        # Currently down. Target goes up if:
        # - history has enough ups
        # AND
        # - last n items were up
        if (
                count_up >= cfg['history_up'] and
                all(cfg['history'][-cfg['consecutive_up']:])
        ):
            cfg['state'] = True
            monitor_state_command(targets, groups, cfg, 'target', target)


def monitor_parse_line(targets, groups, target, line):
    """Parse result line from monitor's command."""
    # Generic "OK" or "ERROR" reply
    stat_ms = None  # Value for statistics, assume error
    if line in ('OK', 'ERROR'):
        state = line == 'OK'
        if state:
            stat_ms = 1  # There is no time, use 1
    else:
        # fping reply
        match = re.match(r'^[^ ]+ : \[\d+\], (.+)$', line)
        if not match:
            return  # No match, ignore line
        result = match.group(1)
        state = ' bytes, ' in result and ' ms (' in result
        if state:
            try:
                stat_ms = float(result.split()[2])
            except ValueError:
                pass

    # Update target state
    monitor_update_target(targets, groups, target, state)

    # Add result to statistics
    cfg = targets[target]
    cfg['time'] = (cfg['time'] + [stat_ms])[-cfg['statistics_size']:]


def monitor_start_targets(targets, groups):
    """Start pinging all targets."""
    started_something = False
    for target, cfg in targets.items():
        # Check if target is already running, or still waiting for restart
        if cfg.get('proc') or cfg['proc_restart'] > time.time():
            continue

        # fping requires --loop, add it if missing
        cmd = cfg['command']
        if 'fping' in cmd[0] and '--loop' not in cmd:
            cmd = cmd[:1] + ['--loop'] + cmd[1:]

        # Assume it is up on first startup
        if 'state' not in cfg:
            cfg['state'] = True

        # Start command (pylint: disable=consider-using-with)
        try:
            cfg['proc'] = subprocess.Popen(cmd,
                                           stdin=subprocess.DEVNULL,
                                           stdout=subprocess.PIPE,
                                           stderr=subprocess.STDOUT,
                                           encoding='utf-8')
            verbose(f'target {target} monitoring command started')
            started_something = True
        except OSError as error:
            print(f'target {target} monitoring command failed: {error}')
            cfg['proc'] = None
            cfg['proc_restart'] = time.time() + 30

            # Mark it as down immediately
            cfg['history'] = [False] * cfg['history_size']
            monitor_update_target(targets, groups, target, False)

    # Small initial sleep so that first read_target() will receive more
    # replies in one go. This helps to get more "target up" events before
    # "group up" events.
    if started_something:
        time.sleep(0.5)


def monitor_close_target(targets, groups, target):
    """Target monitoring command died. Mark it as down."""
    # Terminate proc and schedule restart
    print(f'target {target} monitoring command died, restarting it in 30s',
          flush=True)
    cfg = targets[target]
    cfg['proc'].terminate()
    cfg['proc'].wait(timeout=0.1)
    cfg['proc'] = None
    cfg['proc_restart'] = time.time() + 30
    cfg['time'] = []

    # Mark it as down immediately
    cfg['history'] = [False] * cfg['history_size']
    monitor_update_target(targets, groups, target, False)


def monitor_read_targets(targets, groups):
    """Read incoming lines from all targets."""
    # (Re)Start targets if not running
    monitor_start_targets(targets, groups)

    # Wait for incoming lines from any client, up to 10 seconds
    readfd = [item['proc'].stdout for item in targets.values()
              if item['proc']]
    poll = select.select(readfd, [], [], 10.0)

    # Read all incoming lines
    for readfd in poll[0]:
        for target, cfg in targets.items():
            if cfg['proc'] and cfg['proc'].stdout == readfd:
                line = readfd.readline()
                if line:
                    monitor_parse_line(targets, groups, target, line.strip())
                else:
                    monitor_close_target(targets, groups, target)
                break

    # Update all groups
    monitor_update_groups(targets, groups)


def monitor_terminate_targets(targets):
    """Terminate all subprocesses."""
    for cfg in targets.values():
        if cfg['proc']:
            cfg['proc'].terminate()
    for cfg in targets.values():
        if cfg['proc']:
            cfg['proc'].wait(timeout=0.1)


def parse_config_targets(config):
    """Parse "target foo { ... }" entries from config."""
    targets = {}
    names = [item for item in config if item.startswith('target ')]
    for name in names:
        name = name[7:]
        cfg = targets[name] = {
            'command': [],
            'command_up': [],
            'command_down': [],
            'consecutive_up': 20,    # last n were UP        => UP
            'consecutive_down': 10,  # last n were DOWN      => DOWN
            'history_up': 80,        # count of UPs => n     => UP
            'history_down': 30,      # count of DOWNs >= n   => DOWN
            'history_size': 100,
            'statistics_size': 300,     # Keep last 300 results
            'statistics_interval': 60,  # Write it once a minute
            # Internal items:
            # 'state': True,
            # 'state_time': time(),
            # 'history': [True] * history_size,
            # 'proc': Popen()
            'proc_restart': 0,       # Start immediately
            'time': [],              # Statistics
        }
        fileline = ''
        for fileline, line in config.pop(f'target {name}'):
            if line[0] in ('command', 'command_up', 'command_down'):
                cfg[line[0]] = line[1:]
            else:
                if len(line) != 2:
                    fail(f'{fileline}Can\'t parse line: {" ".join(line)}')
                if line[0] not in cfg:
                    fail(f'{fileline}Unknown keyword: {" ".join(line)}')
                try:
                    cfg[line[0]] = int(line[1])
                except ValueError:
                    fail(f'{fileline}Invalid value: {" ".join(line)}')

        # Verify section
        if not cfg['command']:
            fail(f'{fileline}Missing "command" keyword in "target {name}"')
        for key in ('consecutive_up', 'consecutive_down',
                    'history_up', 'history_down'):
            if cfg[key] > cfg['history_size']:
                fail(f'{fileline}{key} is larger than history_size in '
                     f'"target {name}": {cfg[key]} > {cfg["history_size"]}')
        if cfg['history_size'] - cfg['history_up'] >= cfg['history_down']:
            warning(f'{fileline}Possible up-down loop in '
                    f'"target {name}": history_size {cfg["history_size"]} - '
                    f'history_up {cfg["history_up"]} >= '
                    f'history_down {cfg["history_down"]}')

    return targets


def parse_config_groups(config, targets):
    """Parse "group foo { ... }" entries from config."""
    groups = {}
    names = [item for item in config if item.startswith('group ')]
    for name in names:
        name = name[6:]
        groups[name] = {
            'target': [],
            'command_up': [],
            'command_down': [],
        }
        fileline = ''
        for fileline, line in config.pop(f'group {name}'):
            if line[0] not in groups[name]:
                fail(f'{fileline}Unknown keyword: {" ".join(line)}')
            groups[name][line[0]] = line[1:]

        # Verify section
        target = groups[name].get('target')
        if not target:
            fail(f'{fileline}Missing "target" keyword in "group {name}"')
        for item in target:
            if item not in targets:
                fail(f'{fileline}Undefined target "{item}" in "group {name}"')

    return groups


def monitor_write_statistics(targets):
    """Write statistics file."""
    stats = {
        target: {
            # Config
            'consecutive_up': cfg['consecutive_up'],
            'consecutive_down': cfg['consecutive_down'],
            'history_up': cfg['history_up'],
            'history_down': cfg['history_down'],
            # State
            'state': cfg['state'],
            'history': cfg['history'],
            # fping time
            'time': cfg['time'],
        }
        for target, cfg in targets.items()
    }
    save_file(CONFIG['monitor_statistics_file'],
              [json.dumps(stats, indent=2, sort_keys=True)])


def command_monitor():
    """Monitor targets and run command when their state changes up or down."""
    CONFIG['keep_going'] = 1
    while CONFIG['keep_going']:
        # Read minimal config
        config = minimal_config()
        targets = parse_config_targets(config)
        groups = parse_config_groups(config, targets)

        # Exit silently with OK if no targets defined in configuration
        if not targets:
            if HAVE_NOTIFY:
                notify('READY=1')
                notify('STOPPING=1')
            return 0

        # Define reload/stop signal handler
        def signal_handler(sig, _dummy_frame):
            if sig == signal.SIGINT:
                CONFIG['keep_going'] = 0
                if HAVE_NOTIFY:
                    notify('STOPPING=1')
            else:
                CONFIG['keep_going'] = 2
                if HAVE_NOTIFY:
                    notify('RELOADING=1')

        CONFIG['keep_going'] = 1
        signal.signal(signal.SIGHUP, signal_handler)
        signal.signal(signal.SIGINT, signal_handler)
        save_file(CONFIG['run_dir'] / 'foomuuri-monitor.pid',
                  [str(os.getpid())])

        # Start monitoring
        print('Target monitor ready', flush=True)
        if HAVE_NOTIFY:
            notify('READY=1')
        stat_interval = min(cfg['statistics_interval']
                            for cfg in targets.values())
        next_stat = time.time() + stat_interval / 5
        while CONFIG['keep_going'] == 1:
            monitor_read_targets(targets, groups)

            # Periodic statistics file update
            if stat_interval and time.time() >= next_stat:
                next_stat += stat_interval
                monitor_write_statistics(targets)

        monitor_terminate_targets(targets)

    return 0


def command_help(error=True):
    """Print command line help."""
    # pylint: disable=too-many-statements
    print(f'Foomuuri {VERSION}')
    print()
    print(f'Usage: {sys.argv[0]} {{options}} command')
    print()
    print('Available commands:')
    print()
    print('  start            Load configuration files and generate ruleset')
    print('  stop             Remove ruleset')
    print('  reload           Same as start, followed by resolve and iplist '
          'refresh')
    print('  status           Show current status: running, zone-interface '
          'mapping')
    print('  check            Verify configuration files')
    print('  block            Load "block all traffic" ruleset')
    print('  list             List active ruleset')
    print('  list zone-zone {zone-zone...}')
    print('                   List active ruleset for zone-zone')
    print('  list macro       List all known macros')
    print('  list macro keyword {keyword...}')
    print('                   List all macros with specified name or value')
    print('  list counter     List all named counters')
    print('  list counter keyword {keyword}')
    print('                   List named counter with specified name')
    print('  iplist list      List entries in all configured iplists and '
          'resolves')
    print('  iplist list name {name...}')
    print('                   List entries in named iplist/resolve')
    print('  iplist add name {timeout} ipaddress {ipaddress...}')
    print('                   Add or refresh IP address to iplist')
    print('  iplist del name ipaddress {ipaddress...}')
    print('                   Delete IP address from iplist')
    print('  iplist flush name {name...}')
    print('                   Delete all IP addresses from iplist')
    print('  iplist refresh name {name...}')
    print('                   Refresh iplist @name now')
    print('  set interface {interface} zone {zone}')
    print('                   Change interface to zone')
    print('  set interface {interface} zone -')
    print('                   Remove interface from all zones')
    print()
    print('Available options:')
    print()
    print('  --version        Print version')
    print('  --verbose        Verbose output')
    print('  --quiet          Be quiet')
    print('  --force          Force some operations, don\'t check anything')
    print('  --soft           Don\'t force operations, check more')
    print('  --set=option=value')
    print('                   Set config option to value')
    print()
    print('Internally used commands:')
    print()
    print('  iplist refresh   Refresh iplist entries')
    print('  resolve refresh  Refresh resolve hostnames')
    print('  dbus             Start D-Bus daemon')
    print('  monitor          Start target monitor daemon')
    if error:
        fail()
    return 0


def parse_command_line():
    """Parse command line to CONFIG[command] and CONFIG[parameters]."""
    for arg in sys.argv[1:]:
        if arg == '--help':
            CONFIG['command'] = 'help'
        elif arg == '--version':
            print(VERSION)
            sys.exit(0)
        elif arg in ('--verbose', '--force'):
            CONFIG[arg[2:]] += 1
        elif arg == '--quiet':
            CONFIG['verbose'] -= 1
        elif arg == '--soft':
            CONFIG['force'] -= 1
        elif arg.startswith('--set='):
            if arg.count('=') == 1:
                fail(f'Invalid syntax for --set=option=value: {arg}')
            _dummy, option, value = arg.split('=', 2)
            if option not in CONFIG:
                fail(f'Unknown foomuuri{{}} option: {arg}')
            CONFIG[option] = value
        elif not CONFIG['command']:
            CONFIG['command'] = arg
        else:
            CONFIG['parameters'].append(arg)
    if not CONFIG['command']:
        CONFIG['command'] = 'help'
    config_to_pathlib(False)  # Needed to read conf, and --set=x_dir=y


def run_command():
    """Run CONFIG[command]."""
    # Only some commands can take arguments
    if CONFIG['parameters'] and CONFIG['command'] not in (
            'list', 'iplist', 'set', 'resolve', 'help'):
        return command_help()

    # Help doesn't need root
    if CONFIG['command'] == 'help':
        return command_help(error=False)

    # Warning if not running as root
    CONFIG['root_power'] = not os.getuid()
    if not CONFIG['root_power']:
        warning('Foomuuri should be run as "root"')

    # Run simple commands without ruleset output
    handler = {
        'reload': command_reload,
        'status': command_status,
        'block': command_block,
        'list': command_list,
        'set': command_set,
        'iplist': command_iplist,
        'resolve': command_resolve,
        'dbus': command_dbus,
        'monitor': command_monitor,
    }.get(CONFIG['command'])
    if handler:
        return handler()

    # Run commands with ruleset output
    if CONFIG['command'] in ('start', 'check'):
        command_start()
    elif CONFIG['command'] == 'stop':
        command_stop()
    else:
        fail(f'Unknown command: {CONFIG["command"]}')

    # Save and apply changes
    save_final(CONFIG['next_file'])
    return apply_final()


def main():
    """Parse command line and run command."""
    parse_command_line()
    return run_command()


if __name__ == '__main__':
    try:
        sys.exit(main())
    except BrokenPipeError:
        # Python flushes standard streams on exit; redirect remaining output
        # to devnull to avoid another BrokenPipeError at shutdown
        os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
        sys.exit(1)
