// Copyright 2012 Google Inc. All Rights Reserved.
//
// Licensed 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 com.google.collide.server.participants;

import com.google.collide.dto.server.DtoServerImpls.GetWorkspaceParticipantsResponseImpl;
import com.google.collide.dto.server.DtoServerImpls.ParticipantImpl;
import com.google.collide.dto.server.DtoServerImpls.ParticipantUserDetailsImpl;
import com.google.collide.dto.server.DtoServerImpls.UserDetailsImpl;
import com.google.collide.server.shared.BusModBase;
import com.google.collide.server.shared.util.Dto;
import io.vertx.core.eventbus.Message;
import io.vertx.core.json.JsonObject;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.UUID;

/**
 * Acts as the authentication provider, with a compatible API subset with the bundled default
 * AuthManager that comes with Vertx.
 *
 * This one however is in-memory, and also has affordances for broadcasting to joined participants.
 * Also, this implementation allows for a single username to be logged in as multiple different
 * sessions.
 */
public class Participants extends BusModBase {

  public static final String CLIENT_ADDRESS_PREFX = "client";

  public static final String PAYLOAD_TAG = "payload";
  public static final String OMIT_SENDER_TAG = "omitSender";
  public static final String TARGET_SPECIFIC_CLIENT_TAG = "sendToClient";
  public static final String TARGET_USERS_TABS_TAG = "sendToUsersTabs";

  private static final long DEFAULT_LOGIN_TIMEOUT = 60 * 60 * 1000; // 1 hour

  // TODO: This is temporarily set to 30 mins for testing.
  private static final long DEFAULT_KEEP_ALIVE_TIMEOUT = 30 * 1000 * 60; // 30 secs

  /**
   * A single Collide tab for a logged in user that is connected to the eventbus.
   */
  private static final class ConnectedTab {
    final LoggedInUser loginInfo;
    long timerId;

    ConnectedTab(LoggedInUser loginInfo, long tabDisconnectTimerId) {
      this.loginInfo = loginInfo;
      this.timerId = tabDisconnectTimerId;
    }
  }

  /**
   * A logged in user that may have multiple tabs open.
   */
  private static final class LoggedInUser {
    final String username;

    /** Stable user ID for the lifetime of the server. */
    final String userId;
    long timerId;

    private LoggedInUser(String username) {
      this.username = username;
      this.userId = getStableUserId(username);
    }

    /** Stable identifier for a username. Stable for the lifetime of the server. */
    static final Map<String, String> usernameToStableIdMap = new HashMap<String, String>();
    static String getStableUserId(String username) {
      String stableId = usernameToStableIdMap.get(username);
      if (stableId == null) {
        stableId = UUID.randomUUID().toString();
        usernameToStableIdMap.put(username, stableId);
      }
      return stableId;
    }
  }

  private String password;
  private long tabKeepAliveTimeout;
  private long loginSessionTimeout;

  /** Map of per-tab active client IDs to ConnectedTabs. */
  protected final Map<String, ConnectedTab> connectedTabs = new HashMap<String, ConnectedTab>();

  /** Map of per-user session IDs LoggedInUsers. */
  protected final Map<String, LoggedInUser> loggedInUsers = new HashMap<String, LoggedInUser>();

  @Override
  public void start() {
    super.start();

    this.password = getOptionalStringConfig("password", "");
    this.loginSessionTimeout = getOptionalLongConfig("session_timeout", DEFAULT_LOGIN_TIMEOUT);
    this.tabKeepAliveTimeout = getOptionalLongConfig("keep_alive_timeout", DEFAULT_KEEP_ALIVE_TIMEOUT);
    String addressBase = getOptionalStringConfig("address", "participants");

    eb.consumer(addressBase + ".login", this::doLogin);

    eb.consumer(addressBase + ".logout", this::doLogout);

    eb.consumer(addressBase + ".authorise", this::doAuthorise);

    eb.consumer(addressBase + ".keepAlive", this::doKeepAlive);

    eb.consumer(addressBase + ".getParticipants", this::doGetParticipants);

    eb.consumer(addressBase + ".broadcast", this::doBroadcast);

    eb.consumer(addressBase + ".sendTo", this::doSendTo);
  }

  void doBroadcast(Message<JsonObject> event) {
    String payload = event.body().getString(PAYLOAD_TAG);
    String senderActiveClientId = event.body().getString(OMIT_SENDER_TAG);
    Set<Entry<String, ConnectedTab>> entries = connectedTabs.entrySet();
    for (Entry<String, ConnectedTab> entry : entries) {
      String activeClientId = entry.getKey();
      String address = CLIENT_ADDRESS_PREFX + "." + activeClientId;

      // Send to everyone except the optionally specified sender that we wish to ignore.
      if (!activeClientId.equals(senderActiveClientId)) {
        vertx.eventBus().send(address, Dto.wrap(payload));
      }
    }
  }

  void doSendTo(Message<JsonObject> event) {
    String payload = event.body().getString(PAYLOAD_TAG);

    List<String> clientsToMessage = new ArrayList<String>();
    String activeClientId = event.body().getString(TARGET_SPECIFIC_CLIENT_TAG);
    if (activeClientId != null) {

      // Send to a specific tab.
      ConnectedTab tab = connectedTabs.get(activeClientId);
      if (tab != null) {
        clientsToMessage.add(activeClientId);
      }
    } else {
      String username = event.body().getString(TARGET_USERS_TABS_TAG);
      if (username != null) {

        // Collect the ids of all this user's open tabs.
        Set<Entry<String, ConnectedTab>> allClients = connectedTabs.entrySet();
        for (Entry<String, ConnectedTab> entry : allClients) {
          if (username.equals(entry.getValue().loginInfo.username)) {
            clientsToMessage.add(entry.getKey());
          }
        }
      }
    }

    // Message the clients.
    for (String cid :  clientsToMessage) {
      vertx.eventBus().send(CLIENT_ADDRESS_PREFX + "." + cid, Dto.wrap(payload));
    }
  }

