# SPDX-License-Identifier: AGPL-3.0-or-later
"""
Test module for package module.
"""

import time
import unittest
from unittest.mock import Mock, call, patch

import pytest

from plinth.app import App
from plinth.diagnostic_check import DiagnosticCheck, Result
from plinth.errors import MissingPackageError
from plinth.package import Package, Packages, packages_installed


@pytest.fixture(autouse=True)
def fixture_clean_apps():
    """Fixture to ensure clean set of global apps."""
    App._all_apps = {}


class TestPackageExpressions(unittest.TestCase):

    def test_package(self):
        """Test resolving package names."""
        package = Package('python3')
        assert package.possible() == ['python3']
        assert package.actual() == 'python3'

        package = Package('unknown-package')
        assert package.possible() == ['unknown-package']
        self.assertRaises(MissingPackageError, package.actual)

    def test_package_or_expression(self):
        """Test resolving package OR expressions."""
        expression = Package('python3') | Package('unknown-package')
        assert expression.possible() == ['python3', 'unknown-package']
        assert expression.actual() == 'python3'

        expression = Package('unknown-package') | Package('python3')
        assert expression.possible() == ['unknown-package', 'python3']
        assert expression.actual() == 'python3'

        # When both packages are available, prefer the first.
        expression = Package('bash') | Package('dash')
        assert expression.possible() == ['bash', 'dash']
        assert expression.actual() == 'bash'

        expression = Package('unknown-package') | Package(
            'another-unknown-package')
        assert expression.possible() == [
            'unknown-package', 'another-unknown-package'
        ]
        self.assertRaises(MissingPackageError, expression.actual)


def test_packages_init():
    """Test initialization of packages component."""
    component = Packages('test-component', ['foo', 'bar'])
    assert component.possible_packages == ['foo', 'bar']
    assert component.component_id == 'test-component'
    assert not component.skip_recommends
    assert component.conflicts == []
    assert component.conflicts_action is None
    assert not component.rerun_setup_on_upgrade

    with pytest.raises(ValueError):
        Packages(None, [])

    component = Packages('test-component', [], skip_recommends=True,
                         conflicts=['conflict1', 'conflict2'],
                         conflicts_action=Packages.ConflictsAction.IGNORE,
                         rerun_setup_on_upgrade=True)
    assert component.possible_packages == []
    assert component.skip_recommends
    assert component.conflicts == ['conflict1', 'conflict2']
    assert component.conflicts_action == Packages.ConflictsAction.IGNORE
    assert component.rerun_setup_on_upgrade


@patch('apt.Cache')
def test_packages_get_actual_packages(cache):
    """Test resolving of package expressions to actual packages."""
    cache.return_value = {
        'python3': Mock(name='python3', candidate='1.0'),
        'janus': Mock(name='janus', candidate=None)
    }

    component = Packages('test-component', ['python3'])
    assert component.get_actual_packages() == ['python3']

    component = Packages('test-component', ['unknown-package'])
    with pytest.raises(MissingPackageError):
        component.get_actual_packages()

    component = Packages('test-component',
                         [Package('unknown-package') | Package('python3')])
    assert component.get_actual_packages() == ['python3']

    component = Packages('test-component', [], skip_recommends=True,
                         conflicts=['conflict1', 'conflict2'],
                         conflicts_action=Packages.ConflictsAction.IGNORE)
    assert component.get_actual_packages() == []

    component = Packages('test-component', ['janus'])
    with pytest.raises(MissingPackageError):
        component.get_actual_packages()


@patch('plinth.package.install')
def test_packages_setup(install):
    """Test setting up packages component."""

    class TestApp(App):
        """Test app"""
        app_id = 'test-app'

    component = Packages('test-component', ['python3', 'bash'])
    app = TestApp()
    app.add(component)
    app.setup(old_version=3)
    install.assert_has_calls(
        [call(['python3', 'bash'], skip_recommends=False)])

    component = Packages('test-component', ['bash', 'perl'],
                         skip_recommends=True)
    app = TestApp()
    app.add(component)
    app.setup(old_version=3)
    install.assert_has_calls([call(['bash', 'perl'], skip_recommends=True)])

    component = Packages('test-component',
                         [Package('python3') | Package('unknown-package')])
    app = TestApp()
    app.add(component)
    app.setup(old_version=3)
    install.assert_has_calls([call(['python3'], skip_recommends=False)])


