# Copyright (c) 2016 Canonical Ltd
#
#    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 base64
import json
import os
from unittest import TestCase, mock
from urllib import parse

import pytest
import requests
import requests.adapters

from pylxd import client, exceptions


class TestClient(TestCase):
    """Tests for pylxd.client.Client."""

    def setUp(self):
        self.get_patcher = mock.patch("pylxd.client._APINode.get")
        self.get = self.get_patcher.start()

        self.post_patcher = mock.patch("pylxd.client._APINode.post")
        self.post = self.post_patcher.start()

        response = mock.MagicMock(status_code=200)
        response.json.return_value = {
            "metadata": {
                "auth": "trusted",
                "environment": {"storage": "zfs"},
            }
        }
        self.get.return_value = response

        post_response = mock.MagicMock(status_code=200)
        self.post.return_value = post_response

    def tearDown(self):
        self.get_patcher.stop()
        self.post_patcher.stop()

    @mock.patch("os.path.exists")
    def test_create(self, _path_exists):
        """Client creation sets default API endpoint."""
        _path_exists.return_value = False
        expected = "http+unix://%2Fvar%2Flib%2Flxd%2Funix.socket/1.0"

        an_client = client.Client()

        self.assertEqual(expected, an_client.api._api_endpoint)

    @mock.patch("os.path.exists")
    @mock.patch("os.environ")
    def test_create_with_snap_lxd(self, _environ, _path_exists):
        """Client creation sets default API endpoint."""
        _path_exists.return_value = True
        expected = "http+unix://%2Fvar%2Fsnap%2Flxd%2F" "common%2Flxd%2Funix.socket/1.0"

        an_client = client.Client()
        self.assertEqual(expected, an_client.api._api_endpoint)

    def test_create_LXD_DIR(self):
        """When LXD_DIR is set, use it in the client."""
        os.environ["LXD_DIR"] = "/lxd"
        expected = "http+unix://%2Flxd%2Funix.socket/1.0"

        an_client = client.Client()

        self.assertEqual(expected, an_client.api._api_endpoint)

    def test_create_endpoint(self):
        """Explicitly set the client endpoint."""
        endpoint = "http://lxd"
        expected = "http://lxd/1.0"

        an_client = client.Client(endpoint=endpoint)

        self.assertEqual(expected, an_client.api._api_endpoint)

    def test_create_endpoint_with_project(self):
        """Explicitly set the client endpoint."""
        response = mock.MagicMock(status_code=200)
        response.json.side_effect = [
            {
                "metadata": {
                    "auth": "untrusted",
                    "api_extensions": ["projects"],
                }
            },
        ]
        self.get.return_value = response

        endpoint = "/tmp/unix.socket"
        expected = "http+unix://%2Ftmp%2Funix.socket/1.0"

        with mock.patch("os.path.exists") as mock_exists:
            mock_exists.return_value = True
            a_client = client.Client(endpoint=endpoint, project="prj")

        self.assertEqual(a_client.api._api_endpoint, expected)
        self.assertEqual(a_client.api._project, "prj")

    def test_create_endpoint_unixsocket(self):
        """Test with unix socket endpoint."""
        endpoint = "/tmp/unix.socket"
        expected = "http+unix://%2Ftmp%2Funix.socket/1.0"

        with mock.patch("os.path.exists") as mock_exists:
            mock_exists.return_value = True
            a_client = client.Client(endpoint)

        self.assertEqual(expected, a_client.api._api_endpoint)

    def test_connection_404(self):
        """If the endpoint 404s, an exception is raised."""
        response = mock.MagicMock(status_code=404)
        self.get.return_value = response

        self.assertRaises(exceptions.ClientConnectionFailed, client.Client)

    def test_connection_failed(self):
        """If the connection fails, an exception is raised."""

        def raise_exception():
            raise requests.exceptions.ConnectionError()

        self.get.side_effect = raise_exception
        self.get.return_value = None

        self.assertRaises(exceptions.ClientConnectionFailed, client.Client)

    def test_connection_untrusted(self):
        """Client.trusted is False when certs are untrusted."""
        response = mock.MagicMock(status_code=200)
        response.json.return_value = {"metadata": {"auth": "untrusted"}}
        self.get.return_value = response

        an_client = client.Client()

        self.assertFalse(an_client.trusted)

    def test_connection_trusted(self):
        """Client.trusted is True when certs are untrusted."""
        response = mock.MagicMock(status_code=200)
        response.json.return_value = {"metadata": {"auth": "trusted"}}
        self.get.return_value = response

        an_client = client.Client()

        self.assertTrue(an_client.trusted)

    def test_server_clustered_false_no_info(self):
        """Client.server_clustered is False if the info is not available in metadata."""
        response = mock.MagicMock(status_code=200)
        response.json.return_value = {"metadata": {"environment": {}}}
        self.get.return_value = response
        a_client = client.Client()
        self.assertFalse(a_client.server_clustered)

    def test_server_clustered_false(self):
        """Client.server_clustered is False if not clustered."""
        response = mock.MagicMock(status_code=200)
        response.json.return_value = {
            "metadata": {"environment": {"server_clustered": False}}
        }
        self.get.return_value = response
        a_client = client.Client()
        self.assertFalse(a_client.server_clustered)

    def test_server_clustered_true(self):
        """Client.server_clustered is True if clustered."""
        response = mock.MagicMock(status_code=200)
        response.json.return_value = {
            "metadata": {"environment": {"server_clustered": True}}
        }
        self.get.return_value = response
        a_client = client.Client()
        self.assertTrue(a_client.server_clustered)

    def test_authenticate(self):
        """A client is authenticated."""
        response = mock.MagicMock(status_code=200)
        response.json.side_effect = [
            {
                "metadata": {
                    "auth": "untrusted",
                    "api_extensions": ["explicit_trust_token"],
                }
            },
            {
                "metadata": {
                    "type": "client",
                    "fingerprint": "eaf55b72fc23aa516d709271df9b0116064bf8cfa009cf34c67c33ad32c2320c",
                }
            },
            {"metadata": {"auth": "trusted"}},
        ]
        self.get.return_value = response

        certs = (
            os.path.join(os.path.dirname(__file__), "lxd.crt"),
            os.path.join(os.path.dirname(__file__), "lxd.key"),
        )
        an_client = client.Client("https://lxd", cert=certs)

        an_client.authenticate("test-password")

        self.assertTrue(an_client.trusted)

    def test_authenticate_with_project(self):
        """A client is authenticated with a project."""
        response = mock.MagicMock(status_code=200)
        response.json.side_effect = [
            {
                "metadata": {
                    "auth": "untrusted",
                    "api_extensions": ["projects"],
                }
            },
            {
                "metadata": {
                    "type": "client",
                    "fingerprint": "eaf55b72fc23aa516d709271df9b0116064bf8cfa009cf34c67c33ad32c2320c",
                }
            },
            {
                "metadata": {
                    "auth": "trusted",
                    "environment": {"project": "test-proj"},
                }
            },
        ]
        self.get.return_value = response

        certs = (
            os.path.join(os.path.dirname(__file__), "lxd.crt"),
            os.path.join(os.path.dirname(__file__), "lxd.key"),
        )
        an_client = client.Client("https://lxd", cert=certs, project="test-proj")

        an_client.authenticate("test-password")

        self.assertTrue(an_client.trusted)
        self.assertEqual(an_client.host_info["environment"]["project"], "test-proj")

    def test_authenticate_project_not_supported(self):
        """A client raises an error if projects are not supported."""
        response = mock.MagicMock(status_code=200)
        response.json.return_value = {
            "metadata": {
                "auth": "untrusted",
                "api_extensions": [],
            }
        }
        self.get.return_value = response

        with pytest.raises(exceptions.ClientConnectionFailed):
            client.Client("https://lxd", project="test-proj")

    def test_authenticate_project_not_supported_but_default(self):
        """
        A client doesn't raise an error if projects are not supported and the
        default one is requested.
        """
        response = mock.MagicMock(status_code=200)
        response.json.side_effect = [
            {
                "metadata": {
                    "auth": "untrusted",
                    "api_extensions": [],
                }
            },
            {
                "metadata": {
                    "type": "client",
                    "fingerprint": "eaf55b72fc23aa516d709271df9b0116064bf8cfa009cf34c67c33ad32c2320c",
                }
            },
            {
                "metadata": {
                    "auth": "trusted",
                    "environment": {},
                }
            },
        ]
        self.get.return_value = response

        certs = (
            os.path.join(os.path.dirname(__file__), "lxd.crt"),
            os.path.join(os.path.dirname(__file__), "lxd.key"),
        )
        an_client = client.Client("https://lxd", cert=certs, project="default")

        an_client.authenticate("test-password")

        self.assertTrue(an_client.trusted)

    def test_authenticate_already_authenticated(self):
        """If the client is already authenticated, nothing happens."""
        an_client = client.Client()

        an_client.authenticate("test-password")

        self.assertTrue(an_client.trusted)

    def test_host_info(self):
        """Perform a host query."""
        an_client = client.Client()
        self.assertEqual("zfs", an_client.host_info["environment"]["storage"])

    def test_events(self):
        """The default websocket client is returned."""
        an_client = client.Client()

        ws_client = an_client.events()

        self.assertEqual("/1.0/events", ws_client.resource)

    def test_events_unix_socket(self):
        """A unix socket compatible websocket client is returned."""
        websocket_client = mock.Mock(resource=None)
        WebsocketClient = mock.Mock()
        WebsocketClient.return_value = websocket_client
        os.environ["LXD_DIR"] = "/lxd"
        an_client = client.Client()

        an_client.events(websocket_client=WebsocketClient)

        WebsocketClient.assert_called_once_with(
            "ws+unix:///lxd/unix.socket", ssl_options=None
        )

    def test_events_http(self):
        """An http compatible websocket client is returned."""
        websocket_client = mock.Mock(resource=None)
        WebsocketClient = mock.Mock()
        WebsocketClient.return_value = websocket_client
        an_client = client.Client("http://lxd.local")

        an_client.events(websocket_client=WebsocketClient)

        WebsocketClient.assert_called_once_with("ws://lxd.local", ssl_options=None)

    def test_events_https(self):
        """An https compatible websocket client is returned."""
        websocket_client = mock.Mock(resource=None)
        WebsocketClient = mock.Mock()
        WebsocketClient.return_value = websocket_client
        an_client = client.Client("https://lxd.local", cert=client.DEFAULT_CERTS)

        an_client.events(websocket_client=WebsocketClient)
        ssl_options = {
            "certfile": client.DEFAULT_CERTS.cert,
            "keyfile": client.DEFAULT_CERTS.key,
        }
        WebsocketClient.assert_called_once_with(
            "wss://lxd.local", ssl_options=ssl_options
        )

    def test_events_type_filter(self):
        """The websocket client can filter events by type."""

        an_client = client.Client()

        # from the itertools recipes documentation
        def powerset(types):
            from itertools import chain, combinations

            pwset = [combinations(types, r) for r in range(len(types) + 1)]
            return chain.from_iterable(pwset)

        event_path = "/1.0/events"

        for types in powerset(client.EventType):
            ws_client = an_client.events(event_types=set(types))

            actual_resource = parse.urlparse(ws_client.resource)
            expect_resource = parse.urlparse(event_path)

            if types and client.EventType.All not in types:
                type_csl = ",".join([t.value for t in types])
                query = parse.parse_qs(expect_resource.query)
                query.update({"type": type_csl})
                qs = parse.urlencode(query)
                expect_resource = expect_resource._replace(query=qs)

            self.assertEqual(expect_resource.path, actual_resource.path)

            if types and client.EventType.All not in types:
                qdict = parse.parse_qs(expect_resource.query)
                expect_types = set(qdict["type"][0].split(","))
                qdict = parse.parse_qs(actual_resource.query)
                actual_types = set(qdict["type"][0].split(","))

                self.assertEqual(expect_types, actual_types)
            else:
                self.assertEqual(expect_resource.query, actual_resource.query)

    def test_resources(self):
        a_client = client.Client()
        a_client.host_info["api_extensions"] = ["resources"]
        response = mock.MagicMock(status_code=200)
        response.json.return_value = {
            "metadata": {
                "cpu": {},
            }
        }
        self.get.return_value = response
        self.assertIn("cpu", a_client.resources)

    def test_resources_raises_conn_failed_exception(self):
        a_client = client.Client()
        a_client.host_info["api_extensions"] = ["resources"]
        response = mock.MagicMock(status_code=400)
        self.get.return_value = response
        with self.assertRaises(exceptions.ClientConnectionFailed):
            a_client.resources

    def test_resources_raises_api_extension_not_avail_exception(self):
        a_client = client.Client()
        a_client.host_info["api_extensions"] = []
        with self.assertRaises(exceptions.LXDAPIExtensionNotAvailable):
            a_client.resources

    def test_resources_uses_cache(self):
        a_client = client.Client()
        a_client._resource_cache = {"cpu": {}}
        # Client.__init__ calls get, reset the mock before trying
        # resources to confirm it wasn't called.
        self.get.called = False
        self.assertIn("cpu", a_client.resources)
        self.assertFalse(self.get.called)

    def test_has_api_extension(self):
        a_client = client.Client()
        a_client.host_info = {"api_extensions": ["one", "two"]}
        self.assertFalse(a_client.has_api_extension("three"))
        self.assertTrue(a_client.has_api_extension("one"))
        self.assertTrue(a_client.has_api_extension("two"))

    def test_assert_has_api_extension(self):
        a_client = client.Client()
        a_client.host_info = {"api_extensions": ["one", "two"]}
        with self.assertRaises(exceptions.LXDAPIExtensionNotAvailable) as c:
            self.assertFalse(a_client.assert_has_api_extension("three"))
        self.assertIn("three", str(c.exception))
        a_client.assert_has_api_extension("one")
        a_client.assert_has_api_extension("two")


