#!/usr/bin/env python3
import subprocess
import pytest
import os
import stat
import time
from os.path import join as pjoin
import sys
import re
import itertools
from packaging import version
import logging

basename = pjoin(os.path.dirname(__file__), '..')

def parse_kernel_version(release):
    # Extract the first three numbers from the kernel version string
    match = re.match(r'^(\d+\.\d+\.\d+)', release)
    if match:
        return version.parse(match.group(1))
    return version.parse('0')

def get_printcap():
    cmdline = base_cmdline + [ pjoin(basename, 'example', 'printcap') ]
    proc = subprocess.Popen(cmdline, stdout=subprocess.PIPE,
                            universal_newlines=True)
    (stdout, _) = proc.communicate(30)
    assert proc.returncode == 0

    proto = None
    caps = set()
    for line in stdout.split('\n'):
        if line.startswith('\t'):
            caps.add(line.strip())
            continue

        hit = re.match(r'Protocol version: (\d+)\.(\d+)$', line) 
        if hit:
            proto = (int(hit.group(1)), int(hit.group(2)))

    return (proto, caps)

def test_printcap():
    get_printcap()
    return None

def wait_for_mount(mount_process, mnt_dir,
                   test_fn=os.path.ismount):
    elapsed = 0
    while elapsed < 30:
        if test_fn(mnt_dir):
            return True
        if mount_process.poll() is not None:
            if test_fn(mnt_dir):
                return True
            pytest.fail('file system process terminated prematurely')
        time.sleep(0.1)
        elapsed += 0.1
    pytest.fail("mountpoint failed to come up")

def cleanup(mount_process, mnt_dir):
    # Don't bother trying Valgrind if things already went wrong

    if 'bsd' in sys.platform or 'dragonfly' in sys.platform:
        cmd = [ 'umount', '-f', mnt_dir ]
    else:
        cmd = [pjoin(basename, 'util', 'fusermount3'),
                         '-z', '-u', mnt_dir]
    subprocess.call(cmd, stdout=subprocess.DEVNULL,
                    stderr=subprocess.STDOUT)
    mount_process.terminate()
    try:
        mount_process.wait(1)
    except subprocess.TimeoutExpired:
        mount_process.kill()

def umount(mount_process, mnt_dir):
    logger = logging.getLogger(__name__)
    logger.debug(f"Unmounting {mnt_dir}")

    if 'bsd' in sys.platform or 'dragonfly' in sys.platform:
        cmdline = [ 'umount', mnt_dir ]
        logger.debug("Using BSD-style umount command")
    else:
        logger.debug("Using fusermount3 for unmounting")
        # fusermount3 will be setuid root, so we can only trace it with
        # valgrind if we're root
        if os.getuid() == 0:
            cmdline = base_cmdline
            logger.debug("Running as root, using valgrind if configured")
        else:
            cmdline = []
            logger.debug("Not running as root, skipping valgrind for fusermount3")
        cmdline = cmdline + [ pjoin(basename, 'util', 'fusermount3'),
                              '-z', '-u', mnt_dir ]

    logger.debug(f"Unmount command: {' '.join(cmdline)}")
    try:
        result = subprocess.run(cmdline, capture_output=True, text=True, check=True)
        if result.stdout:
            logger.debug(f"Unmount command stdout: {result.stdout}")
        if result.stderr:
            logger.debug(f"Unmount command stderr: {result.stderr}")
    except subprocess.CalledProcessError as e:
        logger.error(f"Unmount command failed with return code {e.returncode}\nStdout: {e.stdout}\nStderr: {e.stderr}")
        raise

    if not os.path.ismount(mnt_dir):
        logger.debug(f"{mnt_dir} is no longer a mount point")
    else:
        logger.warning(f"{mnt_dir} is still a mount point after unmount command")

    # Give mount process a little while to terminate. Popen.wait(timeout)
    # was only added in 3.3...
    elapsed = 0
    while elapsed < 30:
        code = mount_process.poll()
        if code is not None:
            if code == 0:
                return
            logger.error(f"File system process terminated with code {code}")
            pytest.fail(f'file system process terminated with code {code}')
        time.sleep(0.1)
        elapsed += 0.1
    logger.error("Mount process did not terminate within 30 seconds")
    pytest.fail('mount process did not terminate')


