#
#    Licensed under the Apache License, Version 2.0 (the "License"); you may
#    not use this file except in compliance with the License. You may obtain
#    a copy of the License at
#
#         http://www.apache.org/licenses/LICENSE-2.0
#
#    Unless required by applicable law or agreed to in writing, software
#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
#    License for the specific language governing permissions and limitations
#    under the License.

"""
Systemd Quadlet console container provider.
"""
import json
import os
import re
import socket
import time

from oslo_concurrency import processutils
from oslo_log import log as logging

from ironic.common import exception
from ironic.common import utils
from ironic.conf import CONF
from ironic.console.container import base
from ironic.console.rfb import auth

LOG = logging.getLogger(__name__)

TEMPLATE_PREFIX = 'ironic-console'

# "podman port" output is of the format
# 5900/tcp -> 127.0.0.1:12345
#             ^^^^^^^^^ ^^^^^
PORT_RE = re.compile('^5900/tcp -> (.*):([0-9]+)$')


class SystemdConsoleContainer(base.BaseConsoleContainer):
    """Console container provider which uses Systemd Quadlets."""

    unit_dir = None

    def __init__(self):

        # confirm podman and systemctl are available
        try:
            utils.execute('systemctl', '--version')
        except processutils.ProcessExecutionError as e:
            LOG.exception('systemctl not available, '
                          'this provider cannot be used.')
            raise exception.ConsoleContainerError(provider='systemd', reason=e)
        try:
            utils.execute('podman', '--version')
        except processutils.ProcessExecutionError as e:
            LOG.exception('podman not available, '
                          'it is mandatory to use this provider.')
            raise exception.ConsoleContainerError(provider='systemd', reason=e)

    def _init_unit_dir(self, unit_dir=None):

        if unit_dir:
            self.unit_dir = unit_dir

        elif not self.unit_dir:

            # Write container files to
            # /etc/containers/systemd/users/{uid}/containers/systemd
            # Containers are stateless and can be run rootless as user
            # containers.
            uid = str(os.getuid())
            user_dir = os.path.join('/etc/containers/systemd/users', uid)
            if not os.path.isdir(user_dir):
                reason = (f'Directory {user_dir} must exist and be writable '
                          f'by user {uid}')
                raise exception.ConsoleContainerError(
                    provider='systemd', reason=reason)

            self.unit_dir = os.path.join(
                '/etc/containers/systemd/users', uid, 'containers/systemd')

        if not os.path.exists(self.unit_dir):
            try:
                os.makedirs(self.unit_dir)
            except OSError as e:
                LOG.exception(
                    'Could not initialize console containers')
                raise exception.ConsoleContainerError(
                    provider='systemd', reason=e)

    def _container_path(self, identifier):
        """Build a container path.

        :param identifier: Optional identifier to include in the path
        :returns: A quadlet .container file path
        """
        return os.path.join(
            self.unit_dir, f'{TEMPLATE_PREFIX}-{identifier}.container')

    def _unit_name(self, identifier):
        """Build a unit name.

        :param identifier: Optional identifier to include in the name
        :returns: Unit service name which corresponds to a .container file
        """
        return f'{TEMPLATE_PREFIX}-{identifier}.service'

    def _container_name(self, identifier):
        """Build a container name.

        :param identifier: Optional identifier to include in the name
        :returns: The name of the podman container created by systemd
                  quadlet container
        """
        return f'systemd-{TEMPLATE_PREFIX}-{identifier}'

    def _reload(self):
        """Call systemctl --user daemon-reload

        :raises: ConsoleContainerError
        """
        try:
            utils.execute('systemctl', '--user', 'daemon-reload')
        except processutils.ProcessExecutionError as e:
            LOG.exception('Problem calling systemctl daemon-reload')
            raise exception.ConsoleContainerError(provider='systemd', reason=e)

    def _start(self, unit):
        """Call systemctl --user start.

        :param unit: Name of the unit to start
        :raises: ConsoleContainerError
        """
        try:
            utils.execute('systemctl', '--user', 'start', unit)
        except processutils.ProcessExecutionError as e:
            LOG.exception('Problem calling systemctl start')
            # output the status and journal for this unit
            try:
                out, _ = utils.execute('systemctl', '--user', 'status',
                                       unit, check_exit_code=False)
                LOG.error(out)
                out, _ = utils.execute(
                    'journalctl', '--user', '--no-pager', '-u', unit,
                    check_exit_code=False)
                LOG.error(out)
            except Exception:
                pass
            raise exception.ConsoleContainerError(provider='systemd', reason=e)

    def _stop(self, unit):
        """Call systemctl --user stop.

        :param unit: Name of the unit to stop
        :raises: ConsoleContainerError
        """
        try:
            # Fetch the journal before stopping. If debug logging is enabled
            # then this will log the journal to the conductor log.
            utils.execute(
                'journalctl', '--user', '--no-pager', '-u', unit,
                check_exit_code=False)
            utils.execute('systemctl', '--user', 'stop', unit)
        except processutils.ProcessExecutionError as e:
            LOG.exception('Problem calling systemctl stop')
            # output the status for this unit
            try:
                out, _ = utils.execute('systemctl', '--user', 'status',
                                       unit, check_exit_code=False)
                LOG.error(out)
            except Exception:
                pass
            raise exception.ConsoleContainerError(provider='systemd', reason=e)

    def _host_port(self, container):
        """Extract running host and port from a container.

        Calls 'podman port' and parses the result.

        :param container: container name
        :returns: Tuple of host IP address and published port
        :raises: ConsoleContainerError
        """
        try:
            out, _ = utils.execute('podman', 'port', container)
            match = PORT_RE.match(out)
            if match:
                return match.group(1), int(match.group(2))
            raise exception.ConsoleContainerError(
                provider='systemd',
                reason=f'Could not detect port in the output: {out}')

        except processutils.ProcessExecutionError as e:
            LOG.exception('Problem calling podman port %s', container)
            raise exception.ConsoleContainerError(provider='systemd', reason=e)

    def _write_container_file(self, identifier, app_name, app_info):
        """Create quadlet container file.

        :param identifier: Unique container identifier
        :param app_name: Sets container environment APP value
        :param app_info: Sets container environment APP_INFO value
        :raises: ConsoleContainerError
        """
        try:
            container_file = self._container_path(identifier)

            # TODO(stevebaker) Support bind-mounting certificate files to
            # handle verified BMC certificates

            params = {
                'description': 'A VNC server which displays a console '
                               f'for node {identifier}',
                'image': CONF.vnc.console_image,
                'port': CONF.vnc.systemd_container_publish_port,
                'app': app_name,
                'app_info': json.dumps(app_info),
                'read_only': CONF.vnc.read_only,
            }

            LOG.debug('Writing %s', container_file)
            with open(container_file, 'wt') as fp:
                fp.write(utils.render_template(
                    CONF.vnc.systemd_container_template, params=params))

        except OSError as e:
            LOG.exception('Could not start console container')
            raise exception.ConsoleContainerError(provider='systemd', reason=e)

    def _delete_container_file(self, identifier):
        """Delete container file.

        :param identifier: Unique container identifier
        :raises: ConsoleContainerError
        """
        container_file = self._container_path(identifier)

        try:
            if os.path.exists(container_file):
                LOG.debug('Removing file %s', container_file)
                os.remove(container_file)
        except OSError as e:
            LOG.exception('Could not stop console containers')
            raise exception.ConsoleContainerError(provider='systemd', reason=e)

    def start_container(self, task, app_name, app_info):
        """Start a console container for a node.

        Starts a console container for the task's node.

        :param task: A TaskManager instance.
        :param app_name: Sets container environment APP value
        :param app_info: Sets container environment APP_INFO value
        :raises: ConsoleContainerError
        """
        self._init_unit_dir()
        node = task.node
        uuid = node.uuid

        LOG.debug('Starting console container for node %s', uuid)

        self._write_container_file(
            identifier=uuid, app_name=app_name, app_info=app_info)

        # notify systemd to changed file
        self._reload()

        # start the container
        unit = self._unit_name(uuid)
        try:
            self._start(unit)
        except Exception as e:
            try:
                self._delete_container_file(uuid)
                pass
            except Exception:
                pass
            raise e

        container = self._container_name(uuid)

        host, port = self._host_port(container)
        self._wait_for_listen(host, port)
        return host, port

    def _wait_for_listen(self, host, port):
        """Blocks until VNC port is returning data"""
        for i in range(CONF.vnc.wait_for_ready_timeout):
            try:
                # open a TCP socket using host and port and request 12 bytes of
                # data. This will either fail to connect, or return zero bytes
                # until the container is listening on the port.
                LOG.debug("Attempt %s to connect to %s:%s", i, host, port)
                with socket.create_connection((host, port), timeout=1) as sock:
                    b = sock.recv(auth.VERSION_LENGTH)
                    if len(b) == auth.VERSION_LENGTH:
                        return
                    LOG.debug("Expected %s bytes, got %s",
                              auth.VERSION_LENGTH, len(b))
            except Exception:
                pass
            time.sleep(1)
        reason = f"RFB data not returned by {host}:{port}"
        raise exception.ConsoleContainerError(
            provider='systemd', reason=reason)

    def _stop_container(self, identifier):
        """Stop a console container for a node.

        Any existing running container for this node will be stopped.

        :param identifier: Unique container identifier
        :raises: ConsoleContainerError
        """
        unit = self._unit_name(identifier)
        try:
            # stop any running container
            self._stop(unit)
        except Exception:
            pass

        self._delete_container_file(identifier)

    def stop_container(self, task):
        """Stop a console container for a node.

        Any existing running container for this node will be stopped.

        :param task: A TaskManager instance.
        :raises: ConsoleContainerError
        """
        self._init_unit_dir()
        node = task.node
        uuid = node.uuid
        LOG.debug('Stopping console container for node %s', uuid)
        self._stop_container(uuid)
        # notify systemd to changed file
        self._reload()

    def stop_all_containers(self):
        """Stops all running console containers

        This is run on conductor startup and graceful shutdown to ensure
        no console containers are running.
        :raises: ConsoleContainerError
        """
        LOG.debug('Stopping all console containers')
        self._init_unit_dir()
        stop_count = 0
        if not os.path.exists(self.unit_dir):
            # No unit state, so assume no containers are running
            return

        for filename in os.listdir(self.unit_dir):
            if not filename.startswith(TEMPLATE_PREFIX):
                # ignore containers this isn't managing
                continue

            stop_count = stop_count + 1
            try:
                # get the identifier from the filename and stop the container
                identifier = filename.split(
                    f'{TEMPLATE_PREFIX}-')[1].split('.container')[0]
                self._stop_container(identifier)
            except Exception:
                pass

        if stop_count > 0:
            try:
                # notify systemd to changed file
                self._reload()
            except Exception:
                pass
