import os
import logging
import math
import mock
import subprocess
import sys
import ddtrace

from unittest import TestCase, skip, skipUnless

from ddtrace.api import API, Response
from ddtrace.ext import http
from ddtrace.filters import FilterRequestsOnUrl
from ddtrace.constants import FILTERS_KEY
from ddtrace.tracer import Tracer
from ddtrace.encoding import JSONEncoder, MsgpackEncoder
from ddtrace.compat import httplib, PYTHON_INTERPRETER, PYTHON_VERSION
from ddtrace.internal.runtime.container import CGroupInfo
from ddtrace.payload import Payload
from ddtrace.span import Span
from ddtrace.compat import monotonic
from tests.tracer.test_tracer import get_dummy_tracer


class MockedLogHandler(logging.Handler):
    """Record log messages to verify error logging logic"""

    def __init__(self, *args, **kwargs):
        self.messages = {"debug": [], "info": [], "warning": [], "error": [], "critical": []}
        super(MockedLogHandler, self).__init__(*args, **kwargs)

    def emit(self, record):
        self.acquire()
        try:
            self.messages[record.levelname.lower()].append(record.getMessage())
        finally:
            self.release()


class FlawedAPI(API):
    """
    Deliberately report data with an incorrect method to trigger a 4xx response
    """

    def _put(self, endpoint, data, count=0):
        conn = httplib.HTTPConnection(self.hostname, self.port)
        conn.request("HEAD", endpoint, data, self._headers)
        return Response.from_http_response(conn.getresponse())