class TestAPINode(TestCase):
    """Tests for pylxd.client._APINode."""

    def test_getattr(self):
        """API Nodes can use object notation for nesting."""
        node = client._APINode("http://test.com", mock.sentinel.session)
        new_node = node.test
        self.assertEqual("http://test.com/test", new_node._api_endpoint)

    def test_getattr_storage_pools(self):
        """API node with storage_pool should be storage-pool"""
        node = client._APINode("http://test.com", mock.sentinel.session)
        new_node = node.test.storage_pools
        self.assertEqual("http://test.com/test/storage-pools", new_node._api_endpoint)
        # other _ should stay as they were.
        new_node = node.test.some_thing
        self.assertEqual("http://test.com/test/some_thing", new_node._api_endpoint)

    def test_getitem(self):
        """API Nodes can use dict notation for nesting."""
        node = client._APINode("http://test.com", mock.sentinel.session)
        new_node = node["test"]
        self.assertEqual("http://test.com/test", new_node._api_endpoint)

    def test_getitem_leave_underscores_alone(self):
        """Bug 295 erronously changed underscores to '-' -- let's make sure
        it doens't happend again
        """
        node = client._APINode("http://test.com", mock.sentinel.session)
        new_node = node.thing["my_snapshot"]
        self.assertEqual("http://test.com/thing/my_snapshot", new_node._api_endpoint)

    def test_session(self):
        """Session is exposed on the _APINode."""
        session = requests.Session()
        node = client._APINode("http://test.com", session)
        self.assertIs(node.session, session)

    def test_session_passed_to_child(self):
        """session should be shared across path traversl"""
        parent_node = client._APINode("http+unix://test.com", mock.sentinel.session)
        child_node = parent_node.instances
        self.assertIs(parent_node.session, child_node.session)

    @mock.patch("pylxd.client.requests.Session")
    def test_get(self, Session):
        """Perform a session get."""
        response = mock.Mock(
            **{
                "status_code": 200,
                "json.return_value": {"type": "sync"},
            }
        )
        session = mock.Mock(**{"get.return_value": response})
        Session.return_value = session

        node = client._APINode("http://test.com", session)
        node.get()
        session.get.assert_called_once_with("http://test.com", timeout=None)

    @mock.patch("pylxd.client.requests.Session")
    def test_post(self, Session):
        """Perform a session post."""
        response = mock.Mock(
            **{
                "status_code": 200,
                "json.return_value": {"type": "sync"},
            }
        )
        session = mock.Mock(**{"post.return_value": response})
        Session.return_value = session
        node = client._APINode("http://test.com", session)
        node.post()
        session.post.assert_called_once_with("http://test.com", timeout=None)

    @mock.patch("pylxd.client.requests.Session")
    def test_post_200_not_sync(self, Session):
        """A status code of 200 with async request raises an exception."""
        response = mock.Mock(
            **{
                "status_code": 200,
                "json.return_value": {"type": "async"},
            }
        )
        session = mock.Mock(**{"post.return_value": response})
        Session.return_value = session
        node = client._APINode("http://test.com", session)
        self.assertRaises(exceptions.LXDAPIException, node.post)

    @mock.patch("pylxd.client.requests.Session")
    def test_post_missing_type_200(self, Session):
        """A missing response type raises an exception."""
        response = mock.Mock(
            **{
                "status_code": 200,
                "json.return_value": {},
            }
        )
        session = mock.Mock(**{"post.return_value": response})
        Session.return_value = session
        node = client._APINode("http://test.com", session)
        self.assertRaises(exceptions.LXDAPIException, node.post)

    @mock.patch("pylxd.client.requests.Session")
    def test_put(self, Session):
        """Perform a session put."""
        response = mock.Mock(
            **{
                "status_code": 200,
                "json.return_value": {"type": "sync"},
            }
        )
        session = mock.Mock(**{"put.return_value": response})
        Session.return_value = session
        node = client._APINode("http://test.com", session)
        node.put()
        session.put.assert_called_once_with("http://test.com", timeout=None)

    @mock.patch("pylxd.client.requests.Session")
    def test_patch(self, Session):
        """Perform a session patch."""
        response = mock.Mock(
            **{
                "status_code": 200,
                "json.return_value": {"type": "sync"},
            }
        )
        session = mock.Mock(**{"patch.return_value": response})
        Session.return_value = session
        node = client._APINode("http://test.com", session)
        node.patch()
        session.patch.assert_called_once_with("http://test.com", timeout=None)

    @mock.patch("pylxd.client.requests.Session")
    def test_delete(self, Session):
        """Perform a session delete."""
        response = mock.Mock(
            **{
                "status_code": 200,
                "json.return_value": {"type": "sync"},
            }
        )
        session = mock.Mock(**{"delete.return_value": response})
        Session.return_value = session
        node = client._APINode("http://test.com", session)
        node.delete()
        session.delete.assert_called_once_with("http://test.com", timeout=None)