@patch('plinth.package.packages_installed')
@patch('plinth.package.uninstall')
@patch('plinth.package.install')
def test_packages_setup_with_conflicts(install, uninstall, packages_installed):
    """Test setting up packages with conflicts."""
    packages_installed.return_value = ['exim4-base']

    component = Packages('test-component', ['bash'], conflicts=['exim4-base'],
                         conflicts_action=Packages.ConflictsAction.REMOVE)
    component.setup(old_version=0)
    uninstall.assert_has_calls([call(['exim4-base'], purge=False)])
    install.assert_has_calls([call(['bash'], skip_recommends=False)])

    uninstall.reset_mock()
    install.reset_mock()
    component = Packages('test-component', ['bash'], conflicts=['exim4-base'])
    component.setup(old_version=0)
    uninstall.assert_not_called()
    install.assert_has_calls([call(['bash'], skip_recommends=False)])

    uninstall.reset_mock()
    install.reset_mock()
    component = Packages('test-component', ['bash'],
                         conflicts=['exim4-base', 'not-installed-package'],
                         conflicts_action=Packages.ConflictsAction.IGNORE)
    component.setup(old_version=0)
    uninstall.assert_not_called()
    install.assert_has_calls([call(['bash'], skip_recommends=False)])


@patch('plinth.package.refresh_package_lists')
@patch('plinth.package.uninstall')
def test_packages_uninstall(uninstall, _refresh_package_lists):
    """Test uninstalling packages component."""

    class TestApp(App):
        """Test app"""
        app_id = 'test-app'

    component = Packages('test-component', ['bash', 'dash'])
    app = TestApp()
    app.add(component)
    app.uninstall()
    uninstall.assert_has_calls([call(['bash', 'dash'], purge=True)])


@patch('plinth.package.refresh_package_lists')
@patch('plinth.package.uninstall')
@patch('apt.Cache')
def test_packages_uninstall_exclusion(cache, uninstall,
                                      _refresh_package_lists):
    """Test excluding packages from other installed apps when uninstalling."""

    def _get_mock_package(installed_version='1.0', dependencies=None,
                          recommends=None):
        mock_dependencies = []
        for or_dependencies in (dependencies or []):
            mock_or_dependency = Mock(or_dependencies=[])
            mock_dependencies.append(mock_or_dependency)
            for dependency in or_dependencies:
                mock = Mock()
                mock.name = dependency
                mock_or_dependency.or_dependencies.append(mock)

        mock_recommends = []
        for or_dependencies in (recommends or []):
            mock_or_dependency = Mock(or_dependencies=[])
            mock_recommends.append(mock_or_dependency)
            for dependency in or_dependencies:
                mock = Mock()
                mock.name = dependency
                mock_or_dependency.or_dependencies.append(mock)

        mock = Mock(
            version=installed_version or '1.0',
            is_installed=bool(installed_version),
            installed=Mock(dependencies=mock_dependencies,
                           recommends=mock_recommends))
        return mock

    package2 = _get_mock_package('4.0', [['dep1', 'dep2'], ['dep3'], ['dep4']],
                                 [['dep5']])
    cache.return_value = {
        'package11': _get_mock_package('2.0'),
        'package12': _get_mock_package(None),
        'package2': package2,
        'package3': _get_mock_package('5.0', ['unknown-dep1']),
        'dep1': _get_mock_package('6.0'),
        'dep2': _get_mock_package('6.1'),
        'dep3': _get_mock_package('6.2'),
        'dep4': _get_mock_package(None),
        'dep5': _get_mock_package('6.4'),
        'dep6': _get_mock_package('6.5'),
    }

    class TestApp1(App):
        """Test app."""
        app_id = 'test-app1'

        def __init__(self):
            super().__init__()
            component = Packages('test-component11', [
                'package11', 'package2', 'package3', 'dep1', 'dep2', 'dep3',
                'dep4', 'dep6'
            ])
            self.add(component)

            component = Packages('test-component12',
                                 ['package12', 'package2', 'package3'])
            self.add(component)

    class TestApp2(App):
        """Test app."""
        app_id = 'test-app2'

        def __init__(self):
            super().__init__()
            component = Packages('test-component2', ['package2'])
            self.add(component)

        def get_setup_state(self):
            return App.SetupState.UP_TO_DATE

    class TestApp3(App):
        """Test app."""
        app_id = 'test-app3'

        def __init__(self):
            super().__init__()
            component = Packages('test-component3', ['package3'])
            self.add(component)

        def get_setup_state(self):
            return App.SetupState.NEEDS_SETUP

    app1 = TestApp1()
    TestApp2()
    TestApp3()
    app1.uninstall()
    uninstall.assert_has_calls([
        call(['package12', 'package3'], purge=True),
        call(['package11', 'package3', 'dep6'], purge=True)
    ])