@skipUnless(
    os.environ.get("TEST_DATADOG_INTEGRATION", False),
    "You should have a running trace agent and set TEST_DATADOG_INTEGRATION=1 env variable",
)
class TestWorkers(TestCase):
    """
    Ensures that a workers interacts correctly with the main thread. These are part
    of integration tests so real calls are triggered.
    """

    def setUp(self):
        """
        Create a tracer with running workers, while spying the ``_put()`` method to
        keep trace of triggered API calls.
        """
        # create a new tracer
        self.tracer = Tracer()
        # spy the send() method
        self.api = self.tracer.writer.api
        self.api._put = mock.Mock(self.api._put, wraps=self.api._put)

    def tearDown(self):
        """
        Stop running worker
        """
        self._wait_thread_flush()

    def _wait_thread_flush(self):
        """
        Helper that waits for the thread flush
        """
        self.tracer.writer.flush_queue()
        self.tracer.writer.stop()
        self.tracer.writer.join(None)

    def _get_endpoint_payload(self, calls, endpoint):
        """
        Helper to retrieve the endpoint call from a concurrent
        trace or service call.
        """
        for call, _ in calls:
            if endpoint in call[0]:
                return call[0], self.api._encoder._decode(call[1])

        return None, None

    @skipUnless(
        os.environ.get("TEST_DATADOG_INTEGRATION_UDS", False),
        "You should have a running trace agent on a socket and set TEST_DATADOG_INTEGRATION_UDS=1 env variable",
    )
    def test_worker_single_trace_uds(self):
        self.tracer.configure(uds_path="/tmp/ddagent/trace.sock")
        # Write a first trace so we get a _worker
        self.tracer.trace("client.testing").finish()
        worker = self.tracer.writer
        worker._log_error_status = mock.Mock(
            worker._log_error_status,
            wraps=worker._log_error_status,
        )
        self.tracer.trace("client.testing").finish()

        # one send is expected
        self._wait_thread_flush()
        # Check that no error was logged
        assert worker._log_error_status.call_count == 0

    def test_worker_single_trace_uds_wrong_socket_path(self):
        self.tracer.configure(uds_path="/tmp/ddagent/nosockethere")
        # Write a first trace so we get a _worker
        self.tracer.trace("client.testing").finish()
        worker = self.tracer.writer
        worker._log_error_status = mock.Mock(
            worker._log_error_status,
            wraps=worker._log_error_status,
        )
        self.tracer.trace("client.testing").finish()

        # one send is expected
        self._wait_thread_flush()
        # Check that no error was logged
        assert worker._log_error_status.call_count == 1

    def test_worker_single_trace(self):
        # create a trace block and send it using the transport system
        tracer = self.tracer
        tracer.trace("client.testing").finish()

        # one send is expected
        self._wait_thread_flush()
        assert self.api._put.call_count == 1
        # check and retrieve the right call
        endpoint, payload = self._get_endpoint_payload(self.api._put.call_args_list, "/v0.4/traces")
        assert endpoint == "/v0.4/traces"
        assert len(payload) == 1
        assert len(payload[0]) == 1
        assert payload[0][0][b"name"] == b"client.testing"

    # DEV: If we can make the writer flushing deterministic for the case of tests, then we can re-enable this
    @skip("Writer flush intervals are impossible to time correctly to make this test not flaky")
    def test_worker_multiple_traces(self):
        # make a single send() if multiple traces are created before the flush interval
        tracer = self.tracer
        tracer.trace("client.testing").finish()
        tracer.trace("client.testing").finish()

        # one send is expected
        self._wait_thread_flush()
        assert self.api._put.call_count == 1
        # check and retrieve the right call
        endpoint, payload = self._get_endpoint_payload(self.api._put.call_args_list, "/v0.4/traces")
        assert endpoint == "/v0.4/traces"
        assert len(payload) == 2
        assert len(payload[0]) == 1
        assert len(payload[1]) == 1
        assert payload[0][0][b"name"] == b"client.testing"
        assert payload[1][0][b"name"] == b"client.testing"

    def test_worker_single_trace_multiple_spans(self):
        # make a single send() if a single trace with multiple spans is created before the flush
        tracer = self.tracer
        parent = tracer.trace("client.testing")
        tracer.trace("client.testing").finish()
        parent.finish()

        # one send is expected
        self._wait_thread_flush()
        assert self.api._put.call_count == 1
        # check and retrieve the right call
        endpoint, payload = self._get_endpoint_payload(self.api._put.call_args_list, "/v0.4/traces")
        assert endpoint == "/v0.4/traces"
        assert len(payload) == 1
        assert len(payload[0]) == 2
        assert payload[0][0][b"name"] == b"client.testing"
        assert payload[0][1][b"name"] == b"client.testing"

    def test_worker_http_error_logging(self):
        # Tests the logging http error logic
        tracer = self.tracer
        self.tracer.writer.api = FlawedAPI(Tracer.DEFAULT_HOSTNAME, Tracer.DEFAULT_PORT)
        tracer.trace("client.testing").finish()

        log = logging.getLogger("ddtrace.internal.writer")
        log_handler = MockedLogHandler(level="DEBUG")
        log.addHandler(log_handler)

        self._wait_thread_flush()
        assert tracer.writer._last_error_ts < monotonic()

        logged_errors = log_handler.messages["error"]
        assert len(logged_errors) == 1
        assert (
            "Failed to send traces to Datadog Agent at http://localhost:8126: "
            "HTTP error status 400, reason Bad Request, message Content-Type:" in logged_errors[0]
        )

    def test_worker_filter_request(self):
        self.tracer.configure(settings={FILTERS_KEY: [FilterRequestsOnUrl(r"http://example\.com/health")]})
        # spy the send() method
        self.api = self.tracer.writer.api
        self.api._put = mock.Mock(self.api._put, wraps=self.api._put)

        span = self.tracer.trace("testing.filteredurl")
        span.set_tag(http.URL, "http://example.com/health")
        span.finish()
        span = self.tracer.trace("testing.nonfilteredurl")
        span.set_tag(http.URL, "http://example.com/api/resource")
        span.finish()
        self._wait_thread_flush()

        # Only the second trace should have been sent
        assert self.api._put.call_count == 1
        # check and retrieve the right call
        endpoint, payload = self._get_endpoint_payload(self.api._put.call_args_list, "/v0.4/traces")
        assert endpoint == "/v0.4/traces"
        assert len(payload) == 1
        assert payload[0][0][b"name"] == b"testing.nonfilteredurl"


