import copy
import datetime
import itertools
import json
import logging
import os
import stat

import mock
import pytest
import yaml

from uaclient import apt, exceptions, messages, util
from uaclient.config import (
    PRIVATE_SUBDIR,
    UA_CONFIGURABLE_KEYS,
    VALID_UA_CONFIG_KEYS,
    DataPath,
    UAConfig,
    depth_first_merge_overlay_dict,
    get_config_path,
    parse_config,
)
from uaclient.defaults import DEFAULT_CONFIG_FILE
from uaclient.entitlements import valid_services
from uaclient.entitlements.entitlement_status import ApplicationStatus

KNOWN_DATA_PATHS = (
    ("machine-access-cis", "machine-access-cis.json"),
    ("machine-token", "machine-token.json"),
)
M_PATH = "uaclient.entitlements."

ALL_RESOURCES_AVAILABLE = [
    {"name": name, "available": True}
    for name in valid_services(cfg=UAConfig(), allow_beta=True)
]
ALL_RESOURCES_ENTITLED = [
    {"type": name, "entitled": True}
    for name in valid_services(cfg=UAConfig(), allow_beta=True)
]
NO_RESOURCES_ENTITLED = [
    {"type": name, "entitled": False}
    for name in valid_services(cfg=UAConfig(), allow_beta=True)
]
RESP_ONLY_FIPS_RESOURCE_AVAILABLE = [
    {"name": name, "available": name == "fips"}
    for name in valid_services(cfg=UAConfig(), allow_beta=True)
]


class TestNotices:
    @pytest.mark.parametrize(
        "notices,expected",
        (
            ([], []),
            ([["a", "a1"]], [["a", "a1"]]),
            ([["a", "a1"], ["a", "a1"]], [["a", "a1"]]),
        ),
    )
    def test_add_notice_avoids_duplicates(self, notices, expected, tmpdir):
        cfg = UAConfig({"data_dir": tmpdir.strpath})
        assert None is cfg.read_cache("notices")
        for notice in notices:
            cfg.add_notice(*notice)
        if notices:
            assert expected == cfg.read_cache("notices")
        else:
            assert None is cfg.read_cache("notices")

    @pytest.mark.parametrize(
        "notices,removes,expected",
        (
            ([], [["a", "a1"]], None),
            ([["a", "a1"]], [["a", "a1"]], None),
            ([["a", "a1"], ["a", "a2"]], [["a", "a1"]], [["a", "a2"]]),
            (
                [["a", "a1"], ["a", "a2"], ["b", "b2"]],
                [["a", ".*"]],
                [["b", "b2"]],
            ),
        ),
    )
    def test_remove_notice_removes_matching(
        self, notices, removes, expected, tmpdir
    ):
        cfg = UAConfig({"data_dir": tmpdir.strpath})
        for notice in notices:
            cfg.add_notice(*notice)
        for label, descr in removes:
            cfg.remove_notice(label, descr)
        assert expected == cfg.read_cache("notices")


class TestEntitlements:
    def test_entitlements_property_keyed_by_entitlement_name(self, tmpdir):
        """Return machine_token resourceEntitlements, keyed by name."""
        cfg = UAConfig({"data_dir": tmpdir.strpath})
        token = {
            "availableResources": ALL_RESOURCES_AVAILABLE,
            "machineTokenInfo": {
                "contractInfo": {
                    "resourceEntitlements": [
                        {"type": "entitlement1", "entitled": True},
                        {"type": "entitlement2", "entitled": True},
                    ]
                }
            },
        }
        cfg.write_cache("machine-token", token)
        expected = {
            "entitlement1": {
                "entitlement": {"entitled": True, "type": "entitlement1"}
            },
            "entitlement2": {
                "entitlement": {"entitled": True, "type": "entitlement2"}
            },
        }
        assert expected == cfg.entitlements

    def test_entitlements_uses_resource_token_from_machine_token(self, tmpdir):
        """Include entitlement-specicific resourceTokens from machine_token"""
        cfg = UAConfig({"data_dir": tmpdir.strpath})
        token = {
            "availableResources": ALL_RESOURCES_AVAILABLE,
            "machineTokenInfo": {
                "contractInfo": {
                    "resourceEntitlements": [
                        {"type": "entitlement1", "entitled": True},
                        {"type": "entitlement2", "entitled": True},
                    ]
                }
            },
            "resourceTokens": [
                {"type": "entitlement1", "token": "ent1-token"},
                {"type": "entitlement2", "token": "ent2-token"},
            ],
        }
        cfg.write_cache("machine-token", token)
        expected = {
            "entitlement1": {
                "entitlement": {"entitled": True, "type": "entitlement1"},
                "resourceToken": "ent1-token",
            },
            "entitlement2": {
                "entitlement": {"entitled": True, "type": "entitlement2"},
                "resourceToken": "ent2-token",
            },
        }
        assert expected == cfg.entitlements


class TestAccounts:
    def test_accounts_returns_empty_list_when_no_cached_account_value(
        self, tmpdir
    ):
        """Config.accounts property returns an empty list when no cache."""
        cfg = UAConfig({"data_dir": tmpdir.strpath})

        assert [] == cfg.accounts

    def test_accounts_extracts_accounts_key_from_machine_token_cache(
        self, tmpdir
    ):
        """Use machine_token cached accountInfo when no accounts cache."""
        cfg = UAConfig({"data_dir": tmpdir.strpath})
        accountInfo = {"id": "1", "name": "accountname"}

        cfg.write_cache(
            "machine-token",
            {
                "availableResources": ALL_RESOURCES_AVAILABLE,
                "machineTokenInfo": {"accountInfo": accountInfo},
            },
        )

        assert [accountInfo] == cfg.accounts


