# Copyright 2022-2024 The Debusine Developers
# See the AUTHORS file at the top-level directory of this distribution
#
# This file is part of Debusine. It is subject to the license terms
# in the LICENSE file found in the top-level directory of this
# distribution. No part of Debusine, including this file, may be copied,
# modified, propagated, or distributed except according to the terms
# contained in the LICENSE file.

"""Tests for the views."""
import re
from datetime import timedelta
from typing import ClassVar
from unittest import mock

from django.conf import settings
from django.contrib import messages
from django.contrib.auth import get_user_model
from django.test import override_settings
from django.urls import reverse
from rest_framework import status

from debusine.db.models import (
    Scope,
    Token,
    User,
    WorkRequest,
    WorkflowTemplate,
    default_workspace,
)
from debusine.test.django import ListFilter, TestCase, override_permission
from debusine.web.templatetags import debusine as template_debusine
from debusine.web.views import UserTokenDeleteView
from debusine.web.views.base import Widget
from debusine.web.views.tests.utils import (
    ViewTestMixin,
    date_format,
    html_check_icon,
)


class HomepageViewTests(ViewTestMixin, TestCase):
    """Tests for the Homepage class."""

    no_workspaces_msg = "No workspaces."
    no_workspaces_not_authenticated = (
        "Not authenticated. Only public workspaces are listed."
    )
    list_your_tokens_url = reverse("user:token-list")
    list_your_tokens_html = (
        f'<a class="dropdown-item" href="{list_your_tokens_url}">Tokens</a>'
    )
    create_work_request_url = reverse("work-requests:create")
    create_work_request_html = (
        f'<a class="dropdown-item" href="{create_work_request_url}">'
        "Create work request</a>"
    )

    create_artifact_url = reverse("artifacts:create")
    create_artifact_html = (
        f'<a class="dropdown-item" href="{create_artifact_url}">'
        "Create artifact</a>"
    )

    def test_html_check_icon(self) -> None:
        """Test that html_check_icon returns the right results."""
        self.assertIn("green", html_check_icon(True))
        self.assertNotIn("green", html_check_icon(False))
        self.assertIn("red", html_check_icon(False))
        self.assertNotIn("red", html_check_icon(True))

    def test_slash(self):
        """
        Slash (/) URL and homepage are the same.

        Make sure that the / is handled by the 'homepage' view.
        """
        self.assertEqual(reverse("homepage:homepage"), "/")

    def test_homepage(self):
        """Homepage view loads."""
        # Mark the default workspace as private in order to test the "No
        # workspaces" message.
        default_workspace = self.playground.get_default_workspace()
        default_workspace.public = False
        default_workspace.save()

        response = self.client.get(reverse("homepage:homepage"))
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        tree = self.assertHTMLValid(response)

        self.assertTextContentEqual(
            tree.xpath("//h1")[0], "Welcome to debusine!"
        )

        ul = tree.xpath("//ul[@id='navbar-right']")[0]
        self.assertEqual(
            ul.li.a.get("href"),
            reverse("accounts:login") + f"?next={reverse('homepage:homepage')}",
        )

        self.assertFalse(tree.xpath("//ul[@id='navbar-user-actions']"))

        self.assertNotContains(response, self.list_your_tokens_html, html=True)
        self.assertNotContains(response, self.create_work_request_html)

        self.assertContains(response, self.no_workspaces_msg, html=True)

        self.assertContains(
            response, self.no_workspaces_not_authenticated, html=True
        )

        ul = tree.xpath("//ul[@id='scope-list']")[0]
        self.assertEqual(ul.li.a.get("href"), reverse("workspaces:list"))
        self.assertTextContentEqual(ul.li.a, "debusine")

    def test_homepage_logged_in(self):
        """User is logged in: contains "You are authenticated as: username"."""
        username = "testuser"

        user = get_user_model().objects.create_user(
            username=username, password="password"
        )

        self.client.force_login(user)
        response = self.client.get(reverse("homepage:homepage"))
        tree = self.assertHTMLValid(response)

        ul = tree.xpath("//ul[@id='navbar-right']")[0]
        self.assertTextContentEqual(ul.li.a, username)
        ul = tree.xpath("//ul[@id='navbar-user-actions']")[0]
        self.assertEqual(ul.li[-1].a.get("href"), reverse("accounts:logout"))

        self.assertContains(response, self.list_your_tokens_html, html=True)
        self.assertContains(response, self.create_work_request_html, html=True)

        self.assertContains(
            response, f"No work requests created by {username}."
        )

        workspace_rows = self.workspace_list_table_rows(tree)
        self.assertTextContentEqual(
            workspace_rows[0].td.a, default_workspace().name
        )

        self.assertNotContains(response, self.no_workspaces_msg, html=True)
        self.assertNotContains(
            response, self.no_workspaces_not_authenticated, html=True
        )

    def test_homepage_logged_in_with_work_requests(self):
        """User is logged in and contain user's work requests."""
        username = "testuser"

        user = get_user_model().objects.create_user(
            username=username, password="password"
        )

        self.client.force_login(user)

        work_request1 = self.playground.create_work_request(
            task_name="noop", created_by=user, id=11
        )
        work_request2 = self.playground.create_work_request(
            task_name="noop", created_by=user, id=12
        )

        response = self.client.get(reverse("homepage:homepage"))
        tree = self.assertHTMLValid(response)
        table = tree.xpath("//table[@id='work_request-list']")[0]
        self.assertWorkRequestRow(table.tbody.tr[0], work_request2)
        self.assertWorkRequestRow(table.tbody.tr[1], work_request1)

        # the view does not have the handle to sort the table
        self.assertNotContains(response, _sort_table_handle, html=True)

        # latest first
        self.assertEqual(response.context["work_request_list"][0].id, 12)

    def test_homepage_exclude_internal_work_requests(self):
        """The homepage excludes the user's INTERNAL work requests."""
        username = "testuser"

        user = get_user_model().objects.create_user(
            username=username, password="password"
        )

        self.client.force_login(user)

        template = WorkflowTemplate.objects.create(
            name="test",
            workspace=default_workspace(),
            task_name="noop",
            task_data={},
        )
        root = WorkRequest.objects.create_workflow(
            template=template, data={}, created_by=user
        )
        WorkRequest.objects.create_synchronization_point(
            parent=root, step="test"
        )

        response = self.client.get(reverse("homepage:homepage"))
        tree = self.assertHTMLValid(response)
        table = tree.xpath("//table[@id='work_request-list']")[0]
        self.assertWorkRequestRow(table.tbody.tr[0], root)
        self.assertEqual(len(table.tbody.tr), 1)

    def test_messages(self):
        """Messages from django.contrib.messages are displayed."""

        def mocked_get_context_data(self, *args, **kwargs):
            messages.error(self.request, "Error message")
            return {}

        with mock.patch(
            "debusine.web.views.views.HomepageView.get_context_data",
            autospec=True,
            side_effect=mocked_get_context_data,
        ):
            response = self.client.get(reverse("homepage:homepage"))

        tree = self.assertHTMLValid(response)
        div = tree.xpath("//div[@id='user-message-container']")[0]
        msgdiv = div.div.div
        self.assertEqual(msgdiv.get("class"), "toast")
        self.assertEqual(msgdiv.get("role"), "alert")
        self.assertEqual(msgdiv.div[1].text.strip(), "Error message")

    def test_homepage_scope_list(self):
        """Homepage lists visible scopes."""
        scope1 = self.playground.get_or_create_scope("scope1")
        self.playground.get_or_create_scope("scope2")

        with override_permission(
            Scope, "can_display", ListFilter, exclude=[scope1]
        ):
            response = self.client.get(reverse("homepage:homepage"))
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        tree = self.assertHTMLValid(response)

        ul = tree.xpath("//ul[@id='scope-list']")[0]
        self.assertEqual(
            [self.get_node_text_normalized(li) for li in ul.li],
            ["debusine", "scope2"],
        )


