import os.path
import unittest
import urllib
from unittest import mock

from django.conf import settings
from django.core.files.base import ContentFile
from django.test import TestCase, override_settings
from django.urls import reverse

from wagtail.documents import models


@override_settings(WAGTAILDOCS_SERVE_METHOD=None)
class TestServeView(TestCase):
    def setUp(self):
        self.document = models.Document(title="Test document", file_hash="123456")
        self.document.file.save(
            "serve_view.doc", ContentFile(b"A boring example document")
        )
        self.pdf_document = models.Document(title="Test document", file_hash="123456")
        self.pdf_document.file.save(
            "serve_view.pdf", ContentFile(b"A boring example document")
        )

    def tearDown(self):
        if hasattr(self, "response"):
            # Make sure the response is fully read before deleting the document so
            # that the file is closed by the view.
            # This is required on Windows as the below line that deletes the file
            # will crash if the file is still open.
            b"".join(self.response.streaming_content)

        # delete the FieldFile directly because the TestCase does not commit
        # transactions to trigger transaction.on_commit() in the signal handler
        self.document.file.delete()
        self.pdf_document.file.delete()

    def get(self, document=None):
        document = document or self.document
        self.response = self.client.get(
            reverse("wagtaildocs_serve", args=(document.id, document.filename))
        )
        return self.response

    def test_response_code(self):
        self.assertEqual(self.get().status_code, 200)

    def test_content_disposition_header(self):
        self.assertEqual(
            self.get(self.document)["Content-Disposition"],
            f'attachment; filename="{self.document.filename}"',
        )

    def test_inline_content_disposition_header(self):
        self.assertEqual(
            self.get(self.pdf_document)["Content-Disposition"],
            f'inline; filename="{self.pdf_document.filename}"',
        )

    @mock.patch("wagtail.documents.views.serve.hooks")
    @mock.patch("wagtail.documents.views.serve.get_object_or_404")
    def test_non_local_filesystem_content_disposition_header(
        self, mock_get_object_or_404, mock_hooks
    ):
        """
        Tests the 'Content-Disposition' header in a response when using a
        storage backend that doesn't expose filesystem paths.
        """
        # Create a mock document with no local file to hit the correct code path
        mock_doc = mock.Mock()
        mock_doc.filename = self.document.filename
        mock_doc.content_type = self.document.content_type
        mock_doc.content_disposition = self.document.content_disposition
        mock_doc.file = ContentFile(b"file-like object" * 10)
        mock_doc.file.path = None
        mock_doc.file.url = None
        mock_get_object_or_404.return_value = mock_doc

        # Bypass 'before_serve_document' hooks
        mock_hooks.get_hooks.return_value = []

        response = self.get()

        self.assertEqual(response.status_code, 200)

        self.assertEqual(
            response["Content-Disposition"],
            "attachment; filename={0}; filename*=UTF-8''{0}".format(
                urllib.parse.quote(self.document.filename)
            ),
        )

    @mock.patch("wagtail.documents.views.serve.hooks")
    @mock.patch("wagtail.documents.views.serve.get_object_or_404")
    def test_non_local_filesystem_inline_content_disposition_header(
        self, mock_get_object_or_404, mock_hooks
    ):
        """
        Tests the 'Content-Disposition' header in a response when using a
        storage backend that doesn't expose filesystem paths.
        """
        # Create a mock document with no local file to hit the correct code path
        mock_doc = mock.Mock()
        mock_doc.filename = self.pdf_document.filename
        mock_doc.content_type = self.pdf_document.content_type
        mock_doc.content_disposition = self.pdf_document.content_disposition
        mock_doc.file = ContentFile(b"file-like object" * 10)
        mock_doc.file.path = None
        mock_doc.file.url = None
        mock_get_object_or_404.return_value = mock_doc

        # Bypass 'before_serve_document' hooks
        mock_hooks.get_hooks.return_value = []

        response = self.get(self.pdf_document)

        self.assertEqual(response.status_code, 200)

        self.assertEqual(response["Content-Disposition"], "inline")

    def test_content_length_header(self):
        self.assertEqual(self.get()["Content-Length"], "25")

    def test_content_type_header(self):
        self.assertEqual(self.get()["Content-Type"], "application/msword")

    def test_is_streaming_response(self):
        self.assertTrue(self.get().streaming)

    def test_content(self):
        self.assertEqual(
            b"".join(self.get().streaming_content), b"A boring example document"
        )

    def test_document_served_fired(self):
        mock_handler = mock.MagicMock()
        models.document_served.connect(mock_handler)

        try:
            self.get()

            self.assertEqual(mock_handler.call_count, 1)
            self.assertEqual(mock_handler.mock_calls[0][2]["sender"], models.Document)
            self.assertEqual(mock_handler.mock_calls[0][2]["instance"], self.document)
        finally:
            models.document_served.disconnect(mock_handler)

    def test_with_nonexistent_document(self):
        response = self.client.get(
            reverse(
                "wagtaildocs_serve",
                args=(
                    1000,
                    "blahblahblah",
                ),
            )
        )
        self.assertEqual(response.status_code, 404)

    def test_with_incorrect_filename(self):
        response = self.client.get(
            reverse("wagtaildocs_serve", args=(self.document.id, "incorrectfilename"))
        )
        self.assertEqual(response.status_code, 404)

    def test_has_etag_header(self):
        self.assertEqual(self.get()["ETag"], '"123456"')

    def clear_sendfile_cache(self):
        from wagtail.utils.sendfile import _get_sendfile

        _get_sendfile.clear()


