#!/usr/bin/python3
"""Manage the web sites Caddy serves from the command line, with vpnrouter_sites.

A site has a name, which also names its folder in /var/lib/vpn-router/sites, host names, a port, the interfaces whose
firewall opens it, TLS (none, acme, ca with --ca, or file with --certificate and --key), the file served for a folder,
whether any path without a file is served that file of the site (--spa), and the group whose members change its files.
A change is applied by restarting vpn-router-setup without waiting for it. The answer is text for people on standard
output, and a failure is a message on standard error. With --json the answer is JSON on standard output, and a failure
is {"error": ...} there too. The exit status says what failed: 2 the request is invalid, 3 the site does not exist, 4
the site exists, 1 anything else. -h or --help shows the commands and their options.

  vpn-router-site [--json] list
  vpn-router-site [--json] show <name>
  vpn-router-site [--json] add <name> [options]
  vpn-router-site [--json] update <name> [options]
  vpn-router-site [--json] remove <name> [--content]

  options: [--host H ...] [--no-hosts] [--port P] [--listen internal|external|both] [--tls none|acme|ca|file]
           [--ca NAME] [--certificate FILE] [--key FILE] [--index FILE] [--spa | --no-spa]
           [--enabled | --no-enabled] [--editors GROUP]
"""

import argparse
import json
import subprocess
import sys

sys.path.insert(0, '/usr/lib/vpn-router')

import ldap

import vpnrouter
import vpnrouter_sites
from vpnrouter_sites import Failure

#: The help line of each command.
HELP = {
    'list': 'list the sites',
    'show': 'show a site',
    'add': 'add a site',
    'update': 'change the settings of a site',
    'remove': 'remove a site; its folder is kept without --content',
}


class Parser(argparse.ArgumentParser):
    """An argument parser whose errors are Failures with status 2, so that --json answers them as JSON."""

    def error(self, message):
        raise Failure(f'{message}; see {self.prog} --help', 2)


def parse(argv):
    parser = Parser(prog='vpn-router-site', description='Manage the web sites of the router.')
    parser.add_argument('--json', action='store_true', help='answer in JSON')
    commands = parser.add_subparsers(dest='command', required=True, metavar='command', parser_class=Parser)
    for command, text in HELP.items():
        sub = commands.add_parser(command, help=text, description=text[0].upper() + text[1:] + '.')
        if command != 'list':
            sub.add_argument('name', help='the site name')
        if command in ('add', 'update'):
            sub.add_argument('--host', dest='hosts', action='append', metavar='HOST',
                             help='a host name; give it once for each name, replacing the current ones')
            sub.add_argument('--no-hosts', action='store_true', help='answer any host name')
            sub.add_argument('--port', type=int, metavar='PORT', help='the TCP port')
            sub.add_argument('--listen', choices=vpnrouter_sites.LISTEN, help='the interfaces whose firewall opens the port')
            sub.add_argument('--tls', choices=vpnrouter_sites.TLS, help='where the certificate comes from')
            sub.add_argument('--ca', metavar='NAME', help='the issuing CA of tls ca')
            sub.add_argument('--certificate', metavar='FILE', help='the PEM certificate chain of tls file')
            sub.add_argument('--key', metavar='FILE', help='the PEM private key of tls file')
            sub.add_argument('--index', metavar='FILE', help='the file served for a folder')
            sub.add_argument('--spa', action=argparse.BooleanOptionalAction, help='serve the index of the site for any '
                             'path without a file')
            sub.add_argument('--enabled', action=argparse.BooleanOptionalAction, help='serve the site')
            sub.add_argument('--editors', metavar='GROUP', help="the group whose members change the files; '' for none")
        if command == 'remove':
            sub.add_argument('--content', action='store_true', help='remove the site\'s folder too')
    return parser.parse_args(argv)


def values_of(options):
    """The settings the options give."""
    values = {key: getattr(options, option) for key, option in (('port', 'port'), ('listen', 'listen'), ('tls', 'tls'),
                                                               ('ca', 'ca'), ('index_file', 'index'), ('spa', 'spa'),
                                                               ('enabled', 'enabled'), ('editors', 'editors'))
              if getattr(options, option) is not None}
    if options.no_hosts:
        values['hosts'] = []
    elif options.hosts:
        values['hosts'] = options.hosts
    for key, option in (('certificate', 'certificate'), ('private_key', 'key')):
        path = getattr(options, option)
        if path is not None:
            try:
                with open(path, encoding='utf-8') as handle:
                    values[key] = handle.read()
            except OSError as exc:
                raise Failure(f'{path} cannot be read: {exc.strerror}', 2)
    return values


def run(options):
    """The answer of the command the options name, and whether it changed a site."""
    command = options.command
    if command == 'list':
        return {'sites': vpnrouter_sites.listing()}, False
    if command == 'show':
        with vpnrouter_sites.database() as connection:
            return vpnrouter_sites.shown(vpnrouter_sites.get(connection, options.name)), False
    if command == 'remove':
        return vpnrouter_sites.remove(options.name, options.content), True
    cfg = vpnrouter.load_config()
    return vpnrouter_sites.save(cfg, options.name, values_of(options), command == 'add'), True


def text_of(answer):
    """The answer as text for people."""
    if answer.get('removed'):
        return f'site {answer["name"]} removed'
    sites = answer['sites'] if 'sites' in answer else [answer]
    if not sites:
        return 'no sites'
    head = ('SITE', 'ENABLED', 'HOSTS', 'PORT', 'LISTEN', 'TLS', 'EDITORS')
    rows = [(site['name'], 'yes' if site['enabled'] else 'no', ', '.join(site['hosts']) or '*', str(site['port']),
             site['listen'], site['tls'] + (f' {site["ca"]}' if site['tls'] == 'ca' else ''), site['editors'] or '-')
            for site in sites]
    widths = [max(len(row[column]) for row in (head, *rows)) for column in range(len(head))]
    return '\n'.join('  '.join(cell.ljust(width) for cell, width in zip(row, widths)).rstrip() for row in (head, *rows))


def main(argv):
    # A request that cannot be parsed is answered in JSON when --json is among the arguments.
    as_json = '--json' in argv
    try:
        options = parse(argv)
        as_json = options.json
        answer, changed = run(options)
    except Failure as failure:
        message, status = str(failure), failure.status
    except vpnrouter_sites.DatabaseError as exc:
        message, status = str(exc), 1
    except (ldap.LDAPError, OSError) as exc:
        message, status = f'the user directory failed: {exc}', 1
    else:
        if changed:
            subprocess.run(['systemctl', 'restart', '--no-block', 'vpn-router-setup.service'])
        print(json.dumps(answer) if as_json else text_of(answer))
        return 0
    if as_json:
        print(json.dumps({'error': message}))
    else:
        print(f'vpn-router-site: {message}', file=sys.stderr)
    return status


if __name__ == '__main__':
    sys.exit(main(sys.argv[1:]))