class TestDataPath:
    def test_data_path_returns_data_dir_path_without_key(self):
        """The data_path method returns the data_dir when key is absent."""
        cfg = UAConfig({"data_dir": "/my/dir"})
        assert "/my/dir/{}".format(PRIVATE_SUBDIR) == cfg.data_path()

    @pytest.mark.parametrize("key,path_basename", KNOWN_DATA_PATHS)
    def test_data_path_returns_file_path_with_defined_data_paths(
        self, key, path_basename
    ):
        """When key is defined in Config.data_paths return data_path value."""
        cfg = UAConfig({"data_dir": "/my/dir"})
        private_path = "/my/dir/{}/{}".format(PRIVATE_SUBDIR, path_basename)
        assert private_path == cfg.data_path(key=key)

    @pytest.mark.parametrize(
        "key,path_basename", (("notHere", "notHere"), ("anything", "anything"))
    )
    def test_data_path_returns_file_path_with_undefined_data_paths(
        self, key, path_basename
    ):
        """When key is not in Config.data_paths the key is used to data_dir"""
        cfg = UAConfig({"data_dir": "/my/d"})
        assert "/my/d/{}/{}".format(PRIVATE_SUBDIR, key) == cfg.data_path(
            key=key
        )

    def test_data_path_returns_public_path_for_public_datapath(self):
        cfg = UAConfig({"data_dir": "/my/d"})
        cfg.data_paths["test_path"] = DataPath("test_path", False, False)
        assert "/my/d/test_path" == cfg.data_path("test_path")


CFG_BASE_CONTENT = """\
# Ubuntu-Advantage client config file.
# If you modify this file, run "ua refresh config" to ensure changes are
# picked up by Ubuntu-Advantage client.

contract_url: https://contracts.canonical.com
daemon_log_file: /var/log/ubuntu-advantage-daemon.log
data_dir: /var/lib/ubuntu-advantage
log_file: /var/log/ubuntu-advantage.log
log_level: debug
security_url: https://ubuntu.com/security
timer_log_file: /var/log/ubuntu-advantage-timer.log
"""

CFG_FEATURES_CONTENT = """\
# Ubuntu-Advantage client config file.
# If you modify this file, run "ua refresh config" to ensure changes are
# picked up by Ubuntu-Advantage client.

contract_url: https://contracts.canonical.com
daemon_log_file: /var/log/ubuntu-advantage-daemon.log
data_dir: /var/lib/ubuntu-advantage
features:
  extra_security_params:
    hide: true
  new: 2
  show_beta: true
log_file: /var/log/ubuntu-advantage.log
log_level: debug
security_url: https://ubuntu.com/security
settings_overrides:
  c: 1
  d: 2
timer_log_file: /var/log/ubuntu-advantage-timer.log
"""

UA_CFG_DICT = {
    "ua_config": {
        "apt_http_proxy": None,
        "apt_https_proxy": None,
        "global_apt_http_proxy": None,
        "global_apt_https_proxy": None,
        "ua_apt_http_proxy": None,
        "ua_apt_https_proxy": None,
        "http_proxy": None,
        "https_proxy": None,
        "update_messaging_timer": None,
        "update_status_timer": None,
        "metering_timer": None,
    }
}


class TestUAConfigKeys:
    @pytest.mark.parametrize("attr_name", UA_CONFIGURABLE_KEYS)
    @mock.patch("uaclient.config.UAConfig.write_cfg")
    def test_ua_configurable_keys_set_ua_config_dict(
        self, write_cfg, attr_name, tmpdir
    ):
        """Getters and settings are available fo UA_CONFIGURABLE_KEYS."""
        cfg = UAConfig({"data_dir": tmpdir.strpath})
        assert None is getattr(cfg, attr_name, None)
        cfg_non_members = ("apt_http_proxy", "apt_https_proxy")
        if attr_name not in cfg_non_members:
            setattr(cfg, attr_name, attr_name + "value")
            assert attr_name + "value" == getattr(cfg, attr_name)
            assert attr_name + "value" == cfg.cfg["ua_config"][attr_name]


class TestWriteCfg:
    @pytest.mark.parametrize(
        "orig_content, expected",
        (
            (
                CFG_BASE_CONTENT,
                CFG_BASE_CONTENT
                + yaml.dump(UA_CFG_DICT, default_flow_style=False),
            ),
            (  # Yaml output is sorted alphabetically by key
                "\n".join(sorted(CFG_BASE_CONTENT.splitlines(), reverse=True)),
                CFG_BASE_CONTENT
                + yaml.dump(UA_CFG_DICT, default_flow_style=False),
            ),
            # Any custom comments or unrecognized config keys are dropped
            (
                "unknown-keys-not-preserved: true\n# user comments are lost"
                + CFG_BASE_CONTENT,
                CFG_BASE_CONTENT
                + yaml.dump(UA_CFG_DICT, default_flow_style=False),
            ),
            # All features/settings_overrides ordered after ua_config
            (
                CFG_BASE_CONTENT
                + "features:\n new: 2\n extra_security_params:\n  hide: true\n"
                " show_beta: true\nsettings_overrides:\n d: 2\n c: 1\n",
                CFG_FEATURES_CONTENT
                + yaml.dump(UA_CFG_DICT, default_flow_style=False),
            ),
            (
                "settings_overrides:\n c: 1\n d: 2\nfeatures:\n"
                " show_beta: true\n new: 2\n extra_security_params:\n"
                "  hide: true\nsettings_overrides:\n d: 2\n c: 1\n"
                + CFG_BASE_CONTENT,
                CFG_FEATURES_CONTENT
                + yaml.dump(UA_CFG_DICT, default_flow_style=False),
            ),
        ),
    )
    def test_write_cfg_reads_cfg_andpersists_structured_content_to_config_path(
        self, orig_content, expected, tmpdir
    ):
        """write_cfg writes structured, ordered config YAML to config_path."""
        orig_conf = tmpdir.join("orig_uaclient.conf")
        orig_conf.write(orig_content)
        cfg = UAConfig(cfg=parse_config(orig_conf.strpath)[0])
        out_conf = tmpdir.join("uaclient.conf")
        cfg.write_cfg(out_conf.strpath)
        assert expected == out_conf.read()