@patch('apt.Cache')
def test_diagnose(cache):
    """Test checking for latest version of the package."""
    cache.return_value = {
        'package2': Mock(candidate=Mock(version='2.0', is_installed=True)),
        'package3': Mock(candidate=Mock(version='3.0', is_installed=False)),
        'package7': Mock(candidate=Mock(version='4.0', is_installed=True)),
    }
    component = Packages('test-component', [
        'package1', 'package2', 'package3',
        Package('package4') | Package('package5'),
        Package('package6') | Package('package7')
    ])
    results = component.diagnose()
    assert results == [
        DiagnosticCheck(
            'package-available-package1',
            'Package {package_expression} is not available for install',
            Result.FAILED, {'package_expression': 'package1'},
            'test-component'),
        DiagnosticCheck(
            'package-latest-package2',
            'Package {package_name} is the latest version ({latest_version})',
            Result.PASSED, {
                'package_name': 'package2',
                'latest_version': '2.0'
            }, 'test-component'),
        DiagnosticCheck(
            'package-latest-package3',
            'Package {package_name} is the latest version ({latest_version})',
            Result.WARNING, {
                'package_name': 'package3',
                'latest_version': '3.0'
            }, 'test-component'),
        DiagnosticCheck(
            'package-available-package4 | package5',
            'Package {package_expression} is not available for install',
            Result.FAILED, {'package_expression': 'package4 | package5'},
            'test-component'),
        DiagnosticCheck(
            'package-latest-package7',
            'Package {package_name} is the latest version ({latest_version})',
            Result.PASSED, {
                'package_name': 'package7',
                'latest_version': '4.0'
            }, 'test-component'),
    ]


@patch('plinth.package.packages_installed')
def test_packages_find_conflicts(packages_installed_):
    """Test finding conflicts."""
    packages_installed_.return_value = []
    component = Packages('test-component', ['package3', 'package4'])
    assert component.find_conflicts() is None

    packages_installed_.return_value = []
    component = Packages('test-component', ['package3', 'package4'],
                         conflicts=['package5', 'package6'],
                         conflicts_action=Packages.ConflictsAction.IGNORE)
    assert component.find_conflicts() == []

    packages_installed_.return_value = ['package1', 'package2']
    component = Packages('test-component', ['package3', 'package4'],
                         conflicts=['package1', 'package2'],
                         conflicts_action=Packages.ConflictsAction.IGNORE)
    assert component.find_conflicts() == ['package1', 'package2']


@patch('plinth.package.refresh_package_lists')
@patch('apt.Cache')
@patch('pathlib.Path')
def test_packages_is_available(path_class, cache, refresh_package_lists):
    """Test checking for available packages."""
    package_cache = Mock()
    sources_dir = Mock()
    sources_dir.glob.return_value = []
    sources_list = Mock()

    def path_side_effect(path):
        return {
            '/var/cache/apt/pkgcache.bin': package_cache,
            '/etc/apt/sources.list.d': sources_dir,
            '/etc/apt/sources.list': sources_list
        }[path]

    def get_cache_packages(packages):
        return {
            package: Mock(name=package, candidate='1.0')
            for package in packages
        }

    path_class.side_effect = path_side_effect

    # Packages found in cache
    component = Packages('test-component', ['package1', 'package2'])
    cache.return_value = get_cache_packages(['package1', 'package2'])
    assert component.is_available()
    path_class.assert_not_called()
    refresh_package_lists.assert_not_called()

    # Packages not found, cache exists and is fresh
    cache.return_value = get_cache_packages(['package1'])
    package_cache.exists.return_value = True
    package_cache.stat.return_value.st_mtime = time.time()
    sources_list.stat.return_value.st_mtime = time.time() - 100
    assert not component.is_available()
    refresh_package_lists.assert_not_called()

    # Packages not found, cache does not exist
    cache.return_value = get_cache_packages(['package1'])
    package_cache.exists.return_value = False
    assert not component.is_available()
    refresh_package_lists.assert_called_once()

    # Packages not found, cache is stale because it older than 1 hour
    cache.return_value = get_cache_packages(['package1'])
    refresh_package_lists.reset_mock()
    package_cache.exists.return_value = True
    package_cache.stat.return_value.st_mtime = time.time() - 7200
    assert not component.is_available()
    refresh_package_lists.assert_called_once()

    # Packages not found, cache is stale because it older than sources.list
    cache.return_value = get_cache_packages(['package1'])
    refresh_package_lists.reset_mock()
    package_cache.exists.return_value = True
    package_cache.stat.return_value.st_mtime = time.time() - 100
    sources_list.stat.return_value.st_mtime = time.time()
    assert not component.is_available()
    refresh_package_lists.assert_called_once()

    # Packages not found, cache is stale, but packages found after refresh
    cache.side_effect = [
        get_cache_packages(['package1']),
        get_cache_packages(['package1', 'package2'])
    ]
    refresh_package_lists.reset_mock()
    assert component.is_available()


def test_packages_installed():
    """Test packages_installed()."""
    # list as input
    assert len(packages_installed([])) == 0
    assert len(packages_installed(['unknown-package'])) == 0
    assert len(packages_installed(['python3'])) == 1
    # tuples as input
    assert len(packages_installed(())) == 0
    assert len(packages_installed(('unknown-package', ))) == 0
    assert len(packages_installed(('python3', ))) == 1
