#!/bin/env python3
# crun - OCI runtime written in C
#
# Copyright (C) 2017, 2018, 2019 Giuseppe Scrivano <giuseppe@scrivano.org>
# crun 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 2 of the License, or
# (at your option) any later version.
#
# crun 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 crun.  If not, see <http://www.gnu.org/licenses/>.

import json
import os
import subprocess
import tempfile
import time
from tests_utils import *

def test_pause_unpause():
    """Test pause and unpause commands."""
    if is_rootless():
        return (77, "requires root for cgroup freezer")

    conf = base_config()
    add_all_namespaces(conf, cgroupns=True)
    conf['process']['args'] = ['/init', 'pause']

    cid = None
    try:
        _, cid = run_and_get_output(conf, hide_stderr=True, command='run', detach=True)

        # Verify container is running
        state = json.loads(run_crun_command(['state', cid]))
        if state['status'] != 'running':
            logger.info("container not in running state: %s", state['status'])
            return -1

        # Pause the container
        run_crun_command(['pause', cid])

        # Verify container is paused
        state = json.loads(run_crun_command(['state', cid]))
        if state['status'] != 'paused':
            logger.info("container not in paused state: %s", state['status'])
            return -1

        # Unpause the container
        run_crun_command(['resume', cid])

        # Verify container is running again
        state = json.loads(run_crun_command(['state', cid]))
        if state['status'] != 'running':
            logger.info("container not in running state after resume: %s", state['status'])
            return -1

        return 0

    except subprocess.CalledProcessError as e:
        output = e.output.decode('utf-8', errors='ignore') if e.output else ''
        if "freezer" in output.lower() or "cgroup" in output.lower():
            return (77, "cgroup freezer not available")
        logger.info("test failed: %s", e)
        return -1
    except Exception as e:
        logger.info("test failed: %s", e)
        return -1
    finally:
        if cid is not None:
            run_crun_command(["delete", "-f", cid])


def test_kill_signal():
    """Test kill command with various signals."""

    conf = base_config()
    add_all_namespaces(conf)
    conf['process']['args'] = ['/init', 'pause']

    cid = None
    try:
        _, cid = run_and_get_output(conf, hide_stderr=True, command='run', detach=True)

        # Verify container is running
        state = json.loads(run_crun_command(['state', cid]))
        if state['status'] != 'running':
            logger.info("container not in running state: %s", state['status'])
            return -1

        # Send SIGKILL
        run_crun_command(['kill', cid, 'SIGKILL'])

        # Wait for container to stop
        time.sleep(0.5)

        # Verify container is stopped
        state = json.loads(run_crun_command(['state', cid]))
        if state['status'] != 'stopped':
            logger.info("container not stopped after SIGKILL: %s", state['status'])
            return -1

        return 0

    except Exception as e:
        logger.info("test failed: %s", e)
        return -1
    finally:
        if cid is not None:
            run_crun_command(["delete", "-f", cid])


def test_kill_signal_number():
    """Test kill command with signal number."""

    conf = base_config()
    add_all_namespaces(conf)
    conf['process']['args'] = ['/init', 'pause']

    cid = None
    try:
        _, cid = run_and_get_output(conf, hide_stderr=True, command='run', detach=True)

        # Send signal 9 (SIGKILL)
        run_crun_command(['kill', cid, '9'])

        # Wait for container to stop
        time.sleep(0.5)

        # Verify container is stopped
        state = json.loads(run_crun_command(['state', cid]))
        if state['status'] != 'stopped':
            logger.info("container not stopped after signal 9: %s", state['status'])
            return -1

        return 0

    except Exception as e:
        logger.info("test failed: %s", e)
        return -1
    finally:
        if cid is not None:
            run_crun_command(["delete", "-f", cid])


