#!/usr/bin/python3

"""Launch PostgreSQL and MySQL database, then run tests."""

import os
import shutil
import signal
import socket
import subprocess
import sys
import tempfile
import time
from os.path import exists
from os.path import join

import psycopg2
import pytest

test_mysql = False
try:
    import MySQLdb

    test_mysql = True
except ModuleNotFoundError:
    test_mysql = False

lib_postgresql = "/usr/lib/postgresql/"


class Postgres:
    """Postgres."""

    bin_dir: str
    host: str
    port: int

    def __init__(self, host: str, port: int):
        """Init."""
        if not (bin_dir := self.find_postgresql_bin()):
            print("postgresql bin dir not found")
            sys.exit(1)

        self.bin_dir = bin_dir
        self.host = host
        self.port = port
        self.base_dir = tempfile.mkdtemp()
        os.mkdir(join(self.base_dir, "tmp"))

    def run_initdb(self) -> None:
        """Run initdb."""
        print("running initdb")
        initdb = join(self.bin_dir, "bin", "initdb")

        args = [
            initdb,
            "-D",
            join(self.base_dir, "data"),
            "--lc-messages=C",
            "-U",
            "postgres",
            "-A",
            "trust",
        ]
        subprocess.run(args, check=True)

    def run_server(self) -> subprocess.Popen[str]:
        """Run server."""
        print("starting server")
        postgres = join(self.bin_dir, "bin", "postgres")
        assert os.path.exists(postgres)

        args = [
            postgres,
            "-p",
            str(self.port),
            "-D",
            os.path.join(self.base_dir, "data"),
            "-k",
            os.path.join(self.base_dir, "tmp"),
            "-h",
            self.host,
            "-F",
            "-c",
            "logging_collector=off",
        ]
        return subprocess.Popen(args, text=True, stderr=subprocess.PIPE)

    def stop_server(self, server: subprocess.Popen[str]) -> None:
        """Stop server."""
        print("stopping postgres server")
        if server is None:
            return

        server.send_signal(signal.SIGTERM)
        t0 = time.time()

        while server.poll() is None:
            if time.time() - t0 > 5:
                server.kill()
            time.sleep(0.1)

    def configure(self) -> None:
        """Configure PostgreSQL."""
        conn_params = {"user": "postgres", "host": self.host, "port": self.port}
        for _ in range(50):
            try:
                conn = psycopg2.connect(dbname="template1", **conn_params)
            except psycopg2.OperationalError as e:
                allowed = [
                    "the database system is starting up",
                    "could not connect to server: Connection refused",
                    "failed: Connection refused",
                ]
                if not any(msg in e.args[0] for msg in allowed):
                    raise
                time.sleep(0.1)
                continue
            break
        conn.set_session(autocommit=True)
        cur = conn.cursor()
        cur.execute("CREATE ROLE gis PASSWORD 'gis' SUPERUSER CREATEDB CREATEROLE INHERIT LOGIN")
        cur.execute("CREATE DATABASE gis")
        cur.execute('GRANT CREATE ON DATABASE gis TO "gis"')
        conn.close()

        conn = psycopg2.connect(dbname="gis", **conn_params)
        conn.set_session(autocommit=True)
        cur = conn.cursor()
        cur.execute("CREATE SCHEMA gis")
        cur.execute('GRANT USAGE,CREATE ON SCHEMA gis TO "gis"')
        cur.execute("CREATE EXTENSION postgis")
        cur.execute("CREATE EXTENSION postgis_raster")
        cur.execute("SELECT postgis_version()")
        cur.fetchone()
        conn.close()

    def find_postgresql_bin(self) -> str | None:
        """Find PostgreSQL bin."""
        versions = [int(d) for d in os.listdir(lib_postgresql) if d.isdigit()]

        for v in sorted(versions, reverse=True):
            bin_dir = join(lib_postgresql, str(v))
            if all(exists(join(bin_dir, "bin", f)) for f in ("initdb", "postgres")):
                return bin_dir

        return None