@skipUnless(
    os.environ.get("TEST_DATADOG_INTEGRATION", False),
    "You should have a running trace agent and set TEST_DATADOG_INTEGRATION=1 env variable",
)
class TestAPITransport(TestCase):
    """
    Ensures that traces are properly sent to a local agent. These are part
    of integration tests so real calls are triggered and you have to execute
    a real trace-agent to let them pass.
    """

    @mock.patch("ddtrace.internal.runtime.container.get_container_info")
    def setUp(self, get_container_info):
        """
        Create a tracer without workers, while spying the ``send()`` method
        """
        # Mock the container id we use for making requests
        get_container_info.return_value = CGroupInfo(container_id="test-container-id")

        # create a new API object to test the transport using synchronous calls
        self.tracer = get_dummy_tracer()
        self.api_json = API("localhost", 8126, encoder=JSONEncoder())
        self.api_msgpack = API("localhost", 8126, encoder=MsgpackEncoder())

    @mock.patch("ddtrace.api.httplib.HTTPConnection")
    def test_send_presampler_headers(self, mocked_http):
        # register a single trace with a span and send them to the trace agent
        self.tracer.trace("client.testing").finish()
        trace = self.tracer.writer.pop()
        traces = [trace]

        # make a call and retrieve the `conn` Mock object
        self.api_msgpack.send_traces(traces)
        request_call = mocked_http.return_value.request
        assert request_call.call_count == 1

        # retrieve the headers from the mocked request call
        expected_headers = {
            "Datadog-Container-Id": "test-container-id",  # mocked in setUp()
            "Datadog-Meta-Lang": "python",
            "Datadog-Meta-Lang-Interpreter": PYTHON_INTERPRETER,
            "Datadog-Meta-Lang-Version": PYTHON_VERSION,
            "Datadog-Meta-Tracer-Version": ddtrace.__version__,
            "X-Datadog-Trace-Count": "1",
            "Content-Type": "application/msgpack",
        }
        params, _ = request_call.call_args_list[0]
        headers = params[3]
        assert expected_headers == headers

    def _send_traces_and_check(self, traces, nresponses=1):
        # test JSON encoder
        responses = self.api_json.send_traces(traces)
        assert len(responses) == nresponses
        for response in responses:
            assert isinstance(response, Exception) or response.status == 200

        # test Msgpack encoder
        responses = self.api_msgpack.send_traces(traces)
        assert len(responses) == nresponses
        for response in responses:
            assert isinstance(response, Exception) or response.status == 200

    def test_send_single_trace(self):
        # register a single trace with a span and send them to the trace agent
        self.tracer.trace("client.testing").finish()
        trace = self.tracer.writer.pop()
        traces = [trace]

        self._send_traces_and_check(traces)

    def test_send_many_traces(self):
        # register a single trace with a span and send them to the trace agent
        self.tracer.trace("client.testing").finish()
        trace = self.tracer.writer.pop()
        # 20k is a right number to have both json and msgpack send 2 payload :)
        traces = [trace] * 20000

        self._send_traces_and_check(traces, 2)

    def test_send_single_trace_max_payload(self):
        payload = Payload()

        # compute number of spans to create to surpass max payload
        trace = [Span(self.tracer, "child.span")]
        trace_size = len(payload.encoder.encode_trace(trace))
        num_spans = int(math.floor(payload.max_payload_size / trace_size))

        # setup logging capture
        log = logging.getLogger("ddtrace.api")
        log_handler = MockedLogHandler(level="WARNING")
        log.addHandler(log_handler)

        with self.tracer.trace("client.testing"):
            for n in range(num_spans):
                self.tracer.trace("child.span").finish()

        trace = self.tracer.writer.pop()

        self._send_traces_and_check([trace], 1)

        logged_warnings = log_handler.messages["warning"]
        assert len(logged_warnings) == 1
        assert "Trace is larger than the max payload size, dropping it" in logged_warnings[0]

    def test_send_multiple_trace_max_payload(self):
        payload = Payload()

        # compute number of spans to create to surpass max payload
        trace = [Span(self.tracer, "child.span")]
        trace_size = len(payload.encoder.encode_trace(trace))
        num_spans = int(math.floor((payload.max_payload_size - trace_size) / trace_size))

        # setup logging capture
        log = logging.getLogger("ddtrace.api")
        log_handler = MockedLogHandler(level="WARNING")
        log.addHandler(log_handler)

        self.tracer.trace("client.testing").finish()
        traces = [self.tracer.writer.pop()]

        # create a trace larger than max payload size
        with self.tracer.trace("client.testing"):
            for n in range(num_spans):
                self.tracer.trace("child.span").finish()

        traces.append(self.tracer.writer.pop())

        self._send_traces_and_check(traces, 2)

        logged_warnings = log_handler.messages["warning"]
        assert len(logged_warnings) == 1
        assert "Trace is too big to fit in a payload, dropping it" in logged_warnings[0]

    def test_send_single_with_wrong_errors(self):
        # if the error field is set to True, it must be cast as int so
        # that the agent decoder handles that properly without providing
        # a decoding error
        span = self.tracer.trace("client.testing")
        span.error = True
        span.finish()
        trace = self.tracer.writer.pop()
        traces = [trace]

        self._send_traces_and_check(traces)

    def test_send_multiple_traces(self):
        # register some traces and send them to the trace agent
        self.tracer.trace("client.testing").finish()
        trace_1 = self.tracer.writer.pop()
        self.tracer.trace("client.testing").finish()
        trace_2 = self.tracer.writer.pop()
        traces = [trace_1, trace_2]

        self._send_traces_and_check(traces)

    def test_send_single_trace_multiple_spans(self):
        # register some traces and send them to the trace agent
        with self.tracer.trace("client.testing"):
            self.tracer.trace("client.testing").finish()
        trace = self.tracer.writer.pop()
        traces = [trace]

        self._send_traces_and_check(traces)

    def test_send_multiple_traces_multiple_spans(self):
        # register some traces and send them to the trace agent
        with self.tracer.trace("client.testing"):
            self.tracer.trace("client.testing").finish()
        trace_1 = self.tracer.writer.pop()

        with self.tracer.trace("client.testing"):
            self.tracer.trace("client.testing").finish()
        trace_2 = self.tracer.writer.pop()

        traces = [trace_1, trace_2]

        self._send_traces_and_check(traces)