class TestWebsocketClient(TestCase):
    """Tests for pylxd.client.WebsocketClient."""

    def test_handshake_ok(self):
        """A `message` attribute of an empty list is created."""
        ws_client = client._WebsocketClient("ws://an/fake/path")
        ws_client.handshake_ok()
        self.assertEqual([], ws_client.messages)

    def test_received_message(self):
        """A json dict is added to the messages attribute."""
        message = mock.Mock(data=json.dumps({"test": "data"}).encode("utf-8"))
        ws_client = client._WebsocketClient("ws://an/fake/path")
        ws_client.handshake_ok()
        ws_client.received_message(message)
        self.assertEqual({"test": "data"}, ws_client.messages[0])


class TestGetSessionForUrl(TestCase):
    """Tests for pylxd.client.get_session_for_url."""

    def test_session_unix_socket(self):
        """http+unix URL return a requests_unixsocket session."""
        session = client.get_session_for_url("http+unix://test.com")
        self.assertIsInstance(
            session.get_adapter("http+unix://"), requests.adapters.HTTPAdapter
        )

    def test_session_http(self):
        """HTTP nodes return the default requests session."""
        session = client.get_session_for_url("http://test.com")
        self.assertIsInstance(session, requests.Session)
        self.assertRaises(
            requests.exceptions.InvalidSchema, session.get_adapter, "http+unix://"
        )

    def test_session_cert(self):
        """If certs are given, they're set on the Session."""
        certs = (
            os.path.join(os.path.dirname(__file__), "lxd.crt"),
            os.path.join(os.path.dirname(__file__), "lxd.key"),
        )
        session = client.get_session_for_url("http://test.com", cert=certs)
        self.assertEqual(session.cert, certs)

    def test_session_verify(self):
        """If verify is given, it's set on the Session."""
        session = client.get_session_for_url("http://test.com", verify=True)
        self.assertIs(session.verify, True)