class LoginViewTests(TestCase):
    """Tests for the LoginView class."""

    def test_successful_login(self):
        """Successful login redirect to the homepage with authenticated user."""
        username = "testuser"
        password = "testpassword"

        get_user_model().objects.create_user(
            username=username, password=password
        )

        response = self.client.post(
            reverse("accounts:login"),
            data={"username": username, "password": password},
        )

        self.assertRedirects(response, reverse("homepage:homepage"))
        self.assertTrue(response.wsgi_request.user.is_authenticated)


class LogoutViewTests(TestCase):
    """Tests for the accounts:logout view."""

    def test_successful_render(self):
        """
        Logout template is rendered.

        The view is implemented (and tested) as part of Django. The template
        is implemented by Debusine, and it's rendered here to assert that
        no missing template variables and contains the expected strings.
        """
        response = self.client.post(reverse("accounts:logout"))

        self.assertEqual(response.status_code, status.HTTP_200_OK)


class UserTokenListViewTests(ViewTestMixin, TestCase):
    """Tests for UserTokenListView class."""

    user: ClassVar[User]

    @classmethod
    def setUpTestData(cls):
        """Set up common data."""
        super().setUpTestData()
        cls.user = get_user_model().objects.create_user(
            username="testuser",
            password="testpassword",
            email="test1@example.com",
        )

    def test_logged_no_tokens(self):
        """Logged in user with no tokens."""
        another_user = get_user_model().objects.create_user(
            username="notme", password="testpassword", email="test2@example.com"
        )
        another_token = Token.objects.create(user=another_user)

        self.client.force_login(self.user)
        response = self.client.get(reverse("user:token-list"))
        tree = self.assertHTMLValid(response)
        ul = tree.xpath("//ul[@id='navbar-right']")[0]
        self.assertTextContentEqual(ul.li.a, "testuser")

        self.assertContains(
            response,
            "You do not currently have any tokens",
        )

        create_token_url = reverse("user:token-create")
        self.assertContains(
            response,
            f'<a href="{create_token_url}">create a new token</a>',
            html=True,
        )

        # Ensure that a token for another user is not displayed
        self.assertNotContains(response, another_token.key)

    @staticmethod
    def row_for_token(token: Token) -> str:
        """Return HTML for the row of the table for token."""
        edit_url = reverse("user:token-edit", kwargs={"pk": token.pk})
        delete_url = reverse("user:token-delete", kwargs={"pk": token.pk})

        return (
            "<tr>"
            f"<td>{html_check_icon(token.enabled)}</td>"
            f"<td>{date_format(token.created_at)}</td>"
            f"<td>{token.comment}</td>"
            f'<td><a href="{delete_url}">Delete</a>&nbsp;|&nbsp;'
            f'<a href="{edit_url}">Edit</a></td>'
            "</tr>"
        )

    def test_logged_two_tokens(self):
        """
        Logged user with two tokens: both are displayed.

        Assert tokens are ordered by created_at.
        """
        token1 = Token.objects.create(user=self.user, comment="Token 1")
        token2 = Token.objects.create(user=self.user, comment="Token 2")

        self.client.force_login(self.user)
        response = self.client.get(reverse("user:token-list"))

        self.assertContains(response, self.row_for_token(token1), html=True)
        self.assertContains(response, self.row_for_token(token2), html=True)

        # Check tokens are ordered_by created_

        # token1 is displayed earlier than token2 (was created before)
        self.assertLess(
            response.content.index(token1.comment.encode("utf-8")),
            response.content.index(token2.comment.encode("utf-8")),
        )

        # Make token1 to be created_at after token2 created_at
        token1.created_at = token2.created_at + timedelta(minutes=1)
        token1.save()

        response = self.client.get(reverse("user:token-list"))

        # Tokens appear in the opposite order than before
        self.assertGreater(
            response.content.index(token1.comment.encode("utf-8")),
            response.content.index(token2.comment.encode("utf-8")),
        )

    def test_no_logged(self):
        """Request with a non-logged user: does not return any token."""
        response = self.client.get(reverse("user:token-list"))

        self.assertContains(
            response,
            "You need to be authenticated to list tokens",
            status_code=status.HTTP_403_FORBIDDEN,
            html=True,
        )