@skipUnless(
    os.environ.get("TEST_DATADOG_INTEGRATION", False),
    "You should have a running trace agent and set TEST_DATADOG_INTEGRATION=1 env variable",
)
class TestAPIDowngrade(TestCase):
    """
    Ensures that if the tracing client found an earlier trace agent,
    it will downgrade the current connection to a stable API version
    """

    @skip("msgpack package split breaks this test; it works for newer version of msgpack")
    def test_downgrade_api(self):
        # make a call to a not existing endpoint, downgrades
        # the current API to a stable one
        tracer = get_dummy_tracer()
        tracer.trace("client.testing").finish()
        trace = tracer.writer.pop()

        # the encoder is right but we're targeting an API
        # endpoint that is not available
        api = API("localhost", 8126)
        api._traces = "/v0.0/traces"
        assert isinstance(api._encoder, MsgpackEncoder)

        # after the call, we downgrade to a working endpoint
        response = api.send_traces([trace])
        assert response
        assert response.status == 200
        assert isinstance(api._encoder, JSONEncoder)


@skipUnless(
    os.environ.get("TEST_DATADOG_INTEGRATION", False),
    "You should have a running trace agent and set TEST_DATADOG_INTEGRATION=1 env variable",
)
class TestRateByService(TestCase):
    """
    Check we get feedback from the agent and we're able to process it.
    """

    def setUp(self):
        """
        Create a tracer without workers, while spying the ``send()`` method
        """
        # create a new API object to test the transport using synchronous calls
        self.tracer = get_dummy_tracer()
        self.api_json = API("localhost", 8126, encoder=JSONEncoder(), priority_sampling=True)
        self.api_msgpack = API("localhost", 8126, encoder=MsgpackEncoder(), priority_sampling=True)

    def test_send_single_trace(self):
        # register a single trace with a span and send them to the trace agent
        self.tracer.trace("client.testing").finish()
        trace = self.tracer.writer.pop()
        traces = [trace]

        # [TODO:christian] when CI has an agent that is able to process the v0.4
        # endpoint, add a check to:
        # - make sure the output is a valid JSON
        # - make sure the priority sampler (if enabled) is updated

        # test JSON encoder
        responses = self.api_json.send_traces(traces)
        assert len(responses) == 1
        assert responses[0].status == 200
        assert responses[0].get_json() == dict(rate_by_service={"service:,env:": 1})

        # test Msgpack encoder
        responses = self.api_msgpack.send_traces(traces)
        assert len(responses) == 1
        assert responses[0].status == 200
        assert responses[0].get_json() == dict(rate_by_service={"service:,env:": 1})


@skipUnless(
    os.environ.get("TEST_DATADOG_INTEGRATION", False),
    "You should have a running trace agent and set TEST_DATADOG_INTEGRATION=1 env variable",
)
class TestConfigure(TestCase):
    """
    Ensures that when calling configure without specifying hostname and port,
    previous overrides have been kept.
    """

    def test_configure_keeps_api_hostname_and_port(self):
        tracer = Tracer()  # use real tracer with real api
        assert "localhost" == tracer.writer.api.hostname
        assert 8126 == tracer.writer.api.port
        tracer.configure(hostname="127.0.0.1", port=8127)
        assert "127.0.0.1" == tracer.writer.api.hostname
        assert 8127 == tracer.writer.api.port
        tracer.configure(priority_sampling=True)
        assert "127.0.0.1" == tracer.writer.api.hostname
        assert 8127 == tracer.writer.api.port


def test_debug_mode():
    p = subprocess.Popen(
        [sys.executable, "-c", "import ddtrace"],
        env=dict(),
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
    )
    p.wait()
    assert p.stdout.read() == b""
    assert b"DEBUG:ddtrace" not in p.stderr.read()

    p = subprocess.Popen(
        [sys.executable, "-c", "import ddtrace"],
        env=dict(DD_TRACE_DEBUG="true"),
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
    )
    p.wait()
    assert p.stdout.read() == b""
    # Stderr should have some debug lines
    assert b"DEBUG:ddtrace" in p.stderr.read()