class TestWriteCache:
    @pytest.mark.parametrize(
        "key,clears_cache",
        (
            ("machine-token", True),
            ("machine-access-cis", True),
            ("lock", False),
        ),
    )
    def test_write_cache_clears_machine_token_and_entitlements_instance_vars(
        self, key, clears_cache, tmpdir
    ):
        """Clear _machine_token and _entitlements when machine keys updated."""
        cfg = UAConfig({"data_dir": tmpdir.strpath})
        # setup cached values
        cfg._machine_token = mock.sentinel.token
        cfg._entitlements = mock.sentinel.entitlements
        cfg.write_cache(key, "something")
        if clears_cache:
            assert None is cfg._entitlements
            assert None is cfg._machine_token
        else:
            assert mock.sentinel.token is cfg._machine_token
            assert mock.sentinel.entitlements is cfg._entitlements

    @pytest.mark.parametrize(
        "key,content",
        (("unknownkey", "content1"), ("another-one", "content2")),
    )
    def test_write_cache_write_key_name_in_data_dir_when_data_path_absent(
        self, tmpdir, key, content
    ):
        """When key is not in data_paths, write content to data_dir/key."""
        cfg = UAConfig({"data_dir": tmpdir.strpath})
        expected_path = tmpdir.join(PRIVATE_SUBDIR, key)

        assert not expected_path.check(), "Found unexpected file {}".format(
            expected_path
        )
        assert None is cfg.write_cache(key, content)
        assert expected_path.check(), "Missing expected file {}".format(
            expected_path
        )
        assert content == cfg.read_cache(key)

    def test_write_cache_creates_secure_private_dir(self, tmpdir):
        """private_dir is created with permission 0o700."""
        cfg = UAConfig({"data_dir": tmpdir.strpath})
        # unknown keys are written to the private dir
        expected_dir = tmpdir.join(PRIVATE_SUBDIR)
        assert None is cfg.write_cache("somekey", "somevalue")
        assert True is os.path.isdir(
            expected_dir.strpath
        ), "Missing expected directory {}".format(expected_dir)
        assert 0o700 == stat.S_IMODE(os.lstat(expected_dir.strpath).st_mode)

    def test_write_cache_creates_dir_when_data_dir_does_not_exist(
        self, tmpdir
    ):
        """When data_dir doesn't exist, create it."""
        tmp_subdir = tmpdir.join("does/not/exist")
        cfg = UAConfig({"data_dir": tmp_subdir.strpath})

        assert False is os.path.isdir(
            tmp_subdir.strpath
        ), "Found unexpected directory {}".format(tmp_subdir)
        assert None is cfg.write_cache("somekey", "someval")
        assert True is os.path.isdir(
            tmp_subdir.strpath
        ), "Missing expected directory {}".format(tmp_subdir)
        assert "someval" == cfg.read_cache("somekey")

    @pytest.mark.parametrize(
        "key,value", (("dictkey", {"1": "v1"}), ("listkey", [1, 2, 3]))
    )
    def test_write_cache_writes_json_string_when_content_not_a_string(
        self, tmpdir, key, value
    ):
        """When content is not a string, write a json string."""
        cfg = UAConfig({"data_dir": tmpdir.strpath})

        expected_json_content = json.dumps(value)
        assert None is cfg.write_cache(key, value)
        with open(tmpdir.join(PRIVATE_SUBDIR, key).strpath, "r") as stream:
            assert expected_json_content == stream.read()
        assert value == cfg.read_cache(key)

    @pytest.mark.parametrize(
        "datapath,mode",
        (
            (DataPath("path", False, False), 0o644),
            (DataPath("path", True, False), 0o600),
        ),
    )
    def test_permissions(self, tmpdir, datapath, mode):
        cfg = UAConfig({"data_dir": tmpdir.strpath})
        cfg.data_paths = {"path": datapath}
        cfg.write_cache("path", "")
        assert mode == stat.S_IMODE(os.lstat(cfg.data_path("path")).st_mode)

    def test_write_datetime(self, tmpdir):
        cfg = UAConfig({"data_dir": tmpdir.strpath})
        key = "test_key"
        dt = datetime.datetime.now()
        cfg.write_cache(key, dt)
        with open(cfg.data_path(key)) as f:
            assert dt.isoformat() == f.read().strip('"')


class TestReadCache:
    @pytest.mark.parametrize("key,path_basename", KNOWN_DATA_PATHS)
    def test_read_cache_returns_none_when_data_path_absent(
        self, tmpdir, key, path_basename
    ):
        """Return None when the specified key data_path is not cached."""
        cfg = UAConfig({"data_dir": tmpdir.strpath})
        assert None is cfg.read_cache(key)
        assert not tmpdir.join(path_basename).check()

    @pytest.mark.parametrize("key,path_basename", KNOWN_DATA_PATHS)
    def test_read_cache_returns_content_when_data_path_present(
        self, tmpdir, key, path_basename
    ):
        cfg = UAConfig({"data_dir": tmpdir.strpath})
        os.makedirs(tmpdir.join(PRIVATE_SUBDIR).strpath)
        data_path = tmpdir.join(PRIVATE_SUBDIR, path_basename)
        with open(data_path.strpath, "w") as f:
            f.write("content{}".format(key))

        assert "content{}".format(key) == cfg.read_cache(key)

    @pytest.mark.parametrize("key,path_basename", KNOWN_DATA_PATHS)
    def test_read_cache_returns_stuctured_content_when_json_data_path_present(
        self, tmpdir, key, path_basename
    ):
        cfg = UAConfig({"data_dir": tmpdir.strpath})
        os.makedirs(tmpdir.join(PRIVATE_SUBDIR).strpath)
        data_path = tmpdir.join(PRIVATE_SUBDIR, path_basename)
        expected = {key: "content{}".format(key)}
        with open(data_path.strpath, "w") as f:
            f.write(json.dumps(expected))

        assert expected == cfg.read_cache(key)

    def test_datetimes_are_unserialised(self, tmpdir):
        cfg = UAConfig({"data_dir": tmpdir.strpath})
        os.makedirs(tmpdir.join(PRIVATE_SUBDIR).strpath)
        data_path = tmpdir.join(PRIVATE_SUBDIR, "dt_test")
        with open(data_path.strpath, "w") as f:
            f.write('{"dt": "2019-07-25T14:35:51"}')

        actual = cfg.read_cache("dt_test")
        assert {
            "dt": datetime.datetime(
                2019, 7, 25, 14, 35, 51, tzinfo=datetime.timezone.utc
            )
        } == actual