class UserTokenCreateViewTests(TestCase):
    """Tests for UserTokenCreateView."""

    user: ClassVar[User]

    @classmethod
    def setUpTestData(cls):
        """Initialize class data."""
        super().setUpTestData()
        cls.user = get_user_model().objects.create_user(
            username="testuser", password="testpass"
        )

    def test_create_token(self):
        """Post to "user:token-create" to create a token."""
        self.client.force_login(self.user)
        comment = "Test 1"
        response = self.client.post(
            reverse("user:token-create"), {"comment": comment}
        )

        # The token got created...
        token = Token.objects.get(comment=comment)

        # and has the user assigned
        self.assertEqual(token.user, self.user)

        # check the returned token matches one in the db
        token_key_re = re.compile(
            r".*<code>(?P<token>[a-z0-9]*).*</code>.*",
            re.DOTALL,
        )
        resp_key = token_key_re.match(response.content.decode()).group("token")
        self.assertEqual(token, Token.objects.get_token_or_none(resp_key))

        self.assertContains(
            response,
            _html_collapse_token(resp_key),
            status_code=status.HTTP_200_OK,
            html=True,
        )

    def test_no_logged(self):
        """Request with a non-logged user: cannot create a token."""
        response = self.client.get(reverse("user:token-create"))

        self.assertContains(
            response,
            "You need to be authenticated to create a token",
            status_code=status.HTTP_403_FORBIDDEN,
            html=True,
        )

    def test_initial_form(self):
        """Get request to ensure the form is displayed."""
        self.client.force_login(self.user)

        response = self.client.get(reverse("user:token-create"))

        self.assertContains(
            response, "<title>Debusine - Create a token</title>", html=True
        )
        self.assertContains(response, "<h1>Create token</h1>", html=True)
        self.assertContains(
            response,
            '<input class="btn btn-primary btn-sm" '
            'type="submit" value="Create">',
            html=True,
        )