@override_settings(WAGTAILDOCS_SERVE_METHOD="redirect")
class TestServeViewWithRedirect(TestCase):
    def setUp(self):
        self.document = models.Document(title="Test document")
        self.document.file.save(
            "serve_view_with_redirect.doc",
            ContentFile("A boring example document"),
        )
        self.serve_view_url = reverse(
            "wagtaildocs_serve", args=(self.document.id, self.document.filename)
        )

    def tearDown(self):
        self.document.delete()

    def get(self):
        return self.client.get(
            reverse(
                "wagtaildocs_serve", args=(self.document.id, self.document.filename)
            )
        )

    def test_document_url_should_point_to_serve_view(self):
        self.assertEqual(self.document.url, self.serve_view_url)

    def test_redirect(self):
        response = self.get()
        self.assertRedirects(
            response, self.document.file.url, fetch_redirect_response=False
        )


@override_settings(WAGTAILDOCS_SERVE_METHOD="direct")
class TestDirectDocumentUrls(TestCase):
    def setUp(self):
        self.document = models.Document(title="Test document")
        self.document.file.save(
            "direct_document_urls.doc",
            ContentFile("A boring example document"),
        )

    def tearDown(self):
        self.document.delete()

    def get(self):
        return self.client.get(
            reverse(
                "wagtaildocs_serve", args=(self.document.id, self.document.filename)
            )
        )

    def test_url_should_point_directly_to_file_storage_url(self):
        self.assertEqual(self.document.url, self.document.file.url)

    def test_redirect(self):
        # The serve view will not normally be linked to in 'direct' mode, but we should ensure it
        # still works by redirecting
        response = self.get()
        self.assertRedirects(
            response, self.document.file.url, fetch_redirect_response=False
        )


@override_settings(
    WAGTAILDOCS_SERVE_METHOD=None,
    STORAGES={
        **settings.STORAGES,
        "default": {
            "BACKEND": "wagtail.test.dummy_external_storage.DummyExternalStorage"
        },
    },
)
class TestServeWithExternalStorage(TestCase):
    """
    Test the behaviour of the default serve method when used with a remote storage backend
    (i.e. one that throws NotImplementedError for the path() method).
    """

    def setUp(self):
        self.document = models.Document(title="Test document")
        self.document.file.save(
            "serve_with_external_storage.doc",
            ContentFile("A boring example document"),
        )
        self.serve_view_url = reverse(
            "wagtaildocs_serve", args=(self.document.id, self.document.filename)
        )

    def tearDown(self):
        self.document.delete()

    def test_document_url_should_point_to_serve_view(self):
        self.assertEqual(self.document.url, self.serve_view_url)

    def test_redirect(self):
        # serve view should redirect to the remote URL
        response = self.client.get(self.serve_view_url)
        self.assertRedirects(
            response, self.document.file.url, fetch_redirect_response=False
        )


