#!/usr/bin/env python3

# Copyright 2021 The Bazel Authors. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#    http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import itertools
import os
import unittest
import stat
import subprocess

from rules_python.python.runfiles import runfiles
from pkg.private import manifest


class PkgInstallTest(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        cls.runfiles = runfiles.Create()
        # Somewhat of an implementation detail, but it works.  I think.
        manifest_file = cls.runfiles.Rlocation("rules_pkg/tests/install/test_installer_install_script-install-manifest.json")

        with open(manifest_file, 'r') as fh:
            manifest_entries = manifest.read_entries_from(fh)
            cls.manifest_data = {}

            for entry in manifest_entries:
                cls.manifest_data[entry.dest] = entry
        cls.installdir = os.path.join(os.getenv("TEST_TMPDIR"), "installdir")
        env = {}
        env.update(cls.runfiles.EnvVars())
        subprocess.check_call([
            cls.runfiles.Rlocation("rules_pkg/tests/install/test_installer"),
            "--destdir", cls.installdir,
            "--verbose",
        ],
                              env=env)

    def entity_type_at_path(self, path):
        if os.path.islink(path):
            return manifest.ENTRY_IS_LINK
        elif os.path.isfile(path):
            return manifest.ENTRY_IS_FILE
        elif os.path.isdir(path):
            return manifest.ENTRY_IS_DIR
        else:
            # We can't infer what TreeArtifacts are by looking at them -- the
            # build system is not aware of their contents.
            raise ValueError("Entity {} is not a link, file, or directory")

    def assertEntryTypeMatches(self, entry, actual_path):
        actual_entry_type = self.entity_type_at_path(actual_path)
        self.assertEqual(actual_entry_type, entry.type,
                        "Entity {} should be a {}, but was actually {}".format(
                            entry.dest,
                            manifest.entry_type_to_string(entry.type),
                            manifest.entry_type_to_string(actual_entry_type),
                        ))

    def assertEntryModeMatches(self, entry, actual_path):
        # TODO: permissions in windows are... tricky.  Don't bother
        # testing for them if we're in it for the time being
        if os.name == 'nt':
            return

        actual_mode = stat.S_IMODE(os.stat(actual_path).st_mode)
        expected_mode = int(entry.mode, 8)
        self.assertEqual(actual_mode, expected_mode,
                         "Entry {} has mode {:04o}, expected {:04o}".format(
                            entry.dest, actual_mode, expected_mode,
                        ))

    def test_manifest_matches(self):
        unowned_dirs = set()
        owned_dirs = set()

        # Figure out what directories we are supposed to own, and which ones we
        # aren't.
        #
        # Unowned directories are created implicitly by requesting other
        # elements be created or installed.
        #
        # Owned directories are created explicitly with the pkg_mkdirs rule.
        for dest, data in self.manifest_data.items():
            if data.type == manifest.ENTRY_IS_DIR:
                owned_dirs.add(dest)

            # TODO(nacl): The initial stage of the accumulation returns an empty string,
            # which end up in the set representing the root of the manifest.
            # This may not be the best thing.
            unowned_dirs.update([p for p in itertools.accumulate(os.path.dirname(dest).split('/'),
                                             func=lambda accum, new: accum + '/' + new)])

        # In the above loop, unowned_dirs contains all possible directories that
        # are in the manifest.  Prune them here.
        unowned_dirs -= owned_dirs

        # TODO: check for ownership (user, group)
        found_entries = {dest: False for dest in self.manifest_data.keys()}
        for root, dirs, files in os.walk(self.installdir):
            rel_root_path = os.path.relpath(root, self.installdir)

            # The rest of this uses string comparison.  To reduce potential
            # confusion, ensure that the "." doesn't show up elsewhere.
            #
            # TODO(nacl) consider using pathlib here, which will reduce the
            # need for path cleverness.
            if rel_root_path == '.':
                rel_root_path = ''

            # TODO(nacl): check for treeartifacts here.  If so, prune `dirs`,
            # and set the rest aside for future processing.

            # Directory ownership tests
            if len(files) == 0 and len(dirs) == 0:
                # Empty directories must be explicitly requested by something
                if rel_root_path not in self.manifest_data:
                    self.fail("Directory {} not in manifest".format(rel_root_path))

                entry = self.manifest_data[rel_root_path]
                self.assertEntryTypeMatches(entry, root)
                self.assertEntryModeMatches(entry, root)

                found_entries[rel_root_path] = True
            else:
                # There's something in here.  Depending on how it was set up, it
                # could either be owned or unowned.
                if rel_root_path in self.manifest_data:
                    entry = self.manifest_data[rel_root_path]
                    self.assertEntryTypeMatches(entry, root)
                    self.assertEntryModeMatches(entry, root)

                    found_entries[rel_root_path] = True
                else:
                    # If any unowned directories are here, they must be the
                    # prefix of some entity in the manifest.
                    self.assertIn(rel_root_path, unowned_dirs)

            for f in files:
                # The path on the filesystem in which the file actually exists.

                # TODO(#382): This part of the test assumes that the path
                # separator is '/', which is not the case in Windows.  However,
                # paths emitted in the JSON manifests may also be using
                # '/'-separated paths.
                #
                # Confirm the degree to which this is a problem, and remedy as
                # needed.  It maybe worth setting the keys in the manifest_data
                # dictionary to pathlib.Path or otherwise converting them to
                # native paths.
                fpath = os.path.normpath("/".join([root, f]))
                # The path inside the manifest (relative to the install
                # destdir).
                rel_fpath = os.path.normpath("/".join([rel_root_path, f]))
                if rel_fpath not in self.manifest_data:
                    self.fail("Entity {} not in manifest".format(rel_fpath))

                entry = self.manifest_data[rel_fpath]
                self.assertEntryTypeMatches(entry, fpath)
                self.assertEntryModeMatches(entry, fpath)

                found_entries[rel_fpath] = True

        # TODO(nacl): check for TreeArtifacts

        num_missing = 0
        for dest, present in found_entries.items():
            if present is False:
                print("Entity {} is missing from the tree".format(dest))
                num_missing += 1
        self.assertEqual(num_missing, 0)


if __name__ == "__main__":
    unittest.main()