class UserTokenUpdateViewTests(TestCase):
    """Tests for UserTokenCreateView when editing a token."""

    user: ClassVar[User]
    token: ClassVar[Token]

    @classmethod
    def setUpTestData(cls):
        """Initialize class data."""
        super().setUpTestData()
        cls.user = get_user_model().objects.create_user(
            username="testuser", password="testpass", email="user@example.com"
        )
        cls.token = Token.objects.create(user=cls.user, comment="A comment")

    def test_edit_token(self):
        """Get "user:token-edit" to edit a token."""
        self.client.force_login(self.user)

        enabled = True
        comment = "This is a token"

        response = self.client.post(
            reverse("user:token-edit", kwargs={"pk": self.token.pk}),
            {"comment": comment, "enabled": enabled},
        )

        self.assertRedirects(response, reverse("user:token-list"))

        self.token.refresh_from_db()

        self.assertEqual(self.token.comment, comment)
        self.assertEqual(self.token.enabled, enabled)

    def test_get_404_not_found(self):
        """Get of token/PK/ that exist but belongs to another user: 404."""
        user2 = get_user_model().objects.create(
            username="testuser2",
            password="testpass2",
            email="user2@example.com",
        )

        self.client.force_login(user2)
        response = self.client.get(
            reverse("user:token-edit", kwargs={"pk": self.token.pk})
        )

        self.assertNotContains(
            response, self.token.hash, status_code=status.HTTP_404_NOT_FOUND
        )

    def test_put_404_not_found(self):
        """Put of token/PK/ that exist but belongs to another user: 404."""
        user2 = get_user_model().objects.create(
            username="testuser2",
            password="testpass2",
            email="user2@example.com",
        )

        self.client.force_login(user2)
        comment = "This is the new comment"
        enabled = False
        response = self.client.put(
            reverse("user:token-edit", kwargs={"pk": self.token.pk}),
            {"comment": comment, "enabled": enabled},
        )

        self.assertNotContains(
            response, self.token.hash, status_code=status.HTTP_404_NOT_FOUND
        )

    def test_initial_form(self):
        """Get request to ensure the form is displayed."""
        self.client.force_login(self.user)

        response = self.client.get(
            reverse("user:token-edit", kwargs={"pk": self.token.pk})
        )

        self.assertContains(
            response, "<title>Debusine - Edit a token</title>", html=True
        )
        self.assertContains(response, "<h1>Edit token</h1>", html=True)
        self.assertContains(
            response,
            '<input class="btn btn-primary btn-sm" type="submit" value="Edit">',
            html=True,
        )
        self.assertEqual(
            response.context_data["form"]["comment"].initial, self.token.comment
        )