def test_list_containers():
    """Test list command."""

    conf = base_config()
    add_all_namespaces(conf)
    conf['process']['args'] = ['/init', 'pause']

    cid1 = None
    cid2 = None
    try:
        _, cid1 = run_and_get_output(conf, hide_stderr=True, command='run', detach=True)
        _, cid2 = run_and_get_output(conf, hide_stderr=True, command='run', detach=True)

        # List containers
        output = run_crun_command(['list', '--format', 'json'])
        containers = json.loads(output)

        # Find our containers in the list
        found_cid1 = False
        found_cid2 = False
        for c in containers:
            if c.get('id') == cid1:
                found_cid1 = True
                if c.get('status') != 'running':
                    logger.info("container %s not running: %s", cid1, c.get('status'))
                    return -1
            if c.get('id') == cid2:
                found_cid2 = True
                if c.get('status') != 'running':
                    logger.info("container %s not running: %s", cid2, c.get('status'))
                    return -1

        if not found_cid1:
            logger.info("container %s not found in list", cid1)
            return -1
        if not found_cid2:
            logger.info("container %s not found in list", cid2)
            return -1

        return 0

    except Exception as e:
        logger.info("test failed: %s", e)
        return -1
    finally:
        if cid1 is not None:
            run_crun_command(["delete", "-f", cid1])
        if cid2 is not None:
            run_crun_command(["delete", "-f", cid2])




def test_spec_generation():
    """Test spec command generates valid OCI config."""
    try:
        with tempfile.TemporaryDirectory() as tmpdir:
            # Generate spec
            subprocess.check_call([get_crun_path(), 'spec'], cwd=tmpdir)

            # Verify config.json was created
            config_path = os.path.join(tmpdir, 'config.json')
            if not os.path.exists(config_path):
                logger.info("config.json not created")
                return -1

            # Verify it's valid JSON
            with open(config_path) as f:
                config = json.load(f)

            # Verify basic OCI structure
            if 'ociVersion' not in config:
                logger.info("missing ociVersion")
                return -1
            if 'process' not in config:
                logger.info("missing process")
                return -1
            if 'root' not in config:
                logger.info("missing root")
                return -1

            return 0

    except Exception as e:
        logger.info("test failed: %s", e)
        return -1


def test_spec_rootless():
    """Test spec command with --rootless flag."""
    try:
        with tempfile.TemporaryDirectory() as tmpdir:
            # Generate rootless spec
            subprocess.check_call([get_crun_path(), 'spec', '--rootless'], cwd=tmpdir)

            # Verify config.json was created
            config_path = os.path.join(tmpdir, 'config.json')
            if not os.path.exists(config_path):
                logger.info("config.json not created")
                return -1

            # Verify it's valid JSON
            with open(config_path) as f:
                config = json.load(f)

            # Verify rootless configuration has user namespace
            namespaces = config.get('linux', {}).get('namespaces', [])
            has_user_ns = any(ns.get('type') == 'user' for ns in namespaces)
            if not has_user_ns:
                logger.info("rootless spec missing user namespace")
                return -1

            return 0

    except Exception as e:
        logger.info("test failed: %s", e)
        return -1


def test_kill_sigterm():
    """Test kill command with SIGTERM."""

    conf = base_config()
    add_all_namespaces(conf)
    conf['process']['args'] = ['/init', 'pause']

    cid = None
    try:
        _, cid = run_and_get_output(conf, hide_stderr=True, command='run', detach=True)

        # Send SIGTERM
        run_crun_command(['kill', cid, 'SIGTERM'])

        # Wait for container to stop
        time.sleep(0.5)

        return 0

    except Exception as e:
        logger.info("test failed: %s", e)
        return -1
    finally:
        if cid is not None:
            run_crun_command(["delete", "-f", cid])