@override_settings(WAGTAILDOCS_SERVE_METHOD=None)
class TestServeViewWithSendfile(TestCase):
    def setUp(self):
        # Import using a try-catch block to prevent crashes if the
        # django-sendfile module is not installed
        try:
            import sendfile  # noqa: F401
        except ImportError:
            raise unittest.SkipTest("django-sendfile not installed")

        self.document = models.Document(title="Test document")
        self.document.file.save(
            "serve_view_with_sendfile.doc",
            ContentFile("A boring example document"),
        )

    def tearDown(self):
        # delete the FieldFile directly because the TestCase does not commit
        # transactions to trigger transaction.on_commit() in the signal handler
        self.document.file.delete()

    def get(self):
        return self.client.get(
            reverse(
                "wagtaildocs_serve", args=(self.document.id, self.document.filename)
            )
        )

    def clear_sendfile_cache(self):
        from wagtail.utils.sendfile import _get_sendfile

        _get_sendfile.clear()

    @override_settings(SENDFILE_BACKEND="sendfile.backends.xsendfile")
    def test_sendfile_xsendfile_backend(self):
        self.clear_sendfile_cache()
        response = self.get()

        self.assertEqual(response.status_code, 200)
        self.assertEqual(response["X-Sendfile"], self.document.file.path)

    @override_settings(
        SENDFILE_BACKEND="sendfile.backends.mod_wsgi",
        SENDFILE_ROOT=settings.MEDIA_ROOT,
        SENDFILE_URL=settings.MEDIA_URL[:-1],
    )
    def test_sendfile_mod_wsgi_backend(self):
        self.clear_sendfile_cache()
        response = self.get()

        self.assertEqual(response.status_code, 200)
        self.assertEqual(
            response["Location"],
            os.path.join(settings.MEDIA_URL, self.document.file.name),
        )

    @override_settings(
        SENDFILE_BACKEND="sendfile.backends.nginx",
        SENDFILE_ROOT=settings.MEDIA_ROOT,
        SENDFILE_URL=settings.MEDIA_URL[:-1],
    )
    def test_sendfile_nginx_backend(self):
        self.clear_sendfile_cache()
        response = self.get()

        self.assertEqual(response.status_code, 200)
        self.assertEqual(
            response["X-Accel-Redirect"],
            os.path.join(settings.MEDIA_URL, self.document.file.name),
        )


@override_settings(WAGTAILDOCS_SERVE_METHOD=None)
class TestServeWithUnicodeFilename(TestCase):
    def setUp(self):
        self.document = models.Document(title="Test document")

        self.filename = "docs\u0627\u0644\u0643\u0627\u062a\u062f\u0631\u0627"
        "\u064a\u064a\u0629_\u0648\u0627\u0644\u0633\u0648\u0642"
        try:
            self.document.file.save(
                self.filename, ContentFile("A boring example document")
            )
        except UnicodeEncodeError:
            raise unittest.SkipTest("Filesystem doesn't support unicode filenames")

    def tearDown(self):
        # delete the FieldFile directly because the TestCase does not commit
        # transactions to trigger transaction.on_commit() in the signal handler
        self.document.file.delete()

    def test_response_code(self):
        response = self.client.get(
            reverse("wagtaildocs_serve", args=(self.document.id, self.filename))
        )
        self.assertEqual(response.status_code, 200)

    @mock.patch("wagtail.documents.views.serve.hooks")
    @mock.patch("wagtail.documents.views.serve.get_object_or_404")
    def test_non_local_filesystem_unicode_content_disposition_header(
        self, mock_get_object_or_404, mock_hooks
    ):
        """
        Tests that a unicode 'Content-Disposition' header (for a response using
        a storage backend that doesn't expose filesystem paths) doesn't cause an
        error if encoded differently.
        """
        # Create a mock document to hit the correct code path.
        mock_doc = mock.Mock()
        mock_doc.filename = "TÈST.doc"
        mock_doc.file = ContentFile(b"file-like object" * 10)
        mock_doc.file.path = None
        mock_doc.file.url = None
        mock_get_object_or_404.return_value = mock_doc

        # Bypass 'before_serve_document' hooks
        mock_hooks.get_hooks.return_value = []

        response = self.client.get(
            reverse("wagtaildocs_serve", args=(self.document.id, mock_doc.filename))
        )

        self.assertEqual(response.status_code, 200)

        try:
            response["Content-Disposition"].encode("ascii")
        except UnicodeDecodeError:
            self.fail(
                "Content-Disposition with unicode characters failed ascii encoding."
            )

        try:
            response["Content-Disposition"].encode("latin-1")
        except UnicodeDecodeError:
            self.fail(
                "Content-Disposition with unicode characters failed latin-1 encoding."
            )
