# Copyright 2012 OpenStack LLC.
# 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.

from __future__ import annotations

from http import client as http_client
import json
import time
from typing import Any
from unittest import mock

from keystoneauth1 import exceptions as kexc

from ironicclient.common import filecache
from ironicclient.common import http
from ironicclient import exc
from ironicclient.tests.unit import utils


DEFAULT_TIMEOUT = 600

DEFAULT_HOST = 'localhost'
DEFAULT_PORT = '1234'


def _get_error_body(
    faultstring: str | None = None,
    debuginfo: str | None = None,
    description: str | None = None,
) -> str:
    if description:
        raw_error_body = json.dumps({'description': description})
    else:
        raw_error_body = json.dumps(
            {'faultstring': faultstring, 'debuginfo': debuginfo}
        )
    body = {'error_message': raw_error_body}
    return json.dumps(body)


def _session_client(**kwargs: Any) -> http.SessionClient:
    return http.SessionClient(os_ironic_api_version='1.6',
                              api_version_select_state='default',
                              max_retries=5,
                              retry_interval=2,
                              auth=None,
                              interface='publicURL',
                              service_type='baremetal',
                              region_name='',
                              endpoint_override='http://%s:%s' % (
                                  DEFAULT_HOST, DEFAULT_PORT),
                              **kwargs)


