# copyright 2003-2023 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
# contact https://www.logilab.fr/ -- mailto:contact@logilab.fr
#
# This file is part of CubicWeb.
#
# CubicWeb is free software: you can redistribute it and/or modify it under the
# terms of the GNU Lesser General Public License as published by the Free
# Software Foundation, either version 2.1 of the License, or (at your option)
# any later version.
#
# CubicWeb is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for more
# details.
#
# You should have received a copy of the GNU Lesser General Public License along
# with CubicWeb.  If not, see <https://www.gnu.org/licenses/>.
"""Set of base controllers, which are directly plugged into the application
object to handle publication.
"""

import http.client
import logging

from cubicweb import (
    NoSelectableObject,
    ObjectNotFound,
    ValidationError,
    AuthenticationError,
    UndoTransactionException,
    Forbidden,
    rdf,
)
from cubicweb._exceptions import NotAnEntity
from cubicweb.predicates import authenticated_user, anonymous_user, match_form_params
from cubicweb.utils import json_dumps
from cubicweb_web import Redirect, NotFound
from cubicweb_web.controller import Controller
from cubicweb_web.views import vid_from_rset

logger = logging.getLogger(__name__)


class LoginController(Controller):
    __regid__ = "login"
    __select__ = anonymous_user()

    def publish(self, rset=None):
        """log in the instance"""
        if self._cw.vreg.config["auth-mode"] == "http":
            # HTTP authentication
            raise AuthenticationError()
        else:
            # Cookie authentication
            self._cw.status_out = http.client.FORBIDDEN
            return self.appli.need_login_content(self._cw)


class LoginControllerForAuthed(Controller):
    __regid__ = "login"
    __select__ = ~anonymous_user()

    def publish(self, rset=None):
        """log in the instance"""
        path = self._cw.form.get("postlogin_path", "")
        # Redirect expects a URL, not a path. Also path may contain a query
        # string, hence should not be given to _cw.build_url()
        raise Redirect(self._cw.base_url() + path)


class LogoutController(Controller):
    __regid__ = "logout"

    def publish(self, rset=None):
        """logout from the instance"""
        return self.appli.session_handler.logout(self._cw, self.goto_url())

    def goto_url(self):
        # * in http auth mode, url will be ignored
        # * in cookie mode redirecting to the index view is enough : either
        #   anonymous connection is allowed and the page will be displayed or
        #   we'll be redirected to the login form
        return self._cw.build_url("view", vid="loggedout")


class ViewController(Controller):
    """standard entry point :
    - build result set
    - select and call main template
    """

    __regid__ = "view"
    template = "main-template"

    def publish(self, rset=None):
        """publish a request, returning an encoded string.

        :raise NotFound: if the rql has been rewritten (rewrite rules) and the
        resulting rset is empty"""
        view, rset = self._select_view_and_rset(rset)
        if (
            not rset
            and getattr(self._cw, "_rql_rewritten", False)
            and self._cw.form.get("empty_rset_raises_404", False)
        ):
            raise NotFound

        if hasattr(self._cw, "path"):
            logger.info(
                f"on {self._cw.path} controller {self} selected view {view} for rset"
                f" {rset}"
            )

        view.set_http_cache_headers()
        if self._cw.is_client_cache_valid():
            return b""

        template = self.appli.main_template_id(self._cw)
        return self._cw.vreg["views"].main_template(
            self._cw, template, rset=rset, view=view
        )

    def _select_view_and_rset(self, rset):
        req = self._cw
        if rset is None and not hasattr(req, "_rql_processed"):
            req._rql_processed = True
            if req.cnx:
                rset = self.process_rql()
            else:
                rset = None
        vid = req.form.get("vid") or vid_from_rset(req, rset, self._cw.vreg.schema)
        if rset and len(rset) == 1:
            for mimetype in rdf.RDF_MIMETYPE_TO_FORMAT:
                try:
                    req.headers_out.addRawHeader(
                        "Link",
                        "<%s>;rel=alternate;type=%s"
                        % (f"{self._cw.base_url()}{rset.one().rest_path()}", mimetype),
                    )
                except NotAnEntity:
                    # was a string, or a number (but not an eid)
                    break
        try:
            view = self._cw.vreg["views"].select(vid, req, rset=rset)
        except ObjectNotFound:
            self.warning("the view %s could not be found", vid)
            req.set_message(req._("The view %s could not be found") % vid)
            vid = vid_from_rset(req, rset, self._cw.vreg.schema)
            view = self._cw.vreg["views"].select(vid, req, rset=rset)
        except NoSelectableObject:
            if rset:
                req.set_message(
                    req._("The view %s can not be applied to this query") % vid
                )
            else:
                req.set_message(
                    req._(
                        "You have no access to this view or it can not "
                        "be used to display the current data."
                    )
                )
            vid = req.form.get("fallbackvid") or vid_from_rset(
                req, rset, req.vreg.schema
            )
            view = req.vreg["views"].select(vid, req, rset=rset)
        return view, rset

    def execute_linkto(self, eid=None):
        """XXX __linkto parameter may cause security issue

        defined here since custom application controller inheriting from this
        one use this method?
        """
        req = self._cw
        if "__linkto" not in req.form:
            return
        if eid is None:
            eid = int(req.form["eid"])
        for linkto in req.list_form_param("__linkto", pop=True):
            rtype, eids, target = linkto.split(":")
            assert target in ("subject", "object")
            eids = eids.split("_")
            if target == "subject":
                rql = "SET X %s Y WHERE X eid %%(x)s, Y eid %%(y)s" % rtype
            else:
                rql = "SET Y %s X WHERE X eid %%(x)s, Y eid %%(y)s" % rtype
            for teid in eids:
                req.execute(rql, {"x": eid, "y": int(teid)})