class TestDeleteCacheKey:
    @pytest.mark.parametrize("property_name", ("status-cache", "lock"))
    def test_delete_cache_key_removes_public_or_private_data_path_files(
        self, property_name, tmpdir
    ):
        cfg = UAConfig({"data_dir": tmpdir.strpath})
        cfg.write_cache(property_name, "himom")
        assert True is os.path.exists(cfg.data_path(property_name))
        cfg.delete_cache_key(property_name)
        assert False is os.path.exists(cfg.data_path(property_name))
        assert None is cfg.read_cache(property_name)

    @pytest.mark.parametrize(
        "property_name,clears_cache",
        (
            ("machine-token", True),
            ("machine-access-cis", True),
            ("machine", False),
        ),
    )
    def test_delete_cache_key_clears_machine_token_and_entitlements(
        self, property_name, clears_cache, tmpdir
    ):
        cfg = UAConfig({"data_dir": tmpdir.strpath})
        token = {
            "availableResources": ALL_RESOURCES_AVAILABLE,
            "machineTokenInfo": {
                "contractInfo": {
                    "resourceEntitlements": [
                        {"type": "entitlement1", "entitled": True},
                        {"type": "entitlement2", "entitled": True},
                    ]
                }
            },
        }
        cfg.write_cache("machine-token", token)
        cfg.entitlements  # sets config _entitlements and _machine_token cache
        assert cfg._entitlements is not None
        assert cfg._machine_token is not None
        cfg.delete_cache_key(property_name)
        if clears_cache:
            # internal cache is cleared
            assert cfg._entitlements is None
            assert cfg._machine_token is None

        # Reconstitutes _entitlements and _machine_token caches
        entitlements = cfg.entitlements
        if property_name == "machine-token":
            # We performed delete_cache_key("machine-token") above, so None now
            assert None is cfg._entitlements
            assert None is cfg.machine_token
        else:
            # re-constitute from cache
            assert entitlements is cfg._entitlements
            assert cfg._machine_token is cfg.machine_token


class TestDeleteCache:
    @pytest.mark.parametrize(
        "property_name,data_path_name,expected_null_value",
        (("machine_token", "machine-token", None),),
    )
    def test_delete_cache_properly_clears_all_caches_simple(
        self, tmpdir, property_name, data_path_name, expected_null_value
    ):
        """
        Ensure that delete_cache clears the cache for simple attributes

        (Simple in this context means those that are simply read from the
        filesystem and returned.)
        """
        property_value = "our-value"
        cfg = UAConfig({"data_dir": tmpdir.strpath})

        data_path = cfg.data_path(data_path_name)
        os.makedirs(os.path.dirname(data_path))
        with open(data_path, "w") as f:
            f.write(property_value)

        before_prop_value = getattr(cfg, property_name)
        assert before_prop_value == property_value

        cfg.delete_cache()

        after_prop_value = getattr(cfg, property_name)
        assert expected_null_value == after_prop_value

    def test_delete_cache_unsets_entitlements(self, tmpdir):
        """The delete_cache unsets any cached entitlements content."""
        cfg = UAConfig({"data_dir": tmpdir.strpath})
        token = {
            "availableResources": ALL_RESOURCES_AVAILABLE,
            "machineTokenInfo": {
                "contractInfo": {
                    "resourceEntitlements": [
                        {"type": "entitlement1", "entitled": True}
                    ]
                }
            },
        }
        cfg.write_cache("machine-token", token)
        previous_entitlements = {
            "entitlement1": {
                "entitlement": {"type": "entitlement1", "entitled": True}
            }
        }
        assert previous_entitlements == cfg.entitlements
        cfg.delete_cache()
        assert {} == cfg.entitlements

    def test_delete_cache_removes_all_data_path_files_with_delete_permanent(
        self, tmpdir
    ):
        """Any cached files defined in cfg.data_paths will be removed."""
        cfg = UAConfig({"data_dir": tmpdir.strpath})
        # Create half of the cached files, but not all
        odd_keys = list(sorted(cfg.data_paths.keys()))[::2]
        for odd_key in odd_keys:
            if odd_key == "notices":
                # notices key expects specific list or lists format
                value = [[odd_key, odd_key]]
            else:
                value = odd_key
            cfg.write_cache(odd_key, value)

        present_files = list(
            itertools.chain(
                *[walk_entry[2] for walk_entry in os.walk(tmpdir.strpath)]
            )
        )
        assert len(odd_keys) == len(present_files)
        cfg.delete_cache(delete_permanent=True)
        dirty_files = list(
            itertools.chain(
                *[walk_entry[2] for walk_entry in os.walk(tmpdir.strpath)]
            )
        )
        assert 0 == len(dirty_files), "{} files not deleted".format(
            ", ".join(dirty_files)
        )

    def test_delete_cache_ignores_permanent_data_path_files(self, tmpdir):
        """Any cached files defined in cfg.data_paths will be removed."""
        cfg = UAConfig({"data_dir": tmpdir.strpath})
        for key in cfg.data_paths.keys():
            if key == "notices":
                # notices key expects specific list or lists format
                value = [[key, key]]
            else:
                value = key
            cfg.write_cache(key, value)

        num_permanent_files = len(
            [v for v in cfg.data_paths.values() if v.permanent]
        )
        present_files = list(
            itertools.chain(
                *[walk_entry[2] for walk_entry in os.walk(tmpdir.strpath)]
            )
        )
        assert len(cfg.data_paths.keys()) == len(present_files)
        cfg.delete_cache()
        dirty_files = list(
            itertools.chain(
                *[walk_entry[2] for walk_entry in os.walk(tmpdir.strpath)]
            )
        )
        assert num_permanent_files == len(
            dirty_files
        ), "{} files not deleted".format(", ".join(dirty_files))

    def test_delete_cache_ignores_files_not_defined_in_data_paths(
        self, tmpdir
    ):
        """Any files in data_dir undefined in cfg.data_paths will remain."""
        cfg = UAConfig({"data_dir": tmpdir.strpath})
        t_file = tmpdir.join(PRIVATE_SUBDIR, "otherfile")
        os.makedirs(os.path.dirname(t_file.strpath))
        with open(t_file.strpath, "w") as f:
            f.write("content")
        assert [os.path.basename(t_file.strpath)] == os.listdir(
            tmpdir.join(PRIVATE_SUBDIR).strpath
        )
        cfg.delete_cache()
        assert [os.path.basename(t_file.strpath)] == os.listdir(
            tmpdir.join(PRIVATE_SUBDIR).strpath
        )


