import {ApiError} from 'app/common/ApiError';
import {OpenDocMode} from 'app/common/DocListAPI';
import {ErrorWithCode} from 'app/common/ErrorWithCode';
import {FullUser, UserProfile} from 'app/common/LoginSessionAPI';
import {canEdit, canView, getWeakestRole, Role} from 'app/common/roles';
import {Document} from 'app/gen-server/entity/Document';
import {User} from 'app/gen-server/entity/User';
import {DocAuthKey, DocAuthResult, HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
import {getSessionProfiles, getSessionUser, getSignInStatus, linkOrgWithEmail, SessionObj,
        SessionUserObj, SignInStatus} from 'app/server/lib/BrowserSession';
import {RequestWithOrg} from 'app/server/lib/extractOrg';
import {COOKIE_MAX_AGE, getAllowedOrgForSessionID, getCookieDomain,
        cookieName as sessionCookieName} from 'app/server/lib/gristSessions';
import * as log from 'app/server/lib/log';
import {IPermitStore, Permit} from 'app/server/lib/Permit';
import {allowHost, optStringParam} from 'app/server/lib/requestUtils';
import * as cookie from 'cookie';
import {NextFunction, Request, RequestHandler, Response} from 'express';
import * as onHeaders from 'on-headers';

export interface RequestWithLogin extends Request {
  sessionID: string;
  session: SessionObj;
  org?: string;
  isCustomHost?: boolean;  // when set, the request's domain is a recognized custom host linked
                           // with the specified org.
  users?: UserProfile[];
  userId?: number;
  user?: User;
  userIsAuthorized?: boolean;   // If userId is for "anonymous", this will be false.
  docAuth?: DocAuthResult;      // For doc requests, the docId and the user's access level.
  specialPermit?: Permit;
}

/**
 * Extract the user id from a request, assuming we've added it via appropriate middleware.
 * Throws ApiError with code 401 (unauthorized) if the user id is missing.
 */
export function getUserId(req: Request): number {
  const userId = (req as RequestWithLogin).userId;
  if (!userId) {
    throw new ApiError("user not known", 401);
  }
  return userId;
}

/**
 * Extract the user object from a request, assuming we've added it via appropriate middleware.
 * Throws ApiError with code 401 (unauthorized) if the user is missing.
 */
export function getUser(req: Request): User {
  const user = (req as RequestWithLogin).user;
  if (!user) {
    throw new ApiError("user not known", 401);
  }
  return user;
}

/**
 * Extract the user profiles from a request, assuming we've added them via appropriate middleware.
 * Throws ApiError with code 401 (unauthorized) if the profiles are missing.
 */
export function getUserProfiles(req: Request): UserProfile[] {
  const users = (req as RequestWithLogin).users;
  if (!users) {
    throw new ApiError("user profile not found", 401);
  }
  return users;
}

// Extract the user id from a request, requiring it to be authorized (not an anonymous session).
export function getAuthorizedUserId(req: Request) {
  const userId = getUserId(req);
  if (isAnonymousUser(req)) {
    throw new ApiError("user not authorized", 401);
  }
  return userId;
}

export function isAnonymousUser(req: Request) {
  return !(req as RequestWithLogin).userIsAuthorized;
}

// True if Grist is configured for a single user without specific authorization
// (classic standalone/electron mode).
export function isSingleUserMode(): boolean {
  return process.env.GRIST_SINGLE_USER === '1';
}

/**
 * Returns the express request object with user information added, if it can be
 * found based on passed in headers or the session.  Specifically, sets:
 *   - req.userId: the id of the user in the database users table
 *   - req.userIsAuthorized: set if user has presented credentials that were accepted
 *     (the anonymous user has a userId but does not have userIsAuthorized set if,
 *     as would typically be the case, credentials were not presented)
 *   - req.users: set for org-and-session-based logins, with list of profiles in session
 */
export async function addRequestUser(dbManager: HomeDBManager, permitStore: IPermitStore,
                                     req: Request, res: Response, next: NextFunction) {
  const mreq = req as RequestWithLogin;
  let profile: UserProfile|undefined;

  // First, check for an apiKey
  if (mreq.headers && mreq.headers.authorization) {
    // header needs to be of form "Bearer XXXXXXXXX" to apply
    const parts = String(mreq.headers.authorization).split(' ');
    if (parts[0] === "Bearer") {
      const user = parts[1] ? await dbManager.getUserByKey(parts[1]) : undefined;
      if (!user) {
        return res.status(401).send('Bad request: invalid API key');
      }
      if (user.id === dbManager.getAnonymousUserId()) {
        // We forbid the anonymous user to present an api key.  That saves us
        // having to think through the consequences of authorized access to the
        // anonymous user's profile via the api (e.g. how should the api key be managed).
        return res.status(401).send('Credentials cannot be presented for the anonymous user account via API key');
      }
      mreq.user = user;
      mreq.userId = user.id;
      mreq.userIsAuthorized = true;
    }
  }

  // Special permission header for internal housekeeping tasks
  if (mreq.headers && mreq.headers.permit) {
    const permitKey = String(mreq.headers.permit);
    try {
      const permit = await permitStore.getPermit(permitKey);
      if (!permit) { return res.status(401).send('Bad request: unknown permit'); }
      mreq.user = dbManager.getAnonymousUser();
      mreq.userId = mreq.user.id;
      mreq.specialPermit = permit;
    } catch (err) {
      log.error(`problem reading permit: ${err}`);
      return res.status(401).send('Bad request: permit could not be read');
    }
  }

  // If we haven't already been authenticated, and this is not a GET/HEAD/OPTIONS, then
  // require that the X-Requested-With header field be set to XMLHttpRequest.
  // This is trivial for legitimate web clients to do, and an obstacle to
  // nefarious ones.
  //   https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#use-of-custom-request-headers
  //   https://markitzeroday.com/x-requested-with/cors/2017/06/29/csrf-mitigation-for-ajax-requests.html
  if (!mreq.userId && !mreq.xhr && !['GET', 'HEAD', 'OPTIONS'].includes(mreq.method)) {
    return res.status(401).send('Bad request (missing header)');
  }

  // A bit of extra info we'll add to the "Auth" log message when this request passes the check
  // for custom-host-specific sessionID.
  let customHostSession = '';

  // If we haven't selected a user by other means, and have profiles available in the
  // session, then select a user based on those profiles.
  const session = mreq.session;
  if (!mreq.userId && session && session.users && session.users.length > 0 &&
     mreq.org !== undefined) {

    // Prevent using custom-domain sessionID to authorize to a different domain, since
    // custom-domain owner could hijack such sessions.
    const allowedOrg = getAllowedOrgForSessionID(mreq.sessionID);
    if (allowedOrg) {
      if (allowHost(req, allowedOrg.host)) {
        customHostSession = ` custom-host-match ${allowedOrg.host}`;
      } else {
        // We need an exception for internal forwarding from home server to doc-workers. These use
        // internal hostnames, so we can't expect a custom domain. These requests do include an
        // Organization header, which we'll use to grant the exception, but security issues remain.
        // TODO Issue 1: an attacker can use a custom-domain request to get an API key, which is an
        // open door to all orgs accessible by this user.
        // TODO Issue 2: Organization header is easy for an attacker (who has stolen a session
        // cookie) to include too; it does nothing to prove that the request is internal.
        const org = req.header('organization');
        if (org && org === allowedOrg.org) {
          customHostSession = ` custom-host-fwd ${org}`;
        } else {
          // Log error and fail.
          log.warn("Auth[%s]: sessionID for host %s org %s; wrong for host %s org %s", mreq.method,
              allowedOrg.host, allowedOrg.org, mreq.get('host'), mreq.org);
          return res.status(403).send('Bad request: invalid session ID');
        }
      }
    }

    mreq.users = getSessionProfiles(session);

    // If we haven't set a maxAge yet, set it now.
    if (session && session.cookie && !session.cookie.maxAge) {
      session.cookie.maxAge = COOKIE_MAX_AGE;
    }

    // See if we have a profile linked with the active organization already.
    // TODO: implement userSelector for rest API, to allow "sticky" user selection on pages.
    let sessionUser: SessionUserObj|null = getSessionUser(session, mreq.org, optStringParam(mreq.query.user) || '');

    if (!sessionUser) {
      // No profile linked yet, so let's elect one.
      // Choose a profile that is no worse than the others available.
      const option = await dbManager.getBestUserForOrg(mreq.users, mreq.org);
      if (option) {
        // Modify request session object to link the current org with our choice of
        // profile.  Express-session will save this change.
        sessionUser = linkOrgWithEmail(session, option.email, mreq.org);
        // In this special case of initially linking a profile, we need to look up the user's info.
        mreq.user = await dbManager.getUserByLogin(option.email);
        mreq.userId = option.id;
        mreq.userIsAuthorized = true;
      } else {
        // No profile has access to this org.  We could choose to
        // link no profile, in which case user will end up
        // immediately presented with a sign-in page, or choose to
        // link an arbitrary profile (say, the first one the user
        // logged in as), in which case user will end up with a
        // friendlier page explaining the situation and offering to
        // add an account to resolve it.  We go ahead and pick an
        // arbitrary profile.
        sessionUser = session.users[0];
        if (!session.orgToUser) { throw new Error("Session misconfigured"); }
        // Express-session will save this change.
        session.orgToUser[mreq.org] = 0;
      }
    }

    profile = sessionUser && sessionUser.profile || undefined;

    // If we haven't computed a userId yet, check for one using an email address in the profile.
    // A user record will be created automatically for emails we've never seen before.
    if (profile && !mreq.userId) {
      const user = await dbManager.getUserByLoginWithRetry(profile.email, profile);
      if (user) {
        mreq.user = user;
        mreq.userId = user.id;
        mreq.userIsAuthorized = true;
      }
    }
  }

  // If no userId has been found yet, fall back on anonymous.
  if (!mreq.userId) {
    const anon = dbManager.getAnonymousUser();
    mreq.user = anon;
    mreq.userId = anon.id;
    mreq.userIsAuthorized = false;
    mreq.users = [dbManager.makeFullUser(anon)];
  }

  log.debug("Auth[%s]: id %s email %s host %s path %s org %s%s", mreq.method,
            mreq.userId, mreq.user?.loginEmail, mreq.get('host'), mreq.path, mreq.org,
            customHostSession);

  return next();
}

/**
 * Returns a handler that redirects the user to a login or signup page.
 */
export function redirectToLoginUnconditionally(
  getLoginRedirectUrl: (req: Request, redirectUrl: URL) => Promise<string>,
  getSignUpRedirectUrl: (req: Request, redirectUrl: URL) => Promise<string>
) {
  return async (req: Request, resp: Response, next: NextFunction) => {
    const mreq = req as RequestWithLogin;
    // Tell express-session to set our cookie: session handling post-login relies on it.
    mreq.session.alive = true;

    // Redirect to sign up if it doesn't look like the user has ever logged in (on
    // this browser)  After logging in, `users` will be set in the session.  Even after
    // logging out again, `users` will still be set.
    const signUp: boolean = (mreq.session.users === undefined);
    log.debug(`Authorizer: redirecting to ${signUp ? 'sign up' : 'log in'}`);
    const redirectUrl = new URL(req.protocol + '://' + req.get('host') + req.originalUrl);
    if (signUp) {
      return resp.redirect(await getSignUpRedirectUrl(req, redirectUrl));
    } else {
      return resp.redirect(await getLoginRedirectUrl(req, redirectUrl));
    }
  };
}

/**
 * Middleware to redirects user to a login page when the user is not
 * logged in.  If allowExceptions is set, then we make an exception
 * for a team site allowing anonymous access, or a personal doc
 * allowing anonymous access, or the merged org.
 */
export function redirectToLogin(
  allowExceptions: boolean,
  getLoginRedirectUrl: (req: Request, redirectUrl: URL) => Promise<string>,
  getSignUpRedirectUrl: (req: Request, redirectUrl: URL) => Promise<string>,
  dbManager: HomeDBManager
): RequestHandler {
  const redirectUnconditionally = redirectToLoginUnconditionally(getLoginRedirectUrl,
                                                                 getSignUpRedirectUrl);
  return async (req: Request, resp: Response, next: NextFunction) => {
    const mreq = req as RequestWithLogin;
    mreq.session.alive = true;  // This will ensure that express-session will set our cookie
                                // if it hasn't already - we'll need it if we redirect.
    if (mreq.userIsAuthorized) { return next(); }

    try {
      // Otherwise it's an anonymous user. Proceed normally only if the org allows anon access,
      // or if the org is not set (FlexServer._redirectToOrg will deal with that case).
      if (mreq.userId && allowExceptions) {
        // Anonymous user has qualified access to merged org.
        // If no org is set, leave it to other middleware.  One common case where the
        // org is not set is when it is embedded in the url, and the user visits '/'.
        // If we immediately require a login, it could fail if no cookie exists yet.
        // Also, '/o/docs' allows anonymous access.
        if (!mreq.org || dbManager.isMergedOrg(mreq.org)) { return next(); }
        const result = await dbManager.getOrg({userId: mreq.userId}, mreq.org);
        if (result.status === 200) { return next(); }
      }

      // In all other cases (including unknown org), redirect user to login or sign up.
      return redirectUnconditionally(req, resp, next);
    } catch (err) {
      log.info("Authorizer failed to redirect", err.message);
      return resp.status(401).send(err.message);
    }
  };
}

/**
 * Sets mreq.docAuth if not yet set, and returns it.
 */
export async function getOrSetDocAuth(
  mreq: RequestWithLogin, dbManager: HomeDBManager, urlId: string
): Promise<DocAuthResult> {
  if (!mreq.docAuth) {
    let effectiveUserId = getUserId(mreq);
    if (mreq.specialPermit && mreq.userId === dbManager.getAnonymousUserId()) {
      effectiveUserId = dbManager.getPreviewerUserId();
    }
    mreq.docAuth = await dbManager.getDocAuthCached({urlId, userId: effectiveUserId, org: mreq.org});
    if (mreq.specialPermit && mreq.userId === dbManager.getAnonymousUserId() &&
        mreq.specialPermit.docId === mreq.docAuth.docId) {
      mreq.docAuth = {...mreq.docAuth, access: 'owners'};
    }
  }
  return mreq.docAuth;
}


export interface ResourceSummary {
  kind: 'doc';
  id: string|number;
}

/**
 *
 * Handle authorization for a single document accessed by a given user.
 *
 */
export interface Authorizer {
  // get the id of user, or null if no authorization in place.
  getUserId(): number|null;

  // get user profile if available.
  getUser(): FullUser|null;

  // get the id of the document.
  getDocId(): string;

  // get any link parameters in place when accessing the resource.
  getLinkParameters(): Record<string, string>;

  // Fetch the doc metadata from HomeDBManager.
  getDoc(): Promise<Document>;

  // Check access, throw error if the requested level of access isn't available.
  assertAccess(role: 'viewers'|'editors'|'owners'): Promise<void>;

  // Get the lasted access information calculated for the doc.  This is useful
  // for logging - but access control itself should use assertAccess() to
  // ensure the data is fresh.
  getCachedAuth(): DocAuthResult;
}

/**
 *
 * Handle authorization for a single document and user.
 *
 */
export class DocAuthorizer implements Authorizer {
  constructor(
    private _dbManager: HomeDBManager,
    private _key: DocAuthKey,
    public readonly openMode: OpenDocMode,
    public readonly linkParameters: Record<string, string>,
    private _docAuth?: DocAuthResult,
    private _profile?: UserProfile
  ) {
  }

  public getUserId(): number {
    return this._key.userId;
  }

  public getUser(): FullUser|null {
    return this._profile ? {id: this.getUserId(), ...this._profile} : null;
  }

  public getDocId(): string {
    // We've been careful to require urlId === docId, see DocManager.
    return this._key.urlId;
  }

  public getLinkParameters(): Record<string, string> {
    return this.linkParameters;
  }

  public async getDoc(): Promise<Document> {
    return this._dbManager.getDoc(this._key);
  }

  public async assertAccess(role: 'viewers'|'editors'|'owners'): Promise<void> {
    const docAuth = await this._dbManager.getDocAuthCached(this._key);
    this._docAuth = docAuth;
    assertAccess(role, docAuth, {openMode: this.openMode});
  }

  public getCachedAuth(): DocAuthResult {
    if (!this._docAuth) { throw Error('no cached authentication'); }
    return this._docAuth;
  }
}

export class DummyAuthorizer implements Authorizer {
  constructor(public role: Role|null, public docId: string) {}
  public getUserId() { return null; }
  public getUser() { return null; }
  public getDocId() { return this.docId; }
  public getLinkParameters() { return {}; }
  public async getDoc(): Promise<Document> { throw new Error("Not supported in standalone"); }
  public async assertAccess() { /* noop */ }
  public getCachedAuth(): DocAuthResult {
    return {
      access: this.role,
      docId: this.docId,
      removed: false,
    };
  }
}


export function assertAccess(
  role: 'viewers'|'editors'|'owners', docAuth: DocAuthResult, options: {
    openMode?: OpenDocMode,
    allowRemoved?: boolean,
  } = {}) {
  const openMode = options.openMode || 'default';
  const details = {status: 403, accessMode: openMode};
  if (docAuth.error) {
    if ([400, 401, 403].includes(docAuth.error.status)) {
      // For these error codes, we know our access level - forbidden. Make errors more uniform.
      throw new ErrorWithCode("AUTH_NO_VIEW", "No view access", details);
    }
    throw docAuth.error;
  }

  if (docAuth.removed && !options.allowRemoved) {
    throw new ErrorWithCode("AUTH_NO_VIEW", "Document is deleted", {status: 404});
  }

  // If docAuth has no error, the doc is accessible, but we should still check the level (in case
  // it's possible to access the doc with a level less than "viewer").
  if (!canView(docAuth.access)) {
    throw new ErrorWithCode("AUTH_NO_VIEW", "No view access", details);
  }

  if (role === 'editors') {
    // If opening in a fork or view mode, treat user as viewer and deny write access.
    const access = (openMode === 'fork' || openMode === 'view') ?
      getWeakestRole('viewers', docAuth.access) : docAuth.access;
    if (!canEdit(access)) {
      throw new ErrorWithCode("AUTH_NO_EDIT", "No write access", details);
    }
  }
}

/**
 * Pull out headers to pass along to a proxied service.  Focussed primarily on
 * authentication.
 */
export function getTransitiveHeaders(req: Request): {[key: string]: string} {
  const Authorization = req.get('Authorization');
  const Cookie = req.get('Cookie');
  const PermitHeader = req.get('Permit');
  const Organization = (req as RequestWithOrg).org;
  const XRequestedWith = req.get('X-Requested-With');
  const Origin = req.get('Origin');  // Pass along the original Origin since it may
                                     // play a role in granular access control.
  return {
    ...(Authorization ? { Authorization } : undefined),
    ...(Cookie ? { Cookie } : undefined),
    ...(Organization ? { Organization } : undefined),
    ...(PermitHeader ? { Permit: PermitHeader } : undefined),
    ...(XRequestedWith ? { 'X-Requested-With': XRequestedWith } : undefined),
    ...(Origin ? { Origin } : undefined),
  };
}

export const signInStatusCookieName = sessionCookieName + '_status';

// We expose a sign-in status in a cookie accessible to all subdomains, to assist in auto-signin.
// Its value is SignInStatus ("S", "M" or unset). This middleware keeps this cookie in sync with
// the session state.
//
// Note that this extra cookie isn't strictly necessary today: since it has similar settings to
// the session cookie, subdomains can infer status from that one. It is here in anticipation that
// we make sessions a host-only cookie, to avoid exposing it to externally-hosted subdomains of
// getgrist.com. In that case, the sign-in status cookie would remain a 2nd-level domain cookie.
export function signInStatusMiddleware(req: Request, resp: Response, next: NextFunction) {
  const mreq = req as RequestWithLogin;

  let origSignInStatus: SignInStatus = '';
  if (req.headers.cookie) {
    const cookies = cookie.parse(req.headers.cookie);
    origSignInStatus = cookies[signInStatusCookieName] || '';
  }

  onHeaders(resp, () => {
    const newSignInStatus = getSignInStatus(mreq.session);
    if (newSignInStatus !== origSignInStatus) {
      // If not signed-in any more, set a past date to delete this cookie.
      const expires = (newSignInStatus && mreq.session.cookie.expires) || new Date(0);
      resp.append('Set-Cookie', cookie.serialize(signInStatusCookieName, newSignInStatus, {
        httpOnly: false,    // make available to client-side scripts
        expires,
        domain: getCookieDomain(req),
        path: '/',
        sameSite: 'lax',    // same setting as for grist-sid is fine here.
      }));
    }
  });
  next();
}
