"""
Moodle Development Kit

Copyright (c) 2025 Frédéric Massart

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with this program.  If not, see <http://www.gnu.org/licenses/>.

http://github.com/FMCorz/mdk
"""

import argparse
import json
import logging
import re
from pathlib import Path
from typing import Optional
from urllib.parse import urlencode

from mdk.command import Command
from mdk.docker import (docker_container_exists, ensure_docker_network_exists, get_local_docker_port, is_docker_container_running)
from mdk.moodle import Moodle
from mdk.tools import get_major_version_from_release, open_in_browser, process

logger = logging.getLogger(__name__)


class DockerCommand(Command):

    _description = "Manages container instances"

    def setup_args(self, parser):

        parent = argparse.ArgumentParser(add_help=False)
        parent.add_argument('instance', nargs='?', help='name of the instance')

        subparser = parser.add_subparsers(dest='action', metavar='action', help='the action to perform', required=True)

        # Up, down, rm, stop.
        upparser = subparser.add_parser('up', parents=[parent], help='create and start Moodle in a container')
        upparser.add_argument('-p', '--port', metavar='port', type=int, help='the local port to use')
        upparser.add_argument(
            '-v',
            '--php',
            metavar='version',
            dest='phpversion',
            choices=['7.4', '8.0', '8.1', '8.2', '8.3', '8.4'],
            help='the PHP version to use',
        )
        upparser.add_argument('-N', '--no-create', action='store_true', help='do not create the container if it does not exist')
        upparser.add_argument('--xdebug', action='store_true', help='enable the Xdebug extension in the container')

        downparser = subparser.add_parser('down', parents=[parent], help='stop and remove the Moodle container')
        rmparser = subparser.add_parser('rm', parents=[parent], help='remove the Moodle container')
        stopparser = subparser.add_parser('stop', parents=[parent], help='stop the Moodle container')

        # Database.
        dbparser = subparser.add_parser('db', help='manage the database containers')
        subdbparser = dbparser.add_subparsers(
            dest='subaction',
            metavar='action',
            help='the database action to perform',
            required=True,
        )

        updbparser = subdbparser.add_parser('up', help='create and start the database container')
        engines = [k for k, v in self.C.get('db').items() if type(v) is dict and v.get('dockername')]
        updbparser.add_argument('name', help='the name of the database profile', choices=engines)

        # Adminer.
        adminerparser = subparser.add_parser('adminer', help='manage the Adminer container')
        subadminerparser = adminerparser.add_subparsers(
            dest='subaction',
            metavar='action',
            help='the Adminer action to perform',
            required=True,
        )
        upadminerparser = subadminerparser.add_parser('up', parents=[parent], help='create and start the Adminer container')
        upadminerparser.add_argument(
            '-o',
            '--open',
            action='store_true',
            help='open the Adminer URL in the browser',
        )
        upadminerparser.add_argument(
            '-t',
            '--table',
            help='the table to open (prefix optional)',
        )
        downadminerparser = subadminerparser.add_parser('down', help='stop and remove the container')
        openadminerparser = subadminerparser.add_parser('open', parents=[parent], help='open the Adminer URL in the browser')
        openadminerparser.add_argument(
            '-t',
            '--table',
            help='the table to open (prefix optional)',
        )

        # Selenium.
        seleniumparser = subparser.add_parser('selenium', help='manage the Selenium container')
        subseleniumparser = seleniumparser.add_subparsers(
            dest='subaction',
            metavar='action',
            help='the Selenium action to perform',
            required=True,
        )
        upseleniumparser = subseleniumparser.add_parser('up', parents=[parent], help='create and start the Selenium container')
        upseleniumparser.add_argument(
            '-t',
            '--variant',
            metavar='variant',
            choices=['firefox', 'chrome', 'chromium'],
            default='firefox',
            help='the browser variant to use'
        )
        downseleniumparser = subseleniumparser.add_parser('down', parents=[parent], help='stop and remove the container')
        downseleniumparser.add_argument(
            '-t',
            '--variant',
            metavar='variant',
            choices=['firefox', 'chrome', 'chromium'],
            default='firefox',
            help='the browser variant to use'
        )

    def run(self, args):
        fnname = f'run_{args.action}' if 'subaction' not in args else f'run_{args.action}_{args.subaction}'
        fn = getattr(self, fnname, None)
        if not fn:
            raise NotImplementedError(f'Method "{fnname}" is not implemented.')
        fn(args)

    def run_up(self, args: argparse.Namespace):
        M = self.Wp.resolve(args.instance, raise_exception=True)
        dockername = M.identifier
        dockernet = self.C.get('docker.network')

        if is_docker_container_running(dockername):
            logging.info(f'The container "{dockername}" is already running.')
            return

        elif docker_container_exists(dockername):
            logging.info(f'Starting existing container "{dockername}".')
            process(['docker', 'start', dockername])
            return

        elif args.no_create:
            raise Exception(f'The container "{dockername}" does not exist, creation not allowed.')

        port = args.port
        if not port:
            portmatch = re.search(r'^https?://.*:(\d+)(/.*)?$', M.get('wwwroot'))
            port = int(portmatch.group(1)) if portmatch else 8800

        if is_port_in_use(port):
            raise Exception(f'The port {port} is already in use. Please choose another one.')

        phpversion = args.phpversion
        if not phpversion:
            logging.info('PHP version not specified, trying to guess from instance.')
            phpversion = guess_php_version(M)

        if not phpversion:
            raise Exception('The argument --php-version (-v) is required to create the container.')

        dataroot = self.Wp.getPath(M.identifier, 'data')
        behatfaildumps = self.Wp.getExtraDir(M.identifier, 'behat')

        ensure_docker_network_exists(dockernet)

        envvars = {
            'MDK_DOCKER_NAME': dockername,
        }
        if args.xdebug:
            envvars['PHP_EXTENSION_xdebug'] = '1'
            envvars['PHP_INI-xdebug.client_host'] = 'host.docker.internal'
            envvars['PHP_INI-xdebug.mode'] = 'develop,debug'
            envvars['PHP_INI-xdebug.start_with_request'] = 'yes'

        r, _, _ = process(
            [
                'docker',
                'run',
                '-d',
                '--name',
                dockername,
                '--network',
                dockernet,
                '--volume',
                f'{M.path}:/var/www/html',
                '--volume',
                f'{dataroot}:/var/www/moodledata',
                '--volume',
                f'{behatfaildumps}:/var/www/behatfaildumps',
                *[item for k, v in envvars.items() for item in ['-e', f'{k}={v}']],
                '-p',
                f'{port}:80',
                '--add-host',
                'host.docker.internal:host-gateway',
                f'moodlehq/moodle-php-apache:{phpversion}',
            ],
            stdout=None,
            stderr=None,
        )

        if r != 0:
            raise Exception('Failed to start the container.')

        logging.info(f'The container "{dockername}" has been started on port {port}.')

    def run_down(self, args: argparse.Namespace):
        M = self.Wp.resolve(args.instance, raise_exception=True)
        dockername = M.identifier
        if is_docker_container_running(dockername) and not self._stop(dockername):
            return
        self._rm(dockername)

    def run_rm(self, args: argparse.Namespace):
        M = self.Wp.resolve(args.instance, raise_exception=True)
        dockername = M.identifier
        if is_docker_container_running(dockername):
            raise Exception(f'The container "{dockername}" is running. Please stop, or use command down.')
        elif not docker_container_exists(dockername):
            logging.info(f'The container "{dockername}" does not exist, nothing to remove.')
            return
        self._rm(dockername)

    def run_stop(self, args: argparse.Namespace):
        M = self.Wp.resolve(args.instance, raise_exception=True)
        dockername = M.identifier
        self._stop(dockername)

    def run_db_up(self, args: argparse.Namespace):
        dbprofile = self.C.get('db')[args.name]
        dockernet = self.C.get('docker.network')
        dockername = dbprofile.get('dockername')

        if not dockername:
            raise ValueError('Invalid database profile, it must a docker profile and declare `dockername`.')
        elif dbprofile['engine'] != 'pgsql':
            raise NotImplementedError(f'The database engine "{dbprofile["engine"]}" is not supported by this command.')

        if is_docker_container_running(dockername):
            logging.info(f'The database container "{dockername}" is already running.')
            return

        elif docker_container_exists(dockername):
            logging.info(f'Starting existing database container "{dockername}".')
            process(['docker', 'start', dockername])
            return

        ensure_docker_network_exists(dockernet)

        # Prepare a local folder to store the data.
        pgdata = Path(self.C.get('dirs.mdk'), 'pgsqldata', args.name).expanduser().absolute()
        if not pgdata.exists():
            logging.info(f'Creating PostgreSQL data directory at {pgdata}.')
            pgdata.mkdir(parents=True, exist_ok=True)
            pgdata.chmod(0o777)

        # The mountpoint changes with Postgres 18
        dockerimage = dbprofile.get('dockerimage', 'postgres:latest')
        r, volumesraw, _ = process(['docker', 'image', 'inspect', '--format', '{{json .Config.Volumes}}', dockerimage])
        if r != 0:
            process(['docker', 'pull', dockerimage], stdout=None, stderr=None)
            r, volumesraw, _ = process(['docker', 'image', 'inspect', '--format', '{{json .Config.Volumes}}', dockerimage])
        if r != 0:
            raise Exception(f'Could not inspect the Docker image "{dockerimage}".')
        volumes = json.loads(volumesraw) or {}
        mountpoint = '/var/lib/postgresql' if '/var/lib/postgresql' in volumes else '/var/lib/postgresql/data'

        r, _, _ = process(
            [
                'docker',
                'run',
                '-d',
                '--name',
                dockername,
                '--network',
                dockernet,
                '--volume',
                f'{pgdata}:{mountpoint}',
                '-e',
                f'MDK_DOCKER_NAME={dockername}',
                '-e',
                f'POSTGRES_PASSWORD={dbprofile["passwd"]}',
                dbprofile.get('dockerimage', 'postgres:latest'),
            ],
            stdout=None,
            stderr=None,
        )

        if r != 0:
            raise Exception('Failed to start the database container.')

        logging.info(f'The database container "{dockername}" has been started.')

    def run_adminer_up(self, args: argparse.Namespace):
        instance = self.Wp.resolve(args.instance)

        dockernet = self.C.get('docker.network')
        imagename = self.C.get('adminer.image')
        dockername = self.C.get('adminer.name')
        port = self.C.get('adminer.port')

        if not docker_container_exists(dockername):
            ensure_docker_network_exists(dockernet)

            r, _, _ = process(
                [
                    'docker',
                    'run',
                    '-d',
                    '--restart',
                    'unless-stopped',
                    '--name',
                    dockername,
                    '--network',
                    dockernet,
                    '-p',
                    f'{port}:8080',
                    '-e',
                    f'MDK_DOCKER_NAME={dockername}',
                    imagename,
                ],
                stdout=None,
                stderr=None,
            )

            if r != 0:
                raise Exception('Failed to start the container.')

            logging.info(f'The container "{dockername}" has been started.')

        elif not is_docker_container_running(dockername):
            logging.info(f'The container "{dockername}" already exists, starting...')
            r, _, _ = process(['docker', 'start', dockername], stdout=None, stderr=None)
            if r != 0:
                raise Exception('Failed to start the container.')

        else:
            logging.info(f'The container "{dockername}" is already running.')

        if args.open:
            adminer_open(self.C.get('adminer.port'), instance, args.table)

    def run_adminer_down(self, args: argparse.Namespace):
        self._stop(self.C.get('adminer.name'))
        self._rm(self.C.get('adminer.name'))

    def run_adminer_open(self, args: argparse.Namespace):
        if not is_docker_container_running(self.C.get('adminer.name')):
            raise Exception(f'The container is not running, see the up command.')
        instance = self.Wp.resolve(args.instance)
        adminer_open(self.C.get('adminer.port'), instance, args.table)

    def run_selenium_up(self, args: argparse.Namespace):
        instance = self.Wp.resolve(args.instance)

        dockernet = self.C.get('docker.network')
        imagename = f'selenium/standalone-{args.variant}:3'
        dockername = f'selenium-{args.variant}'

        if is_docker_container_running(dockername):
            logging.info(f'The Selenium container "{dockername}" is already running.')
            return

        elif docker_container_exists(dockername):
            raise Exception(f'The container "{dockername}" already exists. Please remove it first.')

        ensure_docker_network_exists(dockernet)

        r, _, _ = process(
            [
                'docker',
                'run',
                '--rm',  # Delete when stopped.
                '-d',
                '--name',
                dockername,
                '--network',
                dockernet,
            ] + ([
                '--volume',
                f'{instance.path}:/var/www/html',
            ] if instance else []) + [
                '-e',
                f'MDK_DOCKER_NAME={dockername}',
                imagename,
            ],
            stdout=None,
            stderr=None,
        )

        if r != 0:
            raise Exception('Failed to start the Selenium container.')

        logging.info(f'The Selenium container "{dockername}" has been started.')

    def run_selenium_down(self, args: argparse.Namespace):
        dockername = f'selenium-{args.variant}'
        self._stop(dockername)

    def _rm(self, name) -> bool:
        if not is_mdk_container(name):
            raise Exception(f'The container "{name}" does not appear to be managed by MDK, not removing it.')

        logging.info(f'Removing the container "{name}".')
        r, _, _ = process(['docker', 'rm', name], stdout=None, stderr=None)
        return r == 0

    def _stop(self, name) -> bool:
        if not is_docker_container_running(name):
            logging.info(f'The container "{name}" is not running.')
            return False

        r, _, _ = process(['docker', 'stop', name], stdout=None, stderr=None)
        return r == 0