class VersionNegotiationMixinTest(utils.BaseTestCase):

    def setUp(self) -> None:
        super(VersionNegotiationMixinTest, self).setUp()
        self.test_object = http.VersionNegotiationMixin()
        self.test_object.os_ironic_api_version = '1.6'
        self.test_object.api_version_select_state = 'default'
        self.test_object.endpoint_override = "http://localhost:1234"
        self.mock_mcu = mock.MagicMock()
        self.test_object._make_connection_url = self.mock_mcu
        self.response = utils.FakeResponse(
            {}, status=http_client.NOT_ACCEPTABLE)
        self.test_object.get_server = mock.MagicMock(
            return_value=('localhost', '1234'))

    def test__generic_parse_version_headers_has_headers(self) -> None:
        response = {'X-OpenStack-Ironic-API-Minimum-Version': '1.1',
                    'X-OpenStack-Ironic-API-Maximum-Version': '1.6',
                    }
        expected = ('1.1', '1.6')
        result = self.test_object._generic_parse_version_headers(response.get)
        self.assertEqual(expected, result)

    def test__generic_parse_version_headers_missing_headers(self) -> None:
        response = {}
        expected = (None, None)
        result = self.test_object._generic_parse_version_headers(response.get)
        self.assertEqual(expected, result)

    @mock.patch.object(filecache, 'save_data', autospec=True)
    def test_negotiate_version_bad_state(
        self, mock_save_data: mock.MagicMock,
    ) -> None:
        # Test if bad api_version_select_state value
        self.test_object.api_version_select_state = 'word of the day: augur'
        self.assertRaises(
            RuntimeError,
            self.test_object.negotiate_version,
            None, None)
        self.assertEqual(0, mock_save_data.call_count)

    @mock.patch.object(filecache, 'save_data', autospec=True)
    @mock.patch.object(http.VersionNegotiationMixin, '_parse_version_headers',
                       autospec=True)
    def test_negotiate_version_server_older(
        self,
        mock_pvh: mock.MagicMock,
        mock_save_data: mock.MagicMock,
    ) -> None:
        # Test newer client and older server
        latest_ver = '1.5'
        mock_pvh.return_value = ('1.1', latest_ver)
        mock_conn = mock.MagicMock()
        result = self.test_object.negotiate_version(mock_conn, self.response)
        self.assertEqual(latest_ver, result)
        self.assertEqual(1, mock_pvh.call_count)
        host, port = http.get_server(self.test_object.endpoint_override)
        mock_save_data.assert_called_once_with(host=host, port=port,
                                               data=latest_ver)

    @mock.patch.object(filecache, 'save_data', autospec=True)
    @mock.patch.object(http.VersionNegotiationMixin, '_parse_version_headers',
                       autospec=True)
    def test_negotiate_version_server_newer(
        self,
        mock_pvh: mock.MagicMock,
        mock_save_data: mock.MagicMock,
    ) -> None:
        # Test newer server and older client
        mock_pvh.return_value = ('1.1', '1.10')
        mock_conn = mock.MagicMock()
        result = self.test_object.negotiate_version(mock_conn, self.response)
        self.assertEqual('1.6', result)
        self.assertEqual(1, mock_pvh.call_count)
        mock_save_data.assert_called_once_with(host=DEFAULT_HOST,
                                               port=DEFAULT_PORT,
                                               data='1.6')

    @mock.patch.object(filecache, 'save_data', autospec=True)
    @mock.patch.object(http.VersionNegotiationMixin, '_make_simple_request',
                       autospec=True)
    @mock.patch.object(http.VersionNegotiationMixin, '_parse_version_headers',
                       autospec=True)
    def test_negotiate_version_server_no_version_on_error(
            self,
            mock_pvh: mock.MagicMock,
            mock_msr: mock.MagicMock,
            mock_save_data: mock.MagicMock,
    ) -> None:
        # Test older Ironic version which errored with no version number and
        # have to retry with simple get
        mock_pvh.side_effect = iter([(None, None), ('1.1', '1.2')])
        mock_conn = mock.MagicMock()
        result = self.test_object.negotiate_version(mock_conn, self.response)
        self.assertEqual('1.2', result)
        self.assertTrue(mock_msr.called)
        self.assertEqual(2, mock_pvh.call_count)
        self.assertEqual(1, mock_save_data.call_count)

    @mock.patch.object(filecache, 'save_data', autospec=True)
    @mock.patch.object(http.VersionNegotiationMixin, '_parse_version_headers',
                       autospec=True)
    def test_negotiate_version_server_explicit_too_high(
        self,
        mock_pvh: mock.MagicMock,
        mock_save_data: mock.MagicMock,
    ) -> None:
        # requested version is not supported because it is too large
        mock_pvh.return_value = ('1.1', '1.6')
        mock_conn = mock.MagicMock()
        self.test_object.api_version_select_state = 'user'
        self.test_object.os_ironic_api_version = '99.99'
        self.assertRaises(
            exc.UnsupportedVersion,
            self.test_object.negotiate_version,
            mock_conn, self.response)
        self.assertEqual(1, mock_pvh.call_count)
        self.assertEqual(0, mock_save_data.call_count)

    @mock.patch.object(filecache, 'save_data', autospec=True)
    @mock.patch.object(http.VersionNegotiationMixin, '_parse_version_headers',
                       autospec=True)
    def test_negotiate_version_server_explicit_not_supported(
        self,
        mock_pvh: mock.MagicMock,
        mock_save_data: mock.MagicMock,
    ) -> None:
        # requested version is supported by the server but the server returned
        # 406 because the requested operation is not supported with the
        # requested version
        mock_pvh.return_value = ('1.1', '1.6')
        mock_conn = mock.MagicMock()
        self.test_object.api_version_select_state = 'negotiated'
        self.test_object.os_ironic_api_version = '1.5'
        self.assertRaises(
            exc.UnsupportedVersion,
            self.test_object.negotiate_version,
            mock_conn, self.response)
        self.assertEqual(1, mock_pvh.call_count)
        self.assertEqual(0, mock_save_data.call_count)

    @mock.patch.object(filecache, 'save_data', autospec=True)
    @mock.patch.object(http.VersionNegotiationMixin, '_parse_version_headers',
                       autospec=True)
    def test_negotiate_version_strict_version_comparison(
        self,
        mock_pvh: mock.MagicMock,
        mock_save_data: mock.MagicMock,
    ) -> None:
        # Test version comparison with StrictVersion
        max_ver = '1.10'
        mock_pvh.return_value = ('1.2', max_ver)
        mock_conn = mock.MagicMock()
        self.test_object.os_ironic_api_version = '1.10'
        result = self.test_object.negotiate_version(mock_conn, self.response)
        self.assertEqual(max_ver, result)
        self.assertEqual(1, mock_pvh.call_count)
        host, port = http.get_server(self.test_object.endpoint_override)
        mock_save_data.assert_called_once_with(host=host, port=port,
                                               data=max_ver)

    @mock.patch.object(filecache, 'save_data', autospec=True)
    @mock.patch.object(http.VersionNegotiationMixin, '_make_simple_request',
                       autospec=True)
    @mock.patch.object(http.VersionNegotiationMixin, '_parse_version_headers',
                       autospec=True)
    def test_negotiate_version_server_user_latest(
            self,
            mock_pvh: mock.MagicMock,
            mock_msr: mock.MagicMock,
            mock_save_data: mock.MagicMock,
    ) -> None:
        # For the test to work we need to have the mock reply
        # with a version range that exceeds the client version
        # otherwise we are not confirming that the client is
        # clamping the version correctly
        large_value = http.LAST_KNOWN_API_VERSION + 99
        # have to retry with simple get
        mock_pvh.side_effect = iter([
            (None, None),
            ('1.1', f"1.{large_value}")
        ])
        mock_conn = mock.MagicMock()
        self.test_object.api_version_select_state = 'user'
        self.test_object.os_ironic_api_version = 'latest'
        result = self.test_object.negotiate_version(mock_conn, None)
        self.assertEqual(http.LATEST_VERSION, result)
        self.assertEqual('negotiated',
                         self.test_object.api_version_select_state)
        self.assertEqual(http.LATEST_VERSION,
                         self.test_object.os_ironic_api_version)

        self.assertTrue(mock_msr.called)
        self.assertEqual(2, mock_pvh.call_count)
        self.assertEqual(1, mock_save_data.call_count)

    @mock.patch.object(filecache, 'save_data', autospec=True)
    @mock.patch.object(http.VersionNegotiationMixin, '_make_simple_request',
                       autospec=True)
    @mock.patch.object(http.VersionNegotiationMixin, '_parse_version_headers',
                       autospec=True)
    def test_negotiate_version_server_user_list(
            self,
            mock_pvh: mock.MagicMock,
            mock_msr: mock.MagicMock,
            mock_save_data: mock.MagicMock,
    ) -> None:
        # have to retry with simple get
        mock_pvh.side_effect = [(None, None), ('1.1', '1.26')]
        mock_conn = mock.MagicMock()
        self.test_object.api_version_select_state = 'user'
        self.test_object.os_ironic_api_version = ['1.1', '1.6', '1.25',
                                                  '1.26', '1.27', '1.28',
                                                  '1.30']
        result = self.test_object.negotiate_version(mock_conn, self.response)
        self.assertEqual('1.26', result)
        self.assertEqual('negotiated',
                         self.test_object.api_version_select_state)
        self.assertEqual('1.26',
                         self.test_object.os_ironic_api_version)

        self.assertTrue(mock_msr.called)
        self.assertEqual(2, mock_pvh.call_count)
        self.assertEqual(1, mock_save_data.call_count)

    @mock.patch.object(filecache, 'save_data', autospec=True)
    @mock.patch.object(http.VersionNegotiationMixin, '_make_simple_request',
                       autospec=True)
    @mock.patch.object(http.VersionNegotiationMixin, '_parse_version_headers',
                       autospec=True)
    def test_negotiate_version_server_user_list_fails_nomatch(
            self,
            mock_pvh: mock.MagicMock,
            mock_msr: mock.MagicMock,
            mock_save_data: mock.MagicMock,
    ) -> None:
        # have to retry with simple get
        mock_pvh.side_effect = iter([(None, None), ('1.2', '1.26')])
        mock_conn = mock.MagicMock()
        self.test_object.api_version_select_state = 'user'
        self.test_object.os_ironic_api_version = ['1.39', '1.1']
        self.assertRaises(
            exc.UnsupportedVersion,
            self.test_object.negotiate_version,
            mock_conn, self.response)
        self.assertEqual('user',
                         self.test_object.api_version_select_state)
        self.assertEqual(['1.39', '1.1'],
                         self.test_object.os_ironic_api_version)
        self.assertEqual(2, mock_pvh.call_count)
        self.assertEqual(0, mock_save_data.call_count)

    @mock.patch.object(filecache, 'save_data', autospec=True)
    @mock.patch.object(http.VersionNegotiationMixin, '_make_simple_request',
                       autospec=True)
    @mock.patch.object(http.VersionNegotiationMixin, '_parse_version_headers',
                       autospec=True)
    def test_negotiate_version_server_user_list_single_value(
            self,
            mock_pvh: mock.MagicMock,
            mock_msr: mock.MagicMock,
            mock_save_data: mock.MagicMock,
    ) -> None:
        # have to retry with simple get
        mock_pvh.side_effect = iter([(None, None), ('1.1', '1.26')])
        mock_conn = mock.MagicMock()
        self.test_object.api_version_select_state = 'user'
        # NOTE(TheJulia): Lets test this value explicitly because the
        # minor number is actually the same.
        self.test_object.os_ironic_api_version = ['1.01']
        result = self.test_object.negotiate_version(mock_conn, None)
        self.assertEqual('1.1', result)
        self.assertEqual('negotiated',
                         self.test_object.api_version_select_state)
        self.assertEqual('1.1',
                         self.test_object.os_ironic_api_version)
        self.assertTrue(mock_msr.called)
        self.assertEqual(2, mock_pvh.call_count)
        self.assertEqual(1, mock_save_data.call_count)

    @mock.patch.object(filecache, 'save_data', autospec=True)
    @mock.patch.object(http.VersionNegotiationMixin, '_make_simple_request',
                       autospec=True)
    @mock.patch.object(http.VersionNegotiationMixin, '_parse_version_headers',
                       autospec=True)
    def test_negotiate_version_server_user_list_fails_latest(
            self,
            mock_pvh: mock.MagicMock,
            mock_msr: mock.MagicMock,
            mock_save_data: mock.MagicMock,
    ) -> None:
        # have to retry with simple get
        mock_pvh.side_effect = iter([(None, None), ('1.1', '1.2')])
        mock_conn = mock.MagicMock()
        self.test_object.api_version_select_state = 'user'
        self.test_object.os_ironic_api_version = ['1.01', 'latest']
        self.assertRaises(
            ValueError,
            self.test_object.negotiate_version,
            mock_conn, self.response)
        self.assertEqual('user',
                         self.test_object.api_version_select_state)
        self.assertEqual(['1.01', 'latest'],
                         self.test_object.os_ironic_api_version)
        self.assertEqual(2, mock_pvh.call_count)
        self.assertEqual(0, mock_save_data.call_count)

    @mock.patch.object(filecache, 'save_data', autospec=True)
    @mock.patch.object(http.VersionNegotiationMixin, '_make_simple_request',
                       autospec=True)
    @mock.patch.object(http.VersionNegotiationMixin, '_parse_version_headers',
                       autospec=True)
    def test_negotiate_version_explicit_version_request(
            self,
            mock_pvh: mock.MagicMock,
            mock_msr: mock.MagicMock,
            mock_save_data: mock.MagicMock,
    ) -> None:
        mock_pvh.side_effect = iter([(None, None), ('1.1', '1.99')])
        mock_conn = mock.MagicMock()
        self.test_object.api_version_select_state = 'negotiated'
        self.test_object.os_ironic_api_version = '1.30'
        req_header = {'X-OpenStack-Ironic-API-Version': '1.29'}
        response = utils.FakeResponse(
            {}, status=http_client.NOT_ACCEPTABLE,
            request_headers=req_header)
        self.assertRaisesRegex(exc.UnsupportedVersion,
                               ".*is not supported by the server.*",
                               self.test_object.negotiate_version,
                               mock_conn, response)
        self.assertTrue(mock_msr.called)
        self.assertEqual(2, mock_pvh.call_count)
        self.assertFalse(mock_save_data.called)

    def test_get_server(self) -> None:
        host = 'ironic-host'
        port = '6385'
        endpoint_override = 'http://%s:%s/ironic/v1/' % (host, port)
        self.assertEqual((host, port), http.get_server(endpoint_override))