class TestProcessConfig:
    @pytest.mark.parametrize(
        "http_proxy, https_proxy, snap_is_installed, snap_http_val, "
        "snap_https_val, livepatch_enabled, livepatch_http_val, "
        "livepatch_https_val, snap_livepatch_msg, "
        "global_https, global_http, ua_https, ua_http, apt_https, apt_http",
        [
            (
                "http",
                "https",
                False,
                None,
                None,
                False,
                None,
                None,
                "",
                None,
                None,
                None,
                None,
                None,
                None,
            ),
            (
                "http",
                "https",
                True,
                None,
                None,
                False,
                None,
                None,
                "",
                None,
                None,
                None,
                None,
                "apt_https",
                "apt_http",
            ),
            (
                "http",
                "https",
                False,
                None,
                None,
                True,
                None,
                None,
                "",
                "global_https",
                "global_http",
                None,
                None,
                None,
                None,
            ),
            (
                "http",
                "https",
                True,
                None,
                None,
                True,
                None,
                None,
                "",
                None,
                None,
                "ua_https",
                "ua_http",
                None,
                None,
            ),
            (
                None,
                None,
                True,
                None,
                None,
                True,
                None,
                None,
                "",
                "global_https",
                "global_http",
                None,
                None,
                "apt_https",
                "apt_http",
            ),
            (
                None,
                None,
                True,
                "one",
                None,
                True,
                None,
                None,
                "snap",
                "global_https",
                "global_http",
                "ua_https",
                "ua_http",
                "apt_https",
                "apt_http",
            ),
            (
                None,
                None,
                True,
                "one",
                "two",
                True,
                None,
                None,
                "snap",
                None,
                "global_http",
                None,
                None,
                None,
                "apt_http",
            ),
            (
                None,
                None,
                True,
                "one",
                "two",
                True,
                "three",
                None,
                "snap, livepatch",
                "global_htttps",
                None,
                "ua_https",
                None,
                "apt_https",
                None,
            ),
            (
                None,
                None,
                True,
                "one",
                "two",
                True,
                "three",
                "four",
                "snap, livepatch",
                "global_https",
                None,
                None,
                "ua_http",
                None,
                None,
            ),
            (
                None,
                None,
                False,
                None,
                None,
                True,
                "three",
                "four",
                "livepatch",
                None,
                None,
                None,
                None,
                None,
                None,
            ),
        ],
    )
    @mock.patch("uaclient.util.validate_proxy")
    @mock.patch("uaclient.entitlements.livepatch.get_config_option_value")
    @mock.patch("uaclient.entitlements.livepatch.configure_livepatch_proxy")
    @mock.patch(
        "uaclient.entitlements.livepatch.LivepatchEntitlement.application_status"  # noqa: E501
    )
    @mock.patch("uaclient.snap.get_config_option_value")
    @mock.patch("uaclient.snap.configure_snap_proxy")
    @mock.patch("uaclient.snap.is_installed")
    @mock.patch("uaclient.apt.setup_apt_proxy")
    @mock.patch("uaclient.config.UAConfig.write_cfg")
    def test_process_config(
        self,
        m_write_cfg,
        m_apt_configure_proxy,
        m_snap_is_installed,
        m_snap_configure_proxy,
        m_snap_get_config_option,
        m_livepatch_status,
        m_livepatch_configure_proxy,
        m_livepatch_get_config_option,
        m_validate_proxy,
        http_proxy,
        https_proxy,
        snap_is_installed,
        snap_http_val,
        snap_https_val,
        livepatch_enabled,
        livepatch_http_val,
        livepatch_https_val,
        snap_livepatch_msg,
        global_https,
        global_http,
        ua_https,
        ua_http,
        apt_https,
        apt_http,
        capsys,
        tmpdir,
    ):
        m_snap_is_installed.return_value = snap_is_installed
        m_snap_get_config_option.side_effect = [snap_http_val, snap_https_val]
        m_livepatch_status.return_value = (
            (ApplicationStatus.ENABLED, None)
            if livepatch_enabled
            else (None, None)
        )
        m_livepatch_get_config_option.side_effect = [
            livepatch_http_val,
            livepatch_https_val,
        ]
        cfg = UAConfig(
            {
                "ua_config": {
                    "apt_http_proxy": apt_http,
                    "apt_https_proxy": apt_https,
                    "global_apt_https_proxy": global_https,
                    "global_apt_http_proxy": global_http,
                    "ua_apt_https_proxy": ua_https,
                    "ua_apt_http_proxy": ua_http,
                    "http_proxy": http_proxy,
                    "https_proxy": https_proxy,
                    "update_messaging_timer": 21600,
                    "update_status_timer": 43200,
                    "metering_timer": 0,
                },
                "data_dir": tmpdir.strpath,
            }
        )

        if global_https is None and apt_https is not None:
            global_https = apt_https
        if global_http is None and apt_http is not None:
            global_http = apt_http

        exc = False
        if global_https or global_http:
            if ua_https or ua_http:
                exc = True
                with pytest.raises(
                    exceptions.UserFacingError,
                    match=messages.ERROR_PROXY_CONFIGURATION,
                ):
                    cfg.process_config()
        if exc is False:
            cfg.process_config()

            assert [
                mock.call(
                    "http", global_http, util.PROXY_VALIDATION_APT_HTTP_URL
                ),
                mock.call(
                    "https", global_https, util.PROXY_VALIDATION_APT_HTTPS_URL
                ),
                mock.call("http", ua_http, util.PROXY_VALIDATION_APT_HTTP_URL),
                mock.call(
                    "https", ua_https, util.PROXY_VALIDATION_APT_HTTPS_URL
                ),
                mock.call(
                    "http", http_proxy, util.PROXY_VALIDATION_SNAP_HTTP_URL
                ),
                mock.call(
                    "https", https_proxy, util.PROXY_VALIDATION_SNAP_HTTPS_URL
                ),
            ] == m_validate_proxy.call_args_list

            if global_http or global_https:
                assert [
                    mock.call(
                        global_http, global_https, apt.AptProxyScope.GLOBAL
                    )
                ] == m_apt_configure_proxy.call_args_list
            elif ua_http or ua_https:
                assert [
                    mock.call(ua_http, ua_https, apt.AptProxyScope.UACLIENT)
                ] == m_apt_configure_proxy.call_args_list
            else:
                assert [] == m_apt_configure_proxy.call_args_list

            if snap_is_installed:
                assert [
                    mock.call(http_proxy, https_proxy)
                ] == m_snap_configure_proxy.call_args_list

            if livepatch_enabled:
                assert [
                    mock.call(http_proxy, https_proxy)
                ] == m_livepatch_configure_proxy.call_args_list

            expected_out = ""
            if snap_livepatch_msg:
                expected_out = messages.PROXY_DETECTED_BUT_NOT_CONFIGURED.format(  # noqa: E501
                    services=snap_livepatch_msg
                )

            out, err = capsys.readouterr()
            expected_out = """
                Using deprecated "{apt}" config field.
                Please migrate to using "{global_}"
            """
            if apt_http and not global_http:
                assert (
                    expected_out.format(
                        apt=apt_http, global_=global_http
                    ).strip()
                    == out.strip()
                )
            if apt_https and not global_https:
                assert (
                    expected_out.format(
                        apt=apt_https, global_=global_https
                    ).strip()
                    == out.strip()
                )
            assert "" == err

    def test_process_config_errors_for_wrong_timers(self):
        cfg = UAConfig(
            {
                "ua_config": {
                    "update_messaging_timer": "wrong",
                    "update_status_timer": 43200,
                }
            }
        )

        with pytest.raises(
            exceptions.UserFacingError,
            match="Value for the update_messaging_timer interval must be "
            "a positive integer. Default value will be used.",
        ):
            cfg.process_config()