  /**
   * Returns all the connected tabs, as well as the user information for the user that owns each
   * tab.
   */
  void doGetParticipants(Message<JsonObject> event) {
    GetWorkspaceParticipantsResponseImpl resp = GetWorkspaceParticipantsResponseImpl.make();
    List<ParticipantUserDetailsImpl> collaboratorsArr = new ArrayList<ParticipantUserDetailsImpl>();

    Set<Entry<String, ConnectedTab>> collaborators = connectedTabs.entrySet();
    for (Entry<String, ConnectedTab> entry : collaborators) {
      String userId = entry.getValue().loginInfo.userId;
      String username = entry.getValue().loginInfo.username;
      ParticipantUserDetailsImpl participantDetails = ParticipantUserDetailsImpl.make();
      ParticipantImpl participant = ParticipantImpl.make().setId(entry.getKey()).setUserId(userId);
      UserDetailsImpl userDetails = UserDetailsImpl.make()
          .setUserId(userId).setDisplayEmail(username).setDisplayName(username)
          .setGivenName(username);

      participantDetails.setParticipant(participant);
      participantDetails.setUserDetails(userDetails);
      collaboratorsArr.add(participantDetails);
    }

    resp.setParticipants(collaboratorsArr);
    event.reply(Dto.wrap(resp));
  }

  void doKeepAlive(Message<JsonObject> event) {
    final String activeClientId = event.body().getString("activeClient");
    if (activeClientId != null) {
      ConnectedTab loginInfo = connectedTabs.get(activeClientId);
      if (loginInfo != null) {
        vertx.cancelTimer(loginInfo.timerId);
        loginInfo.timerId = vertx.setTimer(tabKeepAliveTimeout,
            id-> connectedTabs.remove(activeClientId));
      }
    }
  }

  void doLogin(final Message<JsonObject> message) {
    System.out.println("Doing login: " + message.body());
    final String username = message.body().getString("username", null);
    if (username == null) {
      sendStatus("denied", message);
      return;
    }
    String password = message.body().getString("password", null);
    if (password == null && !"".equals(this.password)) {
      sendStatus("denied", message, new JsonObject().put("reason", "needs-pass"));
      return;
    }

    if(!authenticate(username, password)) {
      sendStatus("denied", message);
      return;
    }

    // Passed authentication. Create a logged in user and a timer to expire his session.
    if (alreadyLoggedIn(username)) {
      // Cancel the previous session logout timer.
      LoggedInUser existing = loggedInUsers.remove(LoggedInUser.getStableUserId(username));
      vertx.cancelTimer(existing.timerId);
    }

    final LoggedInUser user = new LoggedInUser(username);
    loggedInUsers.put(user.userId, user);
    user.timerId = vertx.setTimer(loginSessionTimeout, e->logout(user.userId));

    // The spelling "sessionID" is needed to work with the vertx eventbus bridge whitelist.
    JsonObject jsonReply = new JsonObject().put("sessionID", user.userId);
    sendOK(message, jsonReply);
  }

  /**
   * This is NOT the same as authenticating. This just checks to see if we are tracking a login
   * session for this username already.
   */
  private boolean alreadyLoggedIn(String username) {
    return loggedInUsers.get(LoggedInUser.getStableUserId(username)) != null;
  }

  private String createActiveTab(LoggedInUser user) {
    final String activeClient = UUID.randomUUID().toString();
    long timerId = vertx.setTimer(tabKeepAliveTimeout, id-> connectedTabs.remove(activeClient));
    connectedTabs.put(activeClient, new ConnectedTab(user, timerId));
    return activeClient;
  }

  private boolean authenticate(String username, String password) {
    return "".equals(this.password) || password.equals(this.password);
  }

  void doLogout(final Message<JsonObject> message) {
    final String sessionID = getMandatoryString("sessionID", message);
    if (sessionID != null) {
      if (logout(sessionID)) {
        sendOK(message);
      } else {
        super.sendError(message, "Not logged in");
      }
    }
  }

  private boolean logout(String userId) {
    LoggedInUser user = loggedInUsers.remove(userId);
    if (user != null) {
      List<Entry<String, ConnectedTab>> usersTabs = new ArrayList<Entry<String, ConnectedTab>>();
      Set<Entry<String, ConnectedTab>> entries = connectedTabs.entrySet();
      for (Entry<String, ConnectedTab> entry : entries) {
        if (userId.equals(entry.getValue().loginInfo.userId)) {
          usersTabs.add(entry);
        }
      }

      for (int i=0;i<usersTabs.size();i++) {
        connectedTabs.remove(usersTabs.get(i).getKey());
        vertx.cancelTimer(usersTabs.get(i).getValue().timerId);
      }
      return true;
    } else {
      return false;
    }
  }

  void doAuthorise(Message<JsonObject> message) {
    String userId = getMandatoryString("sessionID", message);
    if (userId == null) {
      sendStatus("denied", message);
      return;
    }

    LoggedInUser user = loggedInUsers.get(userId);
    if (user != null || "".equals(password)) {
      String username = message.body().getString("username", "anonymous");
      if (user != null) {
        username = user.username;
      } else {
        user = new LoggedInUser(username);
      }
      JsonObject reply = new JsonObject().put("username", username);
      if (message.body().getBoolean("createClient", false)) {
        String activeClient = createActiveTab(user);
        reply.put("activeClient", activeClient);
      }
      sendOK(message, reply);
    } else {
      sendStatus("denied", message);
    }
  }
}