class SessionClientTest(utils.BaseTestCase):
    def test_server_exception_empty_body(self) -> None:
        error_body = _get_error_body()

        fake_session = utils.mockSession({'Content-Type': 'application/json'},
                                         error_body,
                                         http_client.INTERNAL_SERVER_ERROR)

        client = _session_client(session=fake_session)

        self.assertRaises(exc.InternalServerError,
                          client.json_request,
                          'GET', '/v1/resources')

    def test_server_exception_description_only(self) -> None:
        error_msg = 'test error msg'
        error_body = _get_error_body(description=error_msg)
        fake_session = utils.mockSession(
            {'Content-Type': 'application/json'},
            error_body, status_code=http_client.BAD_REQUEST)
        client = _session_client(session=fake_session)

        self.assertRaisesRegex(exc.BadRequest, 'test error msg',
                               client.json_request,
                               'GET', '/v1/resources')

    def test__parse_version_headers(self) -> None:
        # Test parsing of version headers from SessionClient
        fake_session = utils.mockSession(
            {'X-OpenStack-Ironic-API-Minimum-Version': '1.1',
             'X-OpenStack-Ironic-API-Maximum-Version': '1.6',
             'content-type': 'text/plain',
             },
            None,
            http_client.HTTP_VERSION_NOT_SUPPORTED)
        expected_result = ('1.1', '1.6')
        client = _session_client(session=fake_session)
        result = client._parse_version_headers(fake_session.request())
        self.assertEqual(expected_result, result)

    def test_make_simple_request(self) -> None:
        session = utils.mockSession({})

        client = _session_client(session=session)
        res = client._make_simple_request(session, 'GET', 'url')

        session.request.assert_called_once_with(
            'url', 'GET', raise_exc=False,
            endpoint_filter={
                'interface': 'publicURL',
                'service_type': 'baremetal',
                'region_name': ''
            },
            endpoint_override='http://localhost:1234',
            user_agent=http.USER_AGENT)
        self.assertEqual(res, session.request.return_value)

    @mock.patch.object(http.SessionClient, 'get_endpoint', autospec=True)
    def test_endpoint_not_found(
        self, mock_get_endpoint: mock.MagicMock,
    ) -> None:
        mock_get_endpoint.return_value = None

        self.assertRaises(exc.EndpointNotFound, _session_client,
                          session=utils.mockSession({}))

    def test_json_request(self) -> None:
        session = utils.mockSession({}, status_code=200)
        req_id = "req-7b081d28-8272-45f4-9cf6-89649c1c7a1a"
        client = _session_client(
            session=session, additional_headers={"foo": "bar"},
            global_request_id=req_id)
        client.json_request('GET', 'url')

        session.request.assert_called_once_with(
            'url', 'GET', raise_exc=False, auth=None,
            headers={
                "foo": "bar",
                "X-OpenStack-Request-ID": req_id,
                "Content-Type": "application/json",
                "Accept": "application/json",
                "X-OpenStack-Ironic-API-Version": "1.6"
            },
            endpoint_filter={
                'interface': 'publicURL',
                'service_type': 'baremetal',
                'region_name': ''
            },
            endpoint_override='http://localhost:1234',
            user_agent=http.USER_AGENT
        )