class TestParseConfig:
    @mock.patch("uaclient.config.os.path.exists", return_value=False)
    @mock.patch("uaclient.contract.get_available_resources")
    def test_parse_config_uses_defaults_when_no_config_present(
        self, _m_resources, m_exists
    ):
        cwd = os.getcwd()
        with mock.patch.dict("uaclient.config.os.environ", values={}):
            config, _ = parse_config()
        expected_calls = [
            mock.call("{}/uaclient.conf".format(cwd)),
            mock.call("/etc/ubuntu-advantage/uaclient.conf"),
        ]
        assert expected_calls == m_exists.call_args_list
        expected_default_config = {
            "contract_url": "https://contracts.canonical.com",
            "security_url": "https://ubuntu.com/security",
            "data_dir": "/var/lib/ubuntu-advantage",
            "log_file": "/var/log/ubuntu-advantage.log",
            "timer_log_file": "/var/log/ubuntu-advantage-timer.log",
            "daemon_log_file": "/var/log/ubuntu-advantage-daemon.log",  # noqa: E501
            "log_level": "INFO",
        }
        assert expected_default_config == config

    @pytest.mark.parametrize(
        "config_dict,expected_invalid_keys",
        (
            ({"contract_url": "http://abc", "security_url": "http:xyz"}, []),
            (
                {"contract_urs": "http://abc", "security_url": "http:xyz"},
                ["contract_urs"],
            ),
        ),
    )
    def test_parse_config_returns_invalid_keys(
        self, config_dict, expected_invalid_keys, tmpdir
    ):
        config_file = tmpdir.join("uaclient.conf")
        config_file.write(yaml.dump(config_dict))
        env_vars = {"UA_CONFIG_FILE": config_file.strpath}
        with mock.patch.dict("uaclient.config.os.environ", values=env_vars):
            cfg, invalid_keys = parse_config(config_file.strpath)
        assert set(expected_invalid_keys) == invalid_keys
        for key, value in config_dict.items():
            if key in VALID_UA_CONFIG_KEYS:
                assert config_dict[key] == cfg[key]

    @pytest.mark.parametrize(
        "envvar_name,envvar_val,field,expected_val",
        [
            # not on allowlist
            (
                "UA_CONTRACT_URL",
                "https://contract",
                "contract_url",
                "https://contracts.canonical.com",
            ),
            # on allowlist
            (
                "UA_security_URL",
                "https://security",
                "security_url",
                "https://security",
            ),
            (
                "ua_data_dir",
                "~/somedir",
                "data_dir",
                "{}/somedir".format(os.path.expanduser("~")),
            ),
            ("Ua_LoG_FiLe", "some.log", "log_file", "some.log"),
            ("UA_LOG_LEVEL", "debug", "log_level", "debug"),
        ],
    )
    @mock.patch("uaclient.config.os.path.exists", return_value=False)
    @mock.patch("uaclient.contract.get_available_resources")
    def test_parse_config_scrubs_user_environ_values(
        self,
        _m_resources,
        m_exists,
        envvar_name,
        envvar_val,
        field,
        expected_val,
    ):
        user_values = {envvar_name: envvar_val}
        with mock.patch.dict("uaclient.config.os.environ", values=user_values):
            config, _ = parse_config()
        assert expected_val == config[field]

    @mock.patch("uaclient.config.os.path.exists", return_value=False)
    def test_parse_config_scrubs_user_environ_values_features(self, m_exists):
        user_values = {
            "UA_FEATURES_X_Y_Z": "XYZ_VAL",
            "UA_FEATURES_A_B_C": "ABC_VAL",
        }
        with mock.patch.dict("uaclient.config.os.environ", values=user_values):
            config, _ = parse_config()
        expected_config = {
            "features": {"a_b_c": "ABC_VAL", "x_y_z": "XYZ_VAL"}
        }
        assert expected_config["features"] == config["features"]

    @pytest.mark.parametrize(
        "env_var,env_value", [("UA_SECURITY_URL", "ht://security")]
    )
    @mock.patch("uaclient.config.os.path.exists", return_value=False)
    def test_parse_raises_errors_on_invalid_urls(
        self, _m_exists, env_var, env_value
    ):
        user_values = {env_var: env_value}  # no acceptable url scheme
        with mock.patch.dict("uaclient.config.os.environ", values=user_values):
            with pytest.raises(exceptions.UserFacingError) as excinfo:
                parse_config()
        expected_msg = "Invalid url in config. {}: {}".format(
            env_var.replace("UA_", "").lower(), env_value
        )
        assert expected_msg == excinfo.value.msg

    @mock.patch("uaclient.config.os.path.exists")
    @mock.patch("uaclient.util.load_file")
    def test_parse_reads_yaml_from_environ_values(
        self, m_load_file, m_path_exists
    ):
        m_load_file.return_value = "test: true\nfoo: bar"
        m_path_exists.side_effect = [False, False, True]

        user_values = {"UA_FEATURES_TEST": "test.yaml"}
        with mock.patch.dict("uaclient.config.os.environ", values=user_values):
            cfg, _ = parse_config()

        assert {"test": True, "foo": "bar"} == cfg["features"]["test"]

    @mock.patch("uaclient.config.os.path.exists")
    def test_parse_raise_exception_when_environ_yaml_file_does_not_exist(
        self, m_path_exists
    ):
        m_path_exists.return_value = False
        user_values = {"UA_FEATURES_TEST": "test.yaml"}
        with mock.patch.dict("uaclient.config.os.environ", values=user_values):
            with pytest.raises(exceptions.UserFacingError) as excinfo:
                parse_config()

        expected_msg = "Could not find yaml file: test.yaml"
        assert expected_msg == excinfo.value.msg.strip()