def test_kill_all():
    """Test kill command with --all flag."""
    if is_rootless():
        return (77, "requires root for cgroup access")

    conf = base_config()
    add_all_namespaces(conf)
    conf['process']['args'] = ['/init', 'pause']

    cid = None
    try:
        _, cid = run_and_get_output(conf, hide_stderr=True, command='run', detach=True)

        # Kill all processes in container
        run_crun_command(['kill', '--all', cid, 'SIGKILL'])

        # Wait for container to stop
        time.sleep(0.5)

        # Verify container is stopped
        state = json.loads(run_crun_command(['state', cid]))
        if state['status'] != 'stopped':
            logger.info("container not stopped after kill --all: %s", state['status'])
            return -1

        return 0

    except Exception as e:
        logger.info("test failed: %s", e)
        return -1
    finally:
        if cid is not None:
            run_crun_command(["delete", "-f", cid])


def test_list_table_format():
    """Test list command with table format."""

    conf = base_config()
    add_all_namespaces(conf)
    conf['process']['args'] = ['/init', 'pause']

    cid = None
    try:
        _, cid = run_and_get_output(conf, hide_stderr=True, command='run', detach=True)

        # List containers with table format (default)
        output = run_crun_command(['list'])

        # Table output should contain column headers
        if 'ID' not in output and 'STATUS' not in output:
            logger.info("table format missing headers: %s", output[:100])
            return -1

        # Should contain our container ID
        if cid not in output:
            logger.info("container %s not in list output", cid)
            return -1

        return 0

    except Exception as e:
        logger.info("test failed: %s", e)
        return -1
    finally:
        if cid is not None:
            run_crun_command(["delete", "-f", cid])


def test_list_quiet():
    """Test list command with quiet flag."""

    conf = base_config()
    add_all_namespaces(conf)
    conf['process']['args'] = ['/init', 'pause']

    cid = None
    try:
        _, cid = run_and_get_output(conf, hide_stderr=True, command='run', detach=True)

        # List containers in quiet mode
        output = run_crun_command(['list', '-q'])

        # Quiet mode should just list container IDs
        if cid not in output:
            logger.info("container %s not in quiet list output", cid)
            return -1

        return 0

    except Exception as e:
        logger.info("test failed: %s", e)
        return -1
    finally:
        if cid is not None:
            run_crun_command(["delete", "-f", cid])


def test_ps_table_format():
    """Test ps command with table format."""
    if is_rootless():
        return (77, "requires root for cgroup access")

    conf = base_config()
    add_all_namespaces(conf)
    conf['process']['args'] = ['/init', 'pause']

    cid = None
    try:
        _, cid = run_and_get_output(conf, hide_stderr=True, command='run', detach=True)

        # Get process list with table format (default)
        output = run_crun_command(['ps', cid])

        # Table output should contain PID header
        if 'PID' not in output:
            logger.info("ps table format missing PID header")
            return -1

        return 0

    except Exception as e:
        logger.info("test failed: %s", e)
        return -1
    finally:
        if cid is not None:
            run_crun_command(["delete", "-f", cid])


def test_state_command():
    """Test state command."""

    conf = base_config()
    add_all_namespaces(conf)
    conf['process']['args'] = ['/init', 'pause']

    cid = None
    try:
        _, cid = run_and_get_output(conf, hide_stderr=True, command='run', detach=True)

        # Get container state
        output = run_crun_command(['state', cid])
        state = json.loads(output)

        # Verify state structure
        if 'ociVersion' not in state:
            logger.info("state missing ociVersion")
            return -1
        if 'id' not in state:
            logger.info("state missing id")
            return -1
        if state['id'] != cid:
            logger.info("state id mismatch: expected %s, got %s", cid, state['id'])
            return -1
        if 'status' not in state:
            logger.info("state missing status")
            return -1
        if 'pid' not in state:
            logger.info("state missing pid")
            return -1
        if 'bundle' not in state:
            logger.info("state missing bundle")
            return -1

        return 0

    except Exception as e:
        logger.info("test failed: %s", e)
        return -1
    finally:
        if cid is not None:
            run_crun_command(["delete", "-f", cid])