class MySQL:
    """MySQL."""

    base_dir: str
    port: int
    user: str = "root"
    dbname: str = "gis"
    test_user: str = "gis"
    test_password: str = "gis"
    server: subprocess.Popen[str] | None = None

    def __init__(self, port: int):
        """Init."""
        self.base_dir = tempfile.mkdtemp()
        self.port = port
        self.pid_path = os.path.join(self.base_dir, "mysqld.pid")
        self.log_path = os.path.join(self.base_dir, "mysql.err")

    @property
    def mysql_files(self) -> str:
        """Location of mysql-files."""
        return os.path.join(self.base_dir, "mysql-files")

    @property
    def socket_path(self) -> str:
        """Socket location."""
        return f"{self.base_dir}/mysql.sock"

    def initalize(self) -> None:
        """Run command to initialize MySQL ready for tests."""
        if shutil.which("mariadb-install-db"):
            args = [
                "mariadb-install-db",
                "--datadir=" + self.base_dir,
                "--auth-root-authentication-method=normal",
            ]
            subprocess.run(args, stderr=None, check=True)
        elif shutil.which("mysql_install_db"):
            args = ["mysql_install_db", "--datadir=" + self.base_dir]
            subprocess.run(args, stderr=None, check=True)
        else:
            args = [
                "/usr/sbin/mysqld",
                "--initialize-insecure",
                "--datadir=" + self.base_dir,
            ]
            subprocess.run(args, stderr=None, check=True)
        os.mkdir(self.mysql_files)

    def find_server_bin(self) -> str:
        """Find MySQL/MariaDB server binary."""
        candidates = [
            "/usr/sbin/mariadbd",
            "/usr/sbin/mysqld",
            "mariadbd",
            "mysqld",
        ]
        for candidate in candidates:
            if os.path.isabs(candidate) and os.path.exists(candidate):
                return candidate
            if shutil.which(candidate):
                return candidate
        print("mysql server binary not found")
        sys.exit(1)

    def find_admin_bin(self) -> str:
        """Find mysqladmin/mariadb-admin."""
        candidates = ["mysqladmin", "mariadb-admin"]
        for candidate in candidates:
            if shutil.which(candidate):
                return candidate
        print("mysql admin binary not found")
        sys.exit(1)

    def run_server(self) -> subprocess.Popen[str]:
        """Run MySQL server."""
        args = [
            self.find_server_bin(),
            "--datadir=" + self.base_dir,
            f"--socket={self.socket_path}",
            f"--secure-file-priv={self.mysql_files}",
            f"--port={self.port}",
            "--bind-address=127.0.0.1",
            "--skip-name-resolve",
            f"--pid-file={self.pid_path}",
            f"--log-error={self.log_path}",
        ]
        self.server = subprocess.Popen(args, text=True, stderr=subprocess.PIPE)
        return self.server

    def wait_ready(self) -> None:
        """Wait for MySQL server to be ready."""
        admin = self.find_admin_bin()
        for _ in range(200):
            if self.server and self.server.poll() is not None:
                break
            try:
                subprocess.run(
                    [admin, f"--socket={self.socket_path}", "-u", self.user, "ping"],
                    stdout=subprocess.DEVNULL,
                    stderr=subprocess.DEVNULL,
                    check=True,
                )
                return
            except subprocess.CalledProcessError:
                time.sleep(0.1)
        print("mysql server did not become ready")
        if os.path.exists(self.log_path):
            print(self.log_path)
            with open(self.log_path, "r", encoding="utf-8") as handle:
                print(handle.read())
        sys.exit(1)

    def configure(self) -> None:
        """Configure."""
        for _ in range(50):
            try:
                conn = MySQLdb.connect(user=self.user, unix_socket=self.socket_path)  # type: ignore
            except MySQLdb._exceptions.OperationalError as e:
                if e.args[0] != 2002:
                    raise
                time.sleep(0.1)  # waiting for the database to start up
                continue
            break

        cursor = conn.cursor()
        cursor.execute(f"CREATE DATABASE {self.dbname};")
        cursor.execute(
            "CREATE USER IF NOT EXISTS %s@%s IDENTIFIED BY %s;",
            (self.test_user, "localhost", self.test_password),
        )
        cursor.execute(
            "CREATE USER IF NOT EXISTS %s@%s IDENTIFIED BY %s;",
            (self.test_user, "127.0.0.1", self.test_password),
        )
        cursor.execute(
            f"GRANT ALL PRIVILEGES ON {self.dbname}.* TO %s@%s;",
            (self.test_user, "localhost"),
        )
        cursor.execute(
            f"GRANT ALL PRIVILEGES ON {self.dbname}.* TO %s@%s;",
            (self.test_user, "127.0.0.1"),
        )
        cursor.execute(
            "GRANT ALL PRIVILEGES ON *.* TO %s@%s WITH GRANT OPTION;",
            (self.test_user, "localhost"),
        )
        cursor.execute(
            "GRANT ALL PRIVILEGES ON *.* TO %s@%s WITH GRANT OPTION;",
            (self.test_user, "127.0.0.1"),
        )
        cursor.execute("FLUSH PRIVILEGES;")
        cursor.close()
        conn.close()

    @property
    def url(self) -> str:
        """URL."""
        return f"mysql://{self.test_user}:{self.test_password}@127.0.0.1:{self.port}/{self.dbname}"

    def stop_server(self) -> None:
        """Stop MySQL server."""
        print("stopping mysql server")
        args = [self.find_admin_bin(), f"--socket={self.socket_path}", "-u", self.user, "shutdown"]
        subprocess.run(args, check=True)