class UserTokenDeleteViewTests(TestCase):
    """Tests for UserTokenDeleteView."""

    user: ClassVar[User]
    token: ClassVar[Token]

    @classmethod
    def setUpTestData(cls):
        """Set up objects used in the tests."""
        super().setUpTestData()
        cls.user = get_user_model().objects.create_user(
            username="testuser", password="testpass"
        )

        cls.token = Token.objects.create(user=cls.user)

    def test_get_delete_token_authenticated_user(self):
        """Get request to delete a token. Token information is returned."""
        self.client.force_login(self.user)

        response = self.client.get(
            reverse("user:token-delete", kwargs={"pk": self.token.pk})
        )

        expected_contents = (
            "<ul>"
            f"<li>Comment: {self.token.comment}</li>"
            f"<li>Enabled: {html_check_icon(self.token.enabled)}</li>"
            f"<li>Created at: {date_format(self.token.created_at)}</li>"
            "</ul>"
        )

        self.assertContains(response, expected_contents, html=True)

    def test_post_delete_token_authenticated_user(self):
        """Post request to delete a token. The token is deleted."""
        self.client.force_login(self.user)

        response = self.client.post(
            reverse("user:token-delete", kwargs={"pk": self.token.pk})
        )

        self.assertRedirects(response, reverse("user:token-list"))

        # The token was deleted
        self.assertEqual(Token.objects.filter(pk=self.token.pk).count(), 0)

    def test_get_delete_token_non_authenticated(self):
        """
        Non authenticated Get and Post request. Return 403.

        The token is not displayed.
        """
        response = self.client.get(
            reverse("user:token-delete", kwargs={"pk": self.token.pk})
        )

        self.assertContains(
            response,
            UserTokenDeleteView.permission_denied_message,
            status_code=status.HTTP_403_FORBIDDEN,
        )
        self.assertNotContains(
            response, self.token.hash, status_code=status.HTTP_403_FORBIDDEN
        )

        response = self.client.post(
            reverse("user:token-delete", kwargs={"pk": self.token.pk})
        )

        self.assertContains(
            response,
            UserTokenDeleteView.permission_denied_message,
            status_code=status.HTTP_403_FORBIDDEN,
        )
        self.assertNotContains(
            response, self.token.hash, status_code=status.HTTP_403_FORBIDDEN
        )

    def test_get_post_delete_token_another_user(self):
        """
        Get and Post request delete token from another user. Return 404.

        The token is not deleted neither displayed.
        """
        user2 = get_user_model().objects.create_user(
            username="testuser2",
            password="testpass2",
            email="user2@example.com",
        )

        self.client.force_login(user2)

        response = self.client.get(
            reverse("user:token-delete", kwargs={"pk": self.token.pk})
        )

        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
        self.assertNotContains(
            response, self.token.hash, status_code=status.HTTP_404_NOT_FOUND
        )

        response = self.client.get(
            reverse("user:token-delete", kwargs={"pk": self.token.pk})
        )

        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
        self.assertNotContains(
            response, self.token.hash, status_code=status.HTTP_404_NOT_FOUND
        )

        self.assertEqual(Token.objects.filter(pk=self.token.pk).count(), 1)