def test_state_created_container():
    """Test state command on a created but not started container."""
    conf = base_config()
    add_all_namespaces(conf)
    conf['process']['args'] = ['/init', 'true']

    cid = None
    proc = None
    try:
        # use_popen=True is required because 'create' blocks waiting for 'start'
        proc, cid = run_and_get_output(conf, hide_stderr=True, command='create', use_popen=True)

        # Wait for container to be ready (create is async with use_popen=True)
        state = None
        for i in range(50):
            try:
                output = run_crun_command(['state', cid])
                state = json.loads(output)
                break
            except Exception:
                time.sleep(0.1)

        if state is None:
            logger.info("test_state_created_container: container never became ready")
            return -1

        # Verify container is in created state
        if state['status'] != 'created':
            logger.info("test_state_created_container: expected 'created', got '%s'", state['status'])
            return -1

        return 0

    except Exception as e:
        logger.info("test_state_created_container failed: %s", e)
        return -1
    finally:
        if cid is not None:
            run_crun_command(["delete", "-f", cid])
        if proc is not None:
            proc.wait()


def test_state_stopped_container():
    """Test state command on a stopped container."""
    conf = base_config()
    add_all_namespaces(conf)
    conf['process']['args'] = ['/init', 'true']

    cid = None
    try:
        _, cid = run_and_get_output(conf, hide_stderr=True, command='run', keep=True)

        # Container should have exited, get state
        output = run_crun_command(['state', cid])
        state = json.loads(output)

        # Verify container is stopped
        if state['status'] != 'stopped':
            logger.info("test_state_stopped_container: expected 'stopped', got '%s'", state['status'])
            return -1

        return 0

    except Exception as e:
        logger.info("test_state_stopped_container failed: %s", e)
        return -1
    finally:
        if cid is not None:
            run_crun_command(["delete", "-f", cid])


def test_features_command():
    """Test features command returns valid JSON."""
    try:
        output = run_crun_command(['features'])
        features = json.loads(output)

        # Verify basic features structure
        if 'ociVersionMin' not in features:
            logger.info("features missing ociVersionMin")
            return -1
        if 'ociVersionMax' not in features:
            logger.info("features missing ociVersionMax")
            return -1

        return 0

    except Exception as e:
        logger.info("test_features_command failed: %s", e)
        return -1


def test_ps_json_format():
    """Test ps command with JSON format."""
    if is_rootless():
        return (77, "requires root for cgroup access")

    conf = base_config()
    add_all_namespaces(conf)
    conf['process']['args'] = ['/init', 'pause']

    cid = None
    try:
        _, cid = run_and_get_output(conf, hide_stderr=True, command='run', detach=True)

        # Get process list with JSON format
        output = run_crun_command(['ps', '--format', 'json', cid])
        processes = json.loads(output)

        # Should have at least one process
        if not isinstance(processes, list) or len(processes) == 0:
            logger.info("test_ps_json_format: no processes returned")
            return -1

        return 0

    except Exception as e:
        logger.info("test_ps_json_format failed: %s", e)
        return -1
    finally:
        if cid is not None:
            run_crun_command(["delete", "-f", cid])


def test_ps_child_cgroup():
    """Test ps command lists processes that moved into a child cgroup when parent cgroup is empty."""
    if not is_cgroup_v2_unified():
        return (77, "requires cgroup v2")
    if is_rootless():
        return (77, "requires root for cgroup access")

    conf = base_config()
    add_all_namespaces(conf, cgroupns=True)
    conf['process']['args'] = ['/init', 'create-sub-cgroup-and-wait', 'foo']

    cid = None
    try:
        proc, cid = run_and_get_output(conf, command='run', detach=True, use_popen=True)

        # Wait for init to close stdout, which signals it has moved into the child cgroup
        proc.stdout.read()

        # Get process list with JSON format
        output = run_crun_command(['ps', '--format', 'json', cid])
        processes = json.loads(output)

        # Should list the process
        if not isinstance(processes, list) or len(processes) != 1:
            logger.info("test_ps_child_cgroup: expected 1 process, got %s", len(processes) if isinstance(processes, list) else processes)
            return -1

        return 0

    except Exception as e:
        logger.info("test_ps_child_cgroup failed: %s", e)
        return -1
    finally:
        if cid is not None:
            run_crun_command(["delete", "-f", cid])