class TestFeatures:
    @pytest.mark.parametrize("caplog_text", [logging.WARNING], indirect=True)
    @pytest.mark.parametrize(
        "cfg_features,expected, warnings",
        (
            ({}, {}, None),
            (None, {}, None),
            (
                "badstring",
                {},
                "Unexpected uaclient.conf features value."
                " Expected dict, but found badstring",
            ),
            ({"feature1": "value1"}, {"feature1": "value1"}, None),
            (
                {"feature1": "value1", "feature2": False},
                {"feature1": "value1", "feature2": False},
                None,
            ),
        ),
    )
    def test_features_are_a_property_of_uaconfig(
        self, cfg_features, expected, warnings, caplog_text
    ):
        user_cfg = {"features": cfg_features}
        cfg = UAConfig(cfg=user_cfg)
        assert expected == cfg.features
        if warnings:
            assert warnings in caplog_text()


class TestMachineTokenOverlay:
    machine_token_dict = {
        "availableResources": [
            {"available": False, "name": "cc-eal"},
            {"available": True, "name": "esm-infra"},
            {"available": False, "name": "fips"},
        ],
        "machineTokenInfo": {
            "contractInfo": {
                "resourceEntitlements": [
                    {
                        "type": "cc-eal",
                        "entitled": False,
                        "affordances": {
                            "architectures": [
                                "amd64",
                                "ppc64el",
                                "ppc64le",
                                "s390x",
                                "x86_64",
                            ],
                            "series": ["xenial"],
                        },
                        "directives": {
                            "additionalPackages": ["ubuntu-commoncriteria"],
                            "aptKey": "key",
                            "aptURL": "https://esm.ubuntu.com/cc",
                            "suites": ["xenial"],
                        },
                    },
                    {
                        "type": "livepatch",
                        "entitled": True,
                        "affordances": {
                            "architectures": ["amd64", "x86_64"],
                            "tier": "stable",
                        },
                        "directives": {
                            "caCerts": "",
                            "remoteServer": "https://livepatch.canonical.com",
                        },
                        "obligations": {"enableByDefault": True},
                    },
                ]
            }
        },
    }

    @mock.patch("uaclient.util.load_file")
    @mock.patch("uaclient.config.UAConfig.read_cache")
    @mock.patch("uaclient.config.os.path.exists", return_value=True)
    def test_machine_token_update_with_overlay(
        self, m_path, m_read_cache, m_load_file
    ):
        user_cfg = {
            "features": {"machine_token_overlay": "machine-token-path"}
        }
        m_read_cache.return_value = self.machine_token_dict

        remote_server_overlay = "overlay"
        json_str = json.dumps(
            {
                "availableResources": [
                    {"available": False, "name": "esm-infra"},
                    {"available": True, "name": "test-overlay"},
                ],
                "machineTokenInfo": {
                    "contractInfo": {
                        "resourceEntitlements": [
                            {
                                "type": "livepatch",
                                "entitled": False,
                                "affordances": {"architectures": ["test"]},
                                "directives": {"remoteServer": "overlay"},
                            }
                        ]
                    }
                },
            }
        )
        m_load_file.return_value = json_str

        expected = copy.deepcopy(self.machine_token_dict)
        expected["machineTokenInfo"]["contractInfo"]["resourceEntitlements"][
            1
        ]["directives"]["remoteServer"] = remote_server_overlay
        expected["machineTokenInfo"]["contractInfo"]["resourceEntitlements"][
            1
        ]["affordances"]["architectures"] = ["test"]
        expected["machineTokenInfo"]["contractInfo"]["resourceEntitlements"][
            1
        ]["entitled"] = False
        expected["availableResources"][1]["available"] = False
        expected["availableResources"].append(
            {"available": True, "name": "test-overlay"}
        )

        cfg = UAConfig(cfg=user_cfg)
        assert expected == cfg.machine_token

    @mock.patch("uaclient.config.UAConfig.read_cache")
    def test_machine_token_without_overlay(self, m_read_cache):
        user_cfg = {}
        m_read_cache.return_value = self.machine_token_dict
        cfg = UAConfig(cfg=user_cfg)
        assert self.machine_token_dict == cfg.machine_token

    @mock.patch("uaclient.config.UAConfig.read_cache")
    @mock.patch("uaclient.config.os.path.exists", return_value=False)
    def test_machine_token_overlay_file_not_found(self, m_path, m_read_cache):
        invalid_path = "machine-token-path"
        user_cfg = {"features": {"machine_token_overlay": invalid_path}}
        m_read_cache.return_value = self.machine_token_dict

        cfg = UAConfig(cfg=user_cfg)
        expected_msg = messages.INVALID_PATH_FOR_MACHINE_TOKEN_OVERLAY.format(
            file_path=invalid_path
        )

        with pytest.raises(exceptions.UserFacingError) as excinfo:
            cfg.machine_token

        assert expected_msg == str(excinfo.value)

    @mock.patch("uaclient.util.load_file")
    @mock.patch("uaclient.config.UAConfig.read_cache")
    @mock.patch("uaclient.config.os.path.exists", return_value=True)
    def test_machine_token_overlay_json_decode_error(
        self, m_path, m_read_cache, m_load_file
    ):
        invalid_json_path = "machine-token-path"
        user_cfg = {"features": {"machine_token_overlay": invalid_json_path}}
        m_read_cache.return_value = self.machine_token_dict

        json_str = '{"directives": {"remoteServer": "overlay"}'
        m_load_file.return_value = json_str
        expected_msg = messages.ERROR_JSON_DECODING_IN_FILE.format(
            error="Expecting ',' delimiter: line 1 column 43 (char 42)",
            file_path=invalid_json_path,
        )

        cfg = UAConfig(cfg=user_cfg)
        with pytest.raises(exceptions.UserFacingError) as excinfo:
            cfg.machine_token

        assert expected_msg == str(excinfo.value)