class WidgetTests(TestCase):
    """Test widget infrastructure."""

    def test_render(self):
        """Test rendering a widget."""

        class _TestWidget(Widget):
            def render(self, _):  # noqa: U101
                return "RENDERED"

        self.assertEqual(
            template_debusine.widget(None, _TestWidget()), "RENDERED"
        )

    def test_render_error(self):
        """Test rendering a widget that raises on render."""

        class _TestWidget(Widget):
            def render(self, _):  # noqa: U101
                raise RuntimeError("ERROR")

        with override_settings(DEBUG=False):
            with self.assertLogs("debusine.web") as log:
                self.assertRegex(
                    template_debusine.widget(None, _TestWidget()),
                    "<span class='[^']+'>.+UTC: .+failed to render</span>$",
                )
            self.assertIn("failed to render", log.output[0].split("\n")[0])

        with override_settings(DEBUG=True):
            with self.assertRaises(RuntimeError) as e:
                template_debusine.widget(None, _TestWidget())
            self.assertEqual(str(e.exception), "ERROR")


class LegacyRedirectTests(TestCase):
    """Test the best effort redirects for unscoped URLs."""

    def test_scope_redirect_valid(self):
        """Test redirection of a valid legacy URL."""
        response = self.client.get("/accounts/foo/bar?baz=true")
        self.assertEqual(
            response.status_code, status.HTTP_301_MOVED_PERMANENTLY
        )
        self.assertEqual(
            response.headers["Location"],
            f"/{settings.DEBUSINE_DEFAULT_SCOPE}/accounts/foo/bar?baz=true",
        )

    def test_scope_redirect_invalid(self):
        """Test redirection of an invalid legacy URL."""
        # Misspelled url (misses trailing 's')
        response = self.client.get("/account/foo/bar?baz=true")
        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)

    def test_scope_reproduce_redirect_loop(self):
        """Reproduce a redirect loop."""
        artifact, _ = self.playground.create_artifact()

        response = self.client.get(f"/api/1.0/artifact/{artifact.pk}")
        self.assertEqual(
            response.status_code, status.HTTP_301_MOVED_PERMANENTLY
        )
        self.assertEqual(
            response.headers["Location"], f"/api/1.0/artifact/{artifact.pk}/"
        )

        response = self.client.get(
            f"/{settings.DEBUSINE_DEFAULT_SCOPE}/artifact/{artifact.pk}"
        )
        self.assertEqual(
            response.status_code, status.HTTP_301_MOVED_PERMANENTLY
        )
        self.assertEqual(
            response.headers["Location"],
            f"/{settings.DEBUSINE_DEFAULT_SCOPE}/artifact/{artifact.pk}/",
        )

    # urlconfs are created at Django startup, so changing the setting
    # afterwards has no effect: the redirect replacement pattern has already
    # been created. This test would be pointless, but keeping it here as
    # documentation. I would not subclass RedirectView only to have a more
    # dynamic replacement pattern for use only in tests.
    #
    # @override_settings(DEBUSINE_DEFAULT_SCOPE="debian")
    # def test_redirect_different_scope(self):
    #     """Redirection uses DEBUSINE_DEFAULT_SCOPE."""
    #     response = self.client.get("/accounts/foo/bar?baz=true")
    #     self.assertEqual(
    #         response.status_code, status.HTTP_301_MOVED_PERMANENTLY
    #     )
    #     self.assertEqual(
    #         response.headers["Location"],
    #         "/debian/accounts/foo/bar?baz=true",
    #     )

    def test_workspace_redirect_valid(self):
        """Test redirection of a valid legacy workspace URL."""
        response = self.client.get("/debusine/workspace/foo/bar?baz=true")
        self.assertEqual(
            response.status_code, status.HTTP_301_MOVED_PERMANENTLY
        )
        self.assertEqual(
            response.headers["Location"],
            "/debusine/foo/bar?baz=true",
        )


_sort_table_handle = '<span class="order-active">&#9660;</span>'


def _html_collapse_token(key: str) -> str:
    """Return HTML for a show/hide button for token key."""
    return f'''<button class="btn btn-primary btn-sm" type="button"
        data-bs-toggle="collapse"
            data-bs-target="#{key}Div">
        Show / Hide Token
        </button>
        <div class="collapse" id="{key}Div">
            <code>{key}</code>
        </div>
    '''