def docker_get_container_env(name: str) -> dict:
    """Get the container env."""
    r, out, _ = process(['docker', 'inspect', '--format', '{{join .Config.Env "\\n"}}', name])
    if r != 0:
        raise Exception(f'Could not get the environment variables from the container "{name}".')

    envs = {}
    for line in out.splitlines():
        if '=' not in line:
            continue
        key, value = line.split('=', 1)
        envs[key] = value

    return envs


def guess_php_version(M: Moodle) -> Optional[str]:
    envfile = Path(M.path) / Path('admin/environment.xml')
    if not envfile.exists():
        raise Exception('The environment file does not exist in the container.')

    phpversion = None
    version = get_major_version_from_release(M.get('release'))

    import xml.etree.ElementTree as ET
    tree = ET.parse(str(envfile))
    root = tree.getroot()

    lastmnode = root.findall('MOODLE')[-1] if root.findall('MOODLE') else None
    mnode = None
    for m in root.findall('MOODLE'):
        if m.attrib.get('version').strip() != version:
            continue

        mnode = m
        break

    mnode = mnode if mnode else lastmnode  # Not finding any means dev version, so use last version.
    if mnode:
        for php in mnode.findall('PHP'):
            if php.attrib.get('level') == 'required':
                phpversion = php.attrib.get('version').strip()
                break

    return '.'.join(phpversion.split('.')[:2]) if phpversion else None


def is_mdk_container(name: str) -> bool:
    """Check if the container is managed by MDK."""
    env = docker_get_container_env(name)
    return 'MDK_DOCKER_NAME' in env and env['MDK_DOCKER_NAME'] == name


def is_port_in_use(port: int) -> bool:
    import socket
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        return s.connect_ex(('localhost', port)) == 0


def adminer_open(port: int, M: Optional[Moodle], table: Optional[str] = None):
    url = f'http://localhost:{port:d}'
    qs = {}
    if M and M.get('dbtype') == 'pgsql':
        qs['pgsql'] = M.get('dbhost')
        qs['db'] = M.get('dbname')
        qs['username'] = M.get('dbuser')
        qs['password'] = M.get('dbpass')
        qs['ns'] = 'public'
        if table:
            if not table.startswith(M.get('prefix')):
                table = M.get('prefix') + table
            qs['select'] = table
        url += '?' + urlencode(qs)

    open_in_browser(url)