class TestDepthFirstMergeOverlayDict:
    @pytest.mark.parametrize(
        "base_dict, overlay_dict, expected_dict",
        [
            ({"a": 1, "b": 2}, {"c": 3}, {"a": 1, "b": 2, "c": 3}),
            (
                {"a": 1, "b": {"c": 2, "d": 3}},
                {"a": 1, "b": {"c": 10}},
                {"a": 1, "b": {"c": 10, "d": 3}},
            ),
            (
                {"a": 1, "b": {"c": 2, "d": 3}},
                {"d": {"f": 20}},
                {"a": 1, "b": {"c": 2, "d": 3}, "d": {"f": 20}},
            ),
            ({"a": 1, "b": 2}, {}, {"a": 1, "b": 2}),
            ({"a": 1, "b": 2}, {"a": "test"}, {"a": "test", "b": 2}),
            ({}, {"a": 1, "b": 2}, {"a": 1, "b": 2}),
            ({"a": []}, {"a": [1, 2, 3]}, {"a": [1, 2, 3]}),
            ({"a": [5, 6]}, {"a": [1, 2, 3]}, {"a": [1, 2, 3]}),
            ({"a": [{"b": 1}]}, {"a": [{"c": 2}]}, {"a": [{"b": 1, "c": 2}]}),
        ],
    )
    def test_depth_first_merge_dict(
        self, base_dict, overlay_dict, expected_dict
    ):
        depth_first_merge_overlay_dict(base_dict, overlay_dict)
        assert expected_dict == base_dict


class TestGetConfigPath:
    def test_get_config_path_from_env_var(self):
        with mock.patch.dict(
            "uaclient.config.os.environ", values={"UA_CONFIG_FILE": "test"}
        ):
            assert "test" == get_config_path()

    @mock.patch("uaclient.config.os.path.join", return_value="test123")
    @mock.patch("uaclient.config.os.path.exists", return_value=True)
    def test_get_config_path_from_local_dir(self, _m_exists, _m_join):
        with mock.patch.dict("uaclient.config.os.environ", values={}):
            assert "test123" == get_config_path()
            assert _m_join.call_count == 1
            assert _m_exists.call_count == 1

    @mock.patch("uaclient.config.os.path.exists", return_value=False)
    def test_get_default_config_path(self, _m_exists):
        with mock.patch.dict("uaclient.config.os.environ", values={}):
            assert DEFAULT_CONFIG_FILE == get_config_path()
            assert _m_exists.call_count == 1