def get_unused_port(host: str) -> int:
    """Find and return an unused port."""
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.bind((host, 0))
    port: int = sock.getsockname()[1]
    sock.close()

    return port


def clean_up(base_dir: str) -> None:
    """Clean up."""
    print("clean up")
    shutil.rmtree(base_dir, ignore_errors=True)


def main() -> int:
    """Start scratch database server and run tests."""
    host = "localhost"
    pg_port = get_unused_port(host)
    mysql_port = get_unused_port(host)

    if test_mysql:
        mysql = MySQL(mysql_port)
        mysql.initalize()
        mysql.run_server()
        mysql.wait_ready()
        os.environ["PYTEST_MYSQL_DB_URL"] = mysql.url
        os.environ["PYTEST_MARIADB_DB_URL"] = mysql.url.replace("mysql://", "mariadb://", 1)
        mysql.configure()

    pg = Postgres(host, pg_port)
    pg.run_initdb()
    pg_server = pg.run_server()
    pg.configure()
    os.environ["PGPORT"] = str(pg_port)

    skip_tests = []
    if not test_mysql:
        skip_tests.append("mysql")
    if sys.byteorder == "big":
        # skip tests that fail when byteorder is big
        skip_tests += [
            "test_transform",
            "test_WKTElement",
            "test_WKBElement",
            "test_Raster",
            "test_decipher_raster",
            "test_from_shape",
            "test_force_3d",
        ]

    ret = pytest.main(["-k", " and ".join(f"not {t}" for t in skip_tests)])
    pg.stop_server(pg_server)
    assert pg_server.stderr
    pg_server_errors = pg_server.stderr.read()
    if ret:
        print("postgres server error log")
        print()
        print(pg_server_errors)

    clean_up(pg.base_dir)

    if test_mysql:
        mysql.stop_server()
        clean_up(mysql.base_dir)

    return ret


if __name__ == "__main__":
    if os.getuid() == 0:  # Postgres refuses to run as root
        sys.exit(0)

    ret = main()
    sys.exit(ret)
