/*
 *  Licensed to the Apache Software Foundation (ASF) under one
 *  or more contributor license agreements.  See the NOTICE file
 *  distributed with this work for additional information
 *  regarding copyright ownership.  The ASF licenses this file
 *  to you 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.
 */
package org.apache.causeway.viewer.wicket.viewer.integration;

import java.util.Optional;
import java.util.UUID;
import java.util.function.UnaryOperator;

import org.apache.wicket.Session;
import org.apache.wicket.authroles.authentication.AuthenticatedWebSession;
import org.apache.wicket.authroles.authorization.strategies.role.Roles;
import org.apache.wicket.request.Request;
import org.apache.wicket.request.cycle.RequestCycle;

import org.jspecify.annotations.Nullable;

import org.apache.causeway.applib.clock.VirtualClock;
import org.apache.causeway.applib.services.clock.ClockService;
import org.apache.causeway.applib.services.iactnlayer.InteractionContext;
import org.apache.causeway.applib.services.session.SessionSubscriber;
import org.apache.causeway.applib.services.user.UserMemento;
import org.apache.causeway.applib.services.user.UserMemento.AuthenticationSource;
import org.apache.causeway.applib.services.user.UserService;
import org.apache.causeway.commons.collections.Can;
import org.apache.causeway.commons.internal.assertions._Assert;
import org.apache.causeway.core.metamodel.context.HasMetaModelContext;
import org.apache.causeway.core.security.authentication.AuthenticationRequestPassword;
import org.apache.causeway.core.security.authentication.manager.AuthenticationManager;
import org.apache.causeway.viewer.wicket.model.causeway.HasAmendableInteractionContext;
import org.apache.causeway.viewer.wicket.model.models.BookmarkedPagesModel;
import org.apache.causeway.viewer.wicket.ui.components.widgets.breadcrumbs.BreadcrumbModel;
import org.apache.causeway.viewer.wicket.ui.components.widgets.breadcrumbs.BreadcrumbModelProvider;
import org.apache.causeway.viewer.wicket.ui.pages.BookmarkedPagesModelProvider;

import lombok.Getter;
import org.jspecify.annotations.NonNull;
import lombok.extern.log4j.Log4j2;

/**
 * Viewer-specific implementation of {@link AuthenticatedWebSession}, which
 * delegates to the Causeway' configured {@link AuthenticationManager}, and which
 * also tracks thread usage (so that multiple concurrent requests are all
 * associated with the same session).
 */