@mock.patch.object(time, 'sleep', lambda *_: None)
class RetriesTestCase(utils.BaseTestCase):

    def test_session_retry(self) -> None:
        error_body = _get_error_body()

        fake_resp = utils.mockSessionResponse(
            {'Content-Type': 'application/json'},
            error_body,
            http_client.CONFLICT)
        ok_resp = utils.mockSessionResponse(
            {'Content-Type': 'application/json'},
            b"OK",
            http_client.OK)
        fake_session = utils.mockSession({})
        fake_session.request.side_effect = iter((fake_resp, ok_resp))

        client = _session_client(session=fake_session)
        client.json_request('GET', '/v1/resources')
        self.assertEqual(2, fake_session.request.call_count)

    def test_session_retry_503(self) -> None:
        error_body = _get_error_body()

        fake_resp = utils.mockSessionResponse(
            {'Content-Type': 'application/json'},
            error_body,
            http_client.SERVICE_UNAVAILABLE)
        ok_resp = utils.mockSessionResponse(
            {'Content-Type': 'application/json'},
            b"OK",
            http_client.OK)
        fake_session = utils.mockSession({})
        fake_session.request.side_effect = iter((fake_resp, ok_resp))

        client = _session_client(session=fake_session)
        client.json_request('GET', '/v1/resources')
        self.assertEqual(2, fake_session.request.call_count)

    def test_session_retry_connection_refused(self) -> None:
        ok_resp = utils.mockSessionResponse(
            {'Content-Type': 'application/json'},
            b"OK",
            http_client.OK)
        fake_session = utils.mockSession({})
        fake_session.request.side_effect = iter((exc.ConnectionRefused(),
                                                 ok_resp))

        client = _session_client(session=fake_session)
        client.json_request('GET', '/v1/resources')
        self.assertEqual(2, fake_session.request.call_count)

    def test_session_retry_retriable_connection_failure(
        self,
    ) -> None:
        ok_resp = utils.mockSessionResponse(
            {'Content-Type': 'application/json'},
            b"OK",
            http_client.OK)
        fake_session = utils.mockSession({})
        fake_session.request.side_effect = iter(
            (kexc.RetriableConnectionFailure(), ok_resp))

        client = _session_client(session=fake_session)
        client.json_request('GET', '/v1/resources')
        self.assertEqual(2, fake_session.request.call_count)

    def test_session_retry_fail(self) -> None:
        error_body = _get_error_body()

        fake_resp = utils.mockSessionResponse(
            {'Content-Type': 'application/json'},
            error_body,
            http_client.CONFLICT)
        fake_session = utils.mockSession({})
        fake_session.request.return_value = fake_resp

        client = _session_client(session=fake_session)

        self.assertRaises(exc.Conflict, client.json_request,
                          'GET', '/v1/resources')
        self.assertEqual(http.DEFAULT_MAX_RETRIES + 1,
                         fake_session.request.call_count)

    def test_session_max_retries_none(self) -> None:
        error_body = _get_error_body()

        fake_resp = utils.mockSessionResponse(
            {'Content-Type': 'application/json'},
            error_body,
            http_client.CONFLICT)
        fake_session = utils.mockSession({})
        fake_session.request.return_value = fake_resp

        client = _session_client(session=fake_session)
        client.conflict_max_retries = None

        self.assertRaises(exc.Conflict, client.json_request,
                          'GET', '/v1/resources')
        self.assertEqual(http.DEFAULT_MAX_RETRIES + 1,
                         fake_session.request.call_count)

    def test_session_change_max_retries(self) -> None:
        error_body = _get_error_body()

        fake_resp = utils.mockSessionResponse(
            {'Content-Type': 'application/json'},
            error_body,
            http_client.CONFLICT)
        fake_session = utils.mockSession({})
        fake_session.request.return_value = fake_resp

        client = _session_client(session=fake_session)
        client.conflict_max_retries = http.DEFAULT_MAX_RETRIES + 1

        self.assertRaises(exc.Conflict, client.json_request,
                          'GET', '/v1/resources')
        self.assertEqual(http.DEFAULT_MAX_RETRIES + 2,
                         fake_session.request.call_count)