def test_delete_force():
    """Test delete command with force flag on running container."""
    conf = base_config()
    add_all_namespaces(conf)
    conf['process']['args'] = ['/init', 'pause']

    cid = None
    try:
        _, cid = run_and_get_output(conf, hide_stderr=True, command='run', detach=True)

        # Verify container is running
        state = json.loads(run_crun_command(['state', cid]))
        if state['status'] != 'running':
            logger.info("test_delete_force: container not running")
            return -1

        # Force delete running container
        run_crun_command(['delete', '-f', cid])
        cid = None  # Deleted, don't cleanup again

        return 0

    except Exception as e:
        logger.info("test_delete_force failed: %s", e)
        return -1
    finally:
        if cid is not None:
            run_crun_command(["delete", "-f", cid])


def test_start_command():
    """Test start command on a created container."""
    conf = base_config()
    add_all_namespaces(conf)
    conf['process']['args'] = ['/init', 'true']

    cid = None
    proc = None
    try:
        # use_popen=True is required because 'create' blocks waiting for 'start'
        proc, cid = run_and_get_output(conf, hide_stderr=True, command='create', use_popen=True)

        # Wait for container to be ready (create is async with use_popen=True)
        state = None
        for i in range(50):
            try:
                state = json.loads(run_crun_command(['state', cid]))
                break
            except Exception:
                time.sleep(0.1)

        if state is None:
            logger.info("test_start_command: container never became ready")
            return -1

        if state['status'] != 'created':
            logger.info("test_start_command: container not in created state, got '%s'", state['status'])
            return -1

        # Start the container
        run_crun_command(['start', cid])

        # Wait for container to finish
        time.sleep(0.1)

        # Verify container is stopped (since /init true exits immediately)
        state = json.loads(run_crun_command(['state', cid]))
        if state['status'] != 'stopped':
            logger.info("test_start_command: container not stopped after start, status=%s", state['status'])
            return -1

        return 0

    except Exception as e:
        logger.info("test_start_command failed: %s", e)
        return -1
    finally:
        if cid is not None:
            run_crun_command(["delete", "-f", cid])
        if proc is not None:
            proc.wait()


def test_version_command():
    """Test version command."""
    try:
        output = run_crun_command(['--version'])

        # Should contain version info
        if 'crun version' not in output:
            logger.info("test_version_command: missing version info")
            return -1

        return 0

    except Exception as e:
        logger.info("test_version_command failed: %s", e)
        return -1


def test_help_command():
    """Test help command."""
    try:
        output = run_crun_command(['--help'])

        # Should contain usage info
        if 'Usage:' not in output and 'usage:' not in output.lower():
            logger.info("test_help_command: missing usage info")
            return -1

        return 0

    except Exception as e:
        logger.info("test_help_command failed: %s", e)
        return -1


all_tests = {
    "pause-unpause": test_pause_unpause,
    "kill-signal": test_kill_signal,
    "kill-signal-number": test_kill_signal_number,
    "kill-sigterm": test_kill_sigterm,
    "kill-all": test_kill_all,
    "list-containers": test_list_containers,
    "list-table-format": test_list_table_format,
    "list-quiet": test_list_quiet,
    "ps-table-format": test_ps_table_format,
    "ps-json-format": test_ps_json_format,
    "ps-child-cgroup": test_ps_child_cgroup,
    "spec-generation": test_spec_generation,
    "spec-rootless": test_spec_rootless,
    "state-command": test_state_command,
    "state-created": test_state_created_container,
    "state-stopped": test_state_stopped_container,
    "features-command": test_features_command,
    "delete-force": test_delete_force,
    "start-command": test_start_command,
    "version-command": test_version_command,
    "help-command": test_help_command,
}

if __name__ == "__main__":
    tests_main(all_tests)