class TestWsExcludeOrigin:
    """Tests for pylxd.client._ws_exclude_origin."""

    @pytest.mark.parametrize(
        "input_kwargs,expected",
        [
            ({}, ["origin"]),
            ({"exclude_headers": ["upgrade"]}, ["upgrade", "origin"]),
            (
                {"exclude_headers": ["Origin"]},
                ["Origin"],
            ),  # already present, case-insensitive
            ({"exclude_headers": None}, ["origin"]),
        ],
    )
    def test_ws_exclude_origin(self, input_kwargs, expected):
        client._ws_exclude_origin(input_kwargs)
        assert input_kwargs["exclude_headers"] == expected


class TestIsAToken(TestCase):
    """Tests for pylxd.client._is_a_token."""

    def _make_token(self):
        token = json.dumps(
            {
                "client_name": "test",
                "fingerprint": "abcd",
                "addresses": ["192.0.2.1:8443"],
                "secret": "I-am-a-secret",
                "expires_at": "0001-01-01T00:00:00Z",
                "type": "",
            }
        )
        return base64.b64encode(token.encode("utf-8"))

    def test_plain_password_is_not_a_token(self):
        """A plain password string is not a token."""
        self.assertFalse(client._is_a_token("password"))

    def test_b64_non_json_is_not_a_token(self):
        """Base64-encoded non-JSON data is not a token."""
        self.assertFalse(client._is_a_token(base64.b64encode(b"password")))

    def test_valid_token_bytes_is_a_token(self):
        """A valid base64-encoded token (bytes) is recognised as a token."""
        self.assertTrue(client._is_a_token(self._make_token()))

    def test_valid_token_str_is_a_token(self):
        """A valid base64-encoded token (str) is recognised as a token."""
        self.assertTrue(client._is_a_token(self._make_token().decode("utf-8")))

    def test_token_with_surrounding_whitespace_is_a_token(self):
        """A token with surrounding whitespace (copy-paste artefact) is still detected."""
        token_str = self._make_token().decode("utf-8")
        self.assertTrue(client._is_a_token(f"\n  {token_str}  \n"))

    def test_token_bytes_with_surrounding_whitespace_is_a_token(self):
        """A token bytes value with surrounding whitespace is still detected."""
        token_bytes = b"\n  " + self._make_token() + b"  \n"
        self.assertTrue(client._is_a_token(token_bytes))

    def test_invalid_base64_characters_returns_false(self):
        """Data containing non-base64 characters (caught by validate=True) returns False."""
        # These characters are never valid in strict base64 but b64decode without
        # validate=True would silently ignore them.
        self.assertFalse(client._is_a_token("not!valid@base64#"))