def _validation_error(req, ex):
    req.cnx.rollback()
    ex.translate(req._)  # translate messages using ui language
    # XXX necessary to remove existant validation error?
    # imo (syt), it's not necessary
    req.session.data.pop(req.form.get("__errorurl"), None)
    foreid = ex.entity
    eidmap = req.data.get("eidmap", {})
    for var, eid in eidmap.items():
        if foreid == eid:
            foreid = var
            break
    return (foreid, ex.errors)


def _validate_form(req, vreg):
    """
    helper function for FormValidatorController.publish()

    return (status, args, entity) that will be passed as argument
    result to `cubicweb.edition.js:handleFormValidationResult(..., result, ...)`

    status: bool True if valid else False
    args: target location if status is True else error message
    entity: entity being edited
    """
    # XXX should use the `RemoteCallFailed` mechanism
    try:
        ctrl = vreg["controllers"].select("edit", req=req)
    except NoSelectableObject:
        return (False, {None: req._("not authorized")}, None)
    try:
        ctrl.publish(None)
    except ValidationError as ex:
        return (False, _validation_error(req, ex), ctrl._edited_entity)
    except Redirect as ex:
        try:
            txuuid = req.cnx.commit()  # ValidationError may be raised on commit
        except ValidationError as ex:
            return (False, _validation_error(req, ex), ctrl._edited_entity)
        except Exception as ex:
            req.cnx.rollback()
            req.exception("unexpected error while validating form")
            return (False, str(ex), ctrl._edited_entity)
        else:
            if txuuid is not None:
                req.data["last_undoable_transaction"] = txuuid
            # complete entity: it can be used in js callbacks where we might
            # want every possible information
            if ctrl._edited_entity:
                ctrl._edited_entity.complete()
            return (True, ex.location, ctrl._edited_entity)
    except Exception as ex:
        req.cnx.rollback()
        req.exception("unexpected error while validating form")
        return (False, str(ex), ctrl._edited_entity)
    return (False, "???", None)


class FormValidatorController(Controller):
    __regid__ = "validateform"

    def response(self, domid, status, args, entity):
        callback = str(self._cw.form.get("__onsuccess", "null"))
        errback = str(self._cw.form.get("__onfailure", "null"))
        cbargs = str(self._cw.form.get("__cbargs", "null"))
        self._cw.set_content_type("text/html")
        jsargs = json_dumps((status, args, entity))
        return """<script type="text/javascript">
 window.parent.handleFormValidationResponse('%s', %s, %s, %s, %s);
</script>""" % (
            domid,
            callback,
            errback,
            jsargs,
            cbargs,
        )

    def publish(self, rset=None):
        self._cw.ajax_request = True
        # XXX unclear why we have a separated controller here vs
        # js_validate_form on the json controller
        status, args, entity = _validate_form(self._cw, self._cw.vreg)
        domid = self._cw.form.get("__domid", "entityForm")
        return self.response(domid, status, args, entity).encode(self._cw.encoding)


class MailBugReportController(Controller):
    __regid__ = "reportbug"
    __select__ = match_form_params("description")

    def publish(self, rset=None):
        req = self._cw
        desc = req.form["description"]
        # The description is generated and signed by cubicweb itself, check
        # description's signature so we don't want to send spam here
        sign = req.form.get("__signature", "")
        if not (sign and req.vreg.config.check_text_sign(desc, sign)):
            raise Forbidden("Invalid content")
        self.sendmail(
            req.vreg.config["submit-mail"],
            req._("%s error report") % req.vreg.config.appid,
            desc,
        )
        raise Redirect(req.build_url(__message=req._("bug report sent")))


class UndoController(Controller):
    __regid__ = "undo"
    __select__ = authenticated_user() & match_form_params("txuuid")

    def publish(self, rset=None):
        txuuid = self._cw.form["txuuid"]
        try:
            self._cw.cnx.undo_transaction(txuuid)
        except UndoTransactionException as exc:
            errors = exc.errors
            # This will cause a rollback in main_publish
            raise ValidationError(None, {None: "\n".join(errors)})
        else:
            self.redirect()  # Will raise Redirect

    def redirect(self, msg=None):
        req = self._cw
        msg = msg or req._("transaction undone")
        self._redirect({"_cwmsgid": req.set_redirect_message(msg)})