@Log4j2
public class AuthenticatedWebSessionForCauseway
extends AuthenticatedWebSession
implements
    BreadcrumbModelProvider,
    BookmarkedPagesModelProvider,
    HasMetaModelContext,
    HasAmendableInteractionContext {

    private static final long serialVersionUID = 1L;

    public static AuthenticatedWebSessionForCauseway get() {
        return (AuthenticatedWebSessionForCauseway) Session.get();
    }

    /**
     * lazily populated in {@link #getBreadcrumbModel()}
     */
    private BreadcrumbModel breadcrumbModel;
    @Override
    public BreadcrumbModel getBreadcrumbModel() {
        return breadcrumbModel != null
                ? breadcrumbModel
                : (breadcrumbModel = new BreadcrumbModel());
    }

    /**
     * lazily populated in {@link #getBookmarkedPagesModel()}
     */
    private BookmarkedPagesModel bookmarkedPagesModel;
    @Override
    public BookmarkedPagesModel getBookmarkedPagesModel() {
        return bookmarkedPagesModel != null
                ? bookmarkedPagesModel
                : (bookmarkedPagesModel = new BookmarkedPagesModel());
    }

    /**
     * As populated in {@link #signIn(String, String)}.
     */
    private InteractionContext interactionContext;
    private void setInteractionContext(final @Nullable InteractionContext interactionContext) {
        _Assert.assertFalse(
                interactionContext !=null
                 && interactionContext.getUser().isImpersonating(), ()->
                "framework bug: cannot signin with an impersonated user");
        this.interactionContext = interactionContext;
    }

    /**
     * If there is an {@link InteractionContext} already (primed)
     * (as some authentication mechanisms setup in filters,
     * eg SpringSecurityFilter), then just use it.
     * <p>
     * However, for authorization, the authentication still must pass
     * {@link AuthenticationManager} checks,
     * as done in {@link #getInteractionContext()},
     * which on success also sets the signIn flag.
     * <p>
     * Called by {@link WebRequestCycleForCauseway}.
     */
    public void setPrimedInteractionContext(final @NonNull InteractionContext authentication) {
        this.interactionContext = authentication;
    }

    @Getter
    private UUID sessionGuid;
    private String cachedSessionId;

    /**
     * Optionally the current HttpSession's Id,
     * based on whether such a session is available.
     * @implNote side-effect free, that is,
     * must not create a session if there is none yet
     */
    public Optional<String> getCachedSessionId() {
        if (cachedSessionId == null
                && Session.exists()) {
            cachedSessionId = getId();
        }
        return Optional.ofNullable(cachedSessionId);
    }

    public AuthenticatedWebSessionForCauseway(final Request request) {
        super(request);
        sessionGuid = UUID.randomUUID();
    }

    @Override
    public synchronized boolean authenticate(final String username, final String password) {
        var authenticationRequest = new AuthenticationRequestPassword(username, password);
        authenticationRequest.addRole(UserMemento.AUTHORIZED_USER_ROLE);
        InteractionContext interactionContext = getAuthenticationManager().authenticate(authenticationRequest);
        setInteractionContext(interactionContext);
        if (interactionContext != null) {
            log(SessionSubscriber.Type.LOGIN, username, null);
            return true;
        } else {
            return false;
        }
    }

    @Override
    public synchronized void invalidateNow() {

        //
        // similar code in Restful Objects viewer (UserResourceServerside#logout)
        //
        // this needs to be done here because Wicket will expire the HTTP session
        // while the Shiro authenticator uses the session to obtain the details of the
        // principals for it to logout
        //

        getAuthenticationManager().closeSession(
                Optional.ofNullable(interactionContext)
                .map(InteractionContext::getUser)
                .orElse(null));

        super.invalidateNow();

    }

    @Override
    public synchronized void onInvalidate() {

        String userName = null;
        var authentication = getInteractionContext();
        if (authentication != null) {
            userName = authentication.getUser().name();
        }

        super.onInvalidate();

        var causedBy = RequestCycle.get() != null
                ? SessionSubscriber.CausedBy.USER
                : SessionSubscriber.CausedBy.SESSION_EXPIRATION;

        log(SessionSubscriber.Type.LOGOUT, userName, causedBy);
    }

    @Override
    public void amendInteractionContext(final UnaryOperator<InteractionContext> updater) {
        setInteractionContext(updater.apply(interactionContext));
    }

    /**
     * Returns an {@link InteractionContext} either as authenticated (and then cached on the session subsequently),
     * or taking into account {@link UserService impersonation}.
     * <p>
     * The session must still {@link AuthenticationManager#isSessionValid(InteractionContext) be valid}, though
     * note that this will always be true for externally authenticated users.
     */
     synchronized InteractionContext getInteractionContext() {

        if(interactionContext == null) {
            return null;
        }
        if (Optional.ofNullable(getMetaModelContext())
                .map(HasMetaModelContext::getAuthenticationManager)
                .filter(x -> x.isSessionValid(interactionContext))
                .isEmpty()) {
            return null;
        }
        signIn(true);

        return interactionContext;
    }

    @Override
    public AuthenticationManager getAuthenticationManager() {
        return Optional.ofNullable(getMetaModelContext())
                .map(HasMetaModelContext::getAuthenticationManager)
                .orElse(null);
    }

    /**
     * This is a no-op if the {@link #getInteractionContext() authentication session}'s
     * {@link UserMemento#authenticationSource() source} is
     * {@link AuthenticationSource#EXTERNAL external}
     * (eg as managed by keycloak).
     */
    @Override
    public void invalidate() {
        if(interactionContext !=null
                && interactionContext.getUser().authenticationSource().isExternal()) {
            return;
        }
        // otherwise
        super.invalidate();
    }

    @Override
    public synchronized Roles getRoles() {
        if (!isSignedIn()) {
            return null;
        }
        return getInteractionService()
            .currentInteractionContext()
            .map(InteractionContext::getUser)
            .map(user->{
                var roles = new Roles();
                user.streamRoleNames()
                .forEach(roles::add);
                return roles;
            })
            .orElse(null);
    }

    @Override
    public synchronized void detach() {
        if(breadcrumbModel!=null) {
            breadcrumbModel.detach();
        }
        if(bookmarkedPagesModel!=null) {
            bookmarkedPagesModel.detach();
        }
        super.detach();
    }

    @Override
    public void replaceSession() {
        // do nothing here because this will lead to problems with Shiro
        // see https://issues.apache.org/jira/browse/CAUSEWAY-1018
    }

    private void log(
            final SessionSubscriber.Type type,
            final String username,
            final SessionSubscriber.CausedBy causedBy) {

        if(getMetaModelContext()==null) {
            log.warn("Failed to callback SessionLoggingServices due to unavailable MetaModelContext.\n"
                    + "\tEvent Data: type={}, username={}, causedBy={}", type, username, causedBy);
            return;
        }

        var interactionService = getInteractionService();
        var sessionLoggingServices = getSessionLoggingServices();

        final Runnable loggingTask = ()->{

            var now = virtualClock().nowAsJavaUtilDate();
            var httpSessionId = AuthenticatedWebSessionForCauseway.this.getCachedSessionId()
                    .orElse("(none)");

            sessionLoggingServices
            .forEach(sessionLoggingService ->
                sessionLoggingService.log(type, username, now, causedBy, getSessionGuid(), httpSessionId));
        };

        if(interactionService!=null) {
            interactionService.runAnonymous(loggingTask::run);
        } else {
            loggingTask.run();
        }
    }

    protected Can<SessionSubscriber> getSessionLoggingServices() {
        return getServiceRegistry().select(SessionSubscriber.class);
    }

    private VirtualClock virtualClock() {
        try {
            return getServiceRegistry()
                    .lookupService(ClockService.class)
                    .map(ClockService::getClock)
                    .orElseGet(this::nowFallback);
        } catch (Exception e) {
            return nowFallback();
        }
    }

    private VirtualClock nowFallback() {
        return VirtualClock.system();
    }

}