def parse_mountinfo(mnt_dir):
    '''Return the /proc/self/mountinfo entry for *mnt_dir*, or None.

    Parses the line for the mountpoint and returns a dict with keys:
      'mountpoint'    - the mount point path (str)
      'fstype'        - filesystem type as the kernel sees it,
                        e.g. 'fuse' or 'fuse.<subtype>' (str)
      'source'        - mount source field, e.g. 'hello' or
                        '<subtype>#<fsname>' fallback form (str)
      'mount_options' - per-mount options/attrs (set of str), e.g.
                        {'rw','nosuid','nodev','noatime','relatime'}
      'super_options' - superblock options from the filesystem (set of
                        str), e.g. {'rw','user_id=1000','group_id=1000',
                        'default_permissions','allow_other','max_read=...'}

    These fields are exactly what /proc/self/mountinfo exposes (see
    `man 5 proc.5_mountinfo`); they capture the post-mount state that
    differs between the legacy mount(2) path and the new fsopen/fsconfig/
    fsmount path. Asserting on this dict catches bugs where one path
    drops a parameter the other passes (e.g. `subtype` showing up in
    `fstype`, `user=` ending up only in /run/mount/utab, ...).
    '''
    target = os.path.realpath(mnt_dir)
    with open('/proc/self/mountinfo') as fh:
        for line in fh:
            parts = line.rstrip('\n').split(' ')
            try:
                sep = parts.index('-')
            except ValueError:
                continue
            if len(parts) < sep + 4 or sep < 6:
                continue
            mountpoint = parts[4].replace('\\040', ' ')
            if mountpoint != target:
                continue
            return {
                'mountpoint':    mountpoint,
                'fstype':        parts[sep + 1],
                'source':        parts[sep + 2].replace('\\040', ' '),
                'mount_options': set(parts[5].split(',')),
                'super_options': set(parts[sep + 3].split(',')),
            }
    return None


def safe_sleep(secs):
    '''Like time.sleep(), but sleep for at least *secs*

    `time.sleep` may sleep less than the given period if a signal is
    received. This function ensures that we sleep for at least the
    desired time.
    '''

    now = time.time()
    end = now + secs
    while now < end:
        time.sleep(end - now)
        now = time.time()

def fuse_test_marker():
    '''Return a pytest.marker that indicates FUSE availability

    If system/user/environment does not support FUSE, return
    a `pytest.mark.skip` object with more details. If FUSE is
    supported, return `pytest.mark.uses_fuse()`.
    '''

    skip = lambda x: pytest.mark.skip(reason=x)

    if 'bsd' in sys.platform or 'dragonfly' in sys.platform:
        return pytest.mark.uses_fuse()

    with subprocess.Popen(['which', 'fusermount3'], stdout=subprocess.PIPE,
                          universal_newlines=True) as which:
        fusermount_path = which.communicate()[0].strip()

    if not fusermount_path or which.returncode != 0:
        return skip("Can't find fusermount executable")

    if not os.path.exists('/dev/fuse'):
        return skip("FUSE kernel module does not seem to be loaded")

    if os.getuid() == 0:
        return pytest.mark.uses_fuse()

    mode = os.stat(fusermount_path).st_mode
    if mode & stat.S_ISUID == 0:
        return skip('fusermount executable not setuid, and we are not root.')

    try:
        fd = os.open('/dev/fuse', os.O_RDWR)
    except OSError as exc:
        return skip('Unable to open /dev/fuse: %s' % exc.strerror)
    else:
        os.close(fd)

    return pytest.mark.uses_fuse()

def powerset(iterable):
  s = list(iterable)
  return itertools.chain.from_iterable(
      itertools.combinations(s, r) for r in range(len(s)+1))

def create_tmpdir(mnt_dir):
    if not os.path.exists(mnt_dir):
        print("makedirs: '" + mnt_dir + "'")
        os.makedirs(mnt_dir)
    else:
        print("mnt_dir exists: '" + mnt_dir + "'")

# Use valgrind if requested
if os.environ.get('TEST_WITH_VALGRIND', 'no').lower().strip() \
   not in ('no', 'false', '0'):
    base_cmdline = [ 'valgrind', '-q', '--' ]
else:
    base_cmdline = []

# Try to use local fusermount3
os.environ['PATH'] = '%s:%s' % (pjoin(basename, 'util'), os.environ['PATH'])
# Put example binaries on PATH
os.environ['PATH'] = '%s:%s' % (pjoin(basename, 'example'), os.environ['PATH'])

try:
    (fuse_proto, fuse_caps) = get_printcap()
except Exception:
    # Rely on test to raise error
    fuse_proto = (0,0)
    fuse_caps = set()

