// provides a set of common routines for logging a user in

'use strict';

const InitialDataFetcher = require('./initial_data_fetcher');
const UserSubscriptionGranter = require('./user_subscription_granter');
const UUID = require('uuid').v4;
const ProviderFetcher = require(process.env.CSSVC_BACKEND_ROOT + '/api_server/modules/providers/provider_fetcher');
const DetermineCapabilities = require(process.env.CSSVC_BACKEND_ROOT + '/api_server/modules/versioner/determine_capabilities');
const VersionErrors = require(process.env.CSSVC_BACKEND_ROOT + '/api_server/modules/versioner/errors');
const { awaitParallel } = require(process.env.CSSVC_BACKEND_ROOT + '/shared/server_utils/await_utils');
const Fetch = require('node-fetch');
const EmailUtilities = require(process.env.CSSVC_BACKEND_ROOT + '/shared/server_utils/email_utilities');
const CompanyIndexes = require(process.env.CSSVC_BACKEND_ROOT + '/api_server/modules/companies/indexes');
const WebmailCompanies = require(process.env.CSSVC_BACKEND_ROOT + '/api_server/etc/webmail_companies');
const NewRelicOrgIndexes = require(process.env.CSSVC_BACKEND_ROOT + '/api_server/modules/newrelic_comments/new_relic_org_indexes');
const GetEligibleJoinCompanies = require(process.env.CSSVC_BACKEND_ROOT + '/api_server/modules/companies/get_eligible_join_companies');
const AccessTokenCreator = require('./access_token_creator');
const IDPErrors = require(process.env.CSSVC_BACKEND_ROOT + '/api_server/modules/newrelic_idp/errors');
const NRAccessTokenRefresher = require('./nr_access_token_refresher');

class LoginHelper {

	constructor (options) {
		Object.assign(this, options);
		this.loginType = this.loginType || 'web';
		this.request.errorHandler.add(VersionErrors);
		this.request.errorHandler.add(IDPErrors);
		this.api = this.request.api;
		this.apiConfig = this.api.config;
	}

	// perform a true login for the user, including returning full response data
	async login () {
		if (this.user.get('inMaintenanceMode')) {
			throw this.request.errorHandler.error('inMaintenanceMode');
		}

		this.getCountryCode(); // NOTE - no await here, this is not part of the actual request flow

		if (!this.user.didIDPSync) {
			await this.user.handleIDPSync(this.request, true);
		} 
		await awaitParallel([
			this.getInitialData,
			//this.getForeignCompanies, // doesn't apply anymore under one-user-per-org
			this.generateAccessToken
		], this);
		this.grantSubscriptionPermissions(); // NOTE - no await here, this can run in parallel
		await this.updateLastLogin();
		await this.resetLoginCode();
		this.getThirdPartyProviders();
		await this.getEligibleJoinCompanies();	// get companies the user is not a member of, but is eligible to join
		await this.getPossibleAuthDomains();	// get possible auth domains for the user to log into on New Relic
		await this.getAccountIsConnected();		// get whether this user's account is connected to a CS company
		await this.formResponse();
		return this.responseData;
	}

	// prepare for the user being allowed to login, without actually generating the
	// response data needed for a true login
	async allowLogin () {
		await this.generateAccessToken();
		this.grantSubscriptionPermissions(); // NOTE - no await here, this can run in parallel
		this.getCountryCode(); // NOTE - no await here, this is not part of the actual request flow
		return {
			accessToken: this.accessToken
		};
	}

	// get the country-code associated with this user by IP address of the connection
	async getCountryCode () {
		// we hit a free third-party service for this, and it's not mission critial, so
		// set a strict timeout on it so as not to delay other operations
		let result;
		try {
			if (!this.request.request.connection) { return; }
			if (this.request.request.headers['x-cs-test-num']) { return; }
			let ip = this.request.request.get('x-forwarded-for');
			if (!ip) {
				const addr = this.request.request.connection.remoteAddress;
				ip = addr.split(':').pop();
			}
			const response = await Fetch('http://ip2c.org/' + ip, { timeout: 10000 });
			result = await response.text();
			this.countryCode = result.split(';')[1].trim();
			if (this.countryCode) {
				await this.request.data.users.updateDirect(
					{ id: this.request.data.users.objectIdSafe(this.user.id) },
					{ $set: { countryCode: this.countryCode } }
				);
			}
		}
		catch (error) {
			const message = error instanceof Error ? error.message : JSON.stringify(error);
			this.request.warn(`Unable to fetch country code: ${message}`);
		}
	}

	// get the initial data to return in the response, this is a time-saver for the client
	// so it doesn't have to fetch this data with separate requests
	async getInitialData () {
		this.initialDataFetcher = new InitialDataFetcher({
			request: this.request,
			user: this.user
		});
		this.initialData = await this.initialDataFetcher.fetchInitialData();
	}

	/*
	// get any companies the user is a member of (by email) in foreign environments,
	// to display in the organization switcher
	async getForeignCompanies () {
		if (this.request.request.headers['x-cs-block-xenv']) {
			this.request.log('Not fetching foreign companies, blocked by header');
			return [];
		}

		const companies = await this.request.api.services.environmentManager
			.fetchUserCompaniesFromAllEnvironments(this.user.get('email'));
		this.foreignCompanies = companies.map(company => {
			company.company.host = company.host;
			return company.company;
		});
	}
	*/

	// generate an access token for this login if needed
	async generateAccessToken (force) {
		let set = null;

		// generate a unique PubNub token, to be stored with the user object, the one and only way a 
		// user can subscribe to PubNub (though for now, they can also subscribe with their access token,
		// but we will deprecate this ability once the old atom client is deprecated)
		this.pubnubToken = this.user.get('pubNubToken');
		if (!this.pubnubToken) {
			this.pubnubToken = (UUID() + '-' + UUID()).split('-').join('');
			set = set || {};
			set.pubNubToken = this.pubnubToken;
		}
		// set a more generic "broadcaster" token, to allow for other broadcaster solutions beside PubNub
		this.broadcasterToken = this.user.get('broadcasterToken');
		if (!this.broadcasterToken) {
			this.broadcasterToken = this.pubnubToken;
			set = set || {};
			set.broadcasterToken = this.broadcasterToken;
		}

		// look for a new-style token (with min issuance), if it doesn't exist, or our current token
		// was issued before the min issuance, then we need to generate a new token for this login type
		let isNRToken = false;
		try {
			const currentTokenInfo = this.user.getTokenInfoByType(this.loginType);
 			isNRToken = currentTokenInfo && currentTokenInfo.isNRToken;
			const minIssuance = typeof currentTokenInfo === 'object' ? (currentTokenInfo.minIssuance || null) : null;
			this.accessToken = typeof currentTokenInfo === 'object' ? currentTokenInfo.token : this.user.get('accessToken');
			const tokenPayload = (!force && this.accessToken && !isNRToken) ? 
				this.api.services.tokenHandler.verify(this.accessToken) : 
				null;
			if (
				!this.dontGenerateAccessToken && 
				!isNRToken &&
				(
					force ||
					!minIssuance ||
					minIssuance > (tokenPayload.iat * 1000)
				)
			) {
				const { token, minIssuance } = AccessTokenCreator(this.request, this.user.id);
				this.accessToken = token;
				set = set || {};
				set[`accessTokens.${this.loginType}`] = { token, minIssuance };
			}

			// if this is a New Relic issued access token, it may need to be refreshed
			if (isNRToken) {
				this.accessTokenInfo = currentTokenInfo;
 				const result = await NRAccessTokenRefresher({
					request: this.request,
					tokenInfo: currentTokenInfo,
					loginType: this.loginType
				});
				if (result) {
					set = set || {};
					Object.assign(set, result.userSet);
					this.accessTokenInfo = result.newTokenInfo;
					this.accessTokenInfo.isNRToken = true;
				}
			}
			if (set) {
				await this.request.data.users.applyOpById(this.user.id, { $set: set });
			}
		}
		catch (error) {
			if (!force && !isNRToken) {
				// if token seems invalid, try again but force a new token to be created
				this.generateAccessToken(true);
			}
			else {
				const message = typeof error === 'object' ? error.message : error;
				throw this.request.errorHandler.error('token', { reason: message });
			}
		}
	}
	
	// update the time the user last logged in, except if logging in via the web app
	async updateLastLogin () {
		if (this.dontUpdateLastLogin) {
			return;
		}

		const origin = this.request.request.headers['x-cs-plugin-ide'];
		if (origin === 'webclient') {
			return;
		}
		const op = {
			$set: {
				lastLogin: Date.now()
			}
		};
		if (origin) {
			op.$set.lastOrigin = origin;
			const originDetail = this.request.request.headers['x-cs-plugin-ide-detail'];
			if (originDetail) {
				op.$set.lastOriginDetail = originDetail;
			}
		}

		const setFirstSession = (
			!this.dontSetFirstSession &&
			!this.user.get('copiedFromUserId') // don't set firstSessionStartedAt if this is not the original registering user
		);
		if (setFirstSession) {
			op.$set.firstSessionStartedAt = this.user.get('firstSessionStartedAt') === undefined ? Date.now() : 0;
		}

		if (this.countryCode) {
			op.$set.countryCode = this.countryCode;
		}
		await this.request.data.users.applyOpById(this.user.id, op);
	}

	// delete fields associated with a login code, if applicable
	async resetLoginCode () {
		const op = {
			$unset: {
				loginCode: true,
				loginCodeAttempts: true,
				loginCodeExpiresAt: true,
			}
		};
		await this.request.data.users.applyOpById(this.user.id, op);
	}

	// get the third-party issue providers that are available for issue codemark integration
	// this fetches the "standard" in-cloud providers, we'll add to this for providers for each individual team
	getThirdPartyProviders () {
		const info = new ProviderFetcher({
			request: this.request,
			teams: this.initialDataFetcher.teams
		}).getThirdPartyProviders();
		(this.initialDataFetcher.teams || []).forEach(team => {
			const responseTeam = this.initialDataFetcher.initialData.teams.find(t => t.id === team.id);
			if (responseTeam) {
				responseTeam.providerHosts = info.providerHosts[responseTeam.id];
			}
		});
	}

	// form the response to the request
	async formResponse () {
		if (this.notRealLogin) {
			return;
		}

		const { 
			isOnPrem,
			isProductionCloud = false,
			newRelicLandingServiceUrl,
			o11yServerUrl,
			newRelicApiUrl,
			newRelicSecApiUrl,
			csecApiUrl,
		} = this.apiConfig.sharedGeneral;
		const {
			telemetryEndpoint
		} = this.apiConfig.telemetry.segment;
		const { environmentGroup } = this.apiConfig;
		
		// substitute the "short name" of this environment host, if found
		let runTimeEnvironment = this.apiConfig.sharedGeneral.runTimeEnvironment;
		if (environmentGroup && environmentGroup[runTimeEnvironment]) {
			runTimeEnvironment = environmentGroup[runTimeEnvironment].shortName;
		}

		// get this API server's capabilities
		const capabilities = await DetermineCapabilities({ request: this });

		this.responseData = {
			user: this.user.getSanitizedObjectForMe({ request: this.request }),	// include me-only attributes
			accessToken: this.accessToken,	// access token to supply in future requests
			broadcasterToken: this.broadcasterToken, // more generic "broadcaster" token, for broadcaster solutions other than PubNub
			capabilities, // capabilities served by this API server
			features: {
				slack: {
					interactiveComponentsEnabled: this.api.config.integrations.slack.interactiveComponentsEnabled
				}
			},
			isOnPrem,
			isProductionCloud,
			runtimeEnvironment: runTimeEnvironment,
			environmentHosts: Object.values(environmentGroup || []),
			isWebmail: this.isWebmail,
			accountIsConnected: this.accountIsConnected,
			newRelicLandingServiceUrl,
			o11yServerUrl,
			newRelicApiUrl,
			newRelicSecApiUrl,
			telemetryEndpoint,
			csecApiUrl
		};
		if (this.apiConfig.broadcastEngine.pubnub && this.apiConfig.broadcastEngine.pubnub.subscribeKey) {
			this.responseData.pubnubKey = this.apiConfig.broadcastEngine.pubnub.subscribeKey;	// give them the subscribe key for pubnub
			this.responseData.pubnubToken = this.pubnubToken;	// token used to subscribe to PubNub channels
		}

		// if using New Relic issued token, send additional token info
		if (this.accessTokenInfo && this.accessTokenInfo.isNRToken) {
			this.responseData.accessTokenInfo = {
				refreshToken: this.accessTokenInfo.refreshToken,
				expiresAt: this.accessTokenInfo.expiresAt,
				provider: this.accessTokenInfo.provider,
				tokenType: this.accessTokenInfo.tokenType,
				isNRToken: true,
			};
		}
		
		// if using socketcluster for messaging (for on-prem installations), return host info
		if (this.apiConfig.broadcastEngine.selected === 'codestreamBroadcaster') {
			const { host, port, ignoreHttps } = this.apiConfig.broadcastEngine.codestreamBroadcaster;
			this.responseData.socketCluster = { host, port, ignoreHttps };
		}
		Object.assign(this.responseData, this.initialData);

		// add any foreign (cross-environment) companies
		this.responseData.companies = [...this.responseData.companies, ...(this.foreignCompanies || [])];

		// add eligibleJoinCompanies as a user attribute
		if (this.eligibleJoinCompanies && this.eligibleJoinCompanies.length > 0) {
			this.responseData.user.eligibleJoinCompanies = this.eligibleJoinCompanies;
		}

		// add possibleAuthDomains as a user attribute
		this.responseData.user.possibleAuthDomains = this.possibleAuthDomains || [];
	}

	// grant the user permission to subscribe to various broadcaster channels
	async grantSubscriptionPermissions () {
		try {
			await new UserSubscriptionGranter({
				api: this.api,
				data: this.request.data,
				user: this.user,
				request: this.request
			}).grantAll();
		}
		catch (error) {
			throw this.request.errorHandler.error('userMessagingGrant', { reason: error });
		}
	}

	// get list of companies the user is not a member of, but is eligible to join
	async getEligibleJoinCompanies () {
		const email = this.user.get('email');
		const domain = EmailUtilities.parseEmail(email).domain.toLowerCase();
		this.isWebmail = WebmailCompanies.includes(domain);

		const ignoreDomain = this.isWebmail;
		const ignoreInvite = false;

		if (this.notRealLogin) { return; }

		this.eligibleJoinCompanies = await GetEligibleJoinCompanies(
			email,
			this.request,
			{ ignoreDomain, ignoreInvite }
		);
	}

	// get possible auth domains for the user to log into on New Relic
	async getPossibleAuthDomains () {
		if (!this.api.services.idp) { return; }
		if (!this.request.request.headers['x-cs-enable-uid']) { return; }
		let mockResponse;
		if (this.request.request.headers['x-cs-no-newrelic']) {
			mockResponse = true;
			this.request.log('NOTE: not getting possible auth domains, sending mock response');
		}

		// don't let this determination preempt the login process
		try {
			const teamId = (this.user.get('teamIds') || [])[0];
			if (!teamId) return;
			const token = (((this.user.get('providerInfo') || {})[teamId] || {}).newrelic || {}).accessToken;
			const bearerToken = (((this.user.get('providerInfo') || {})[teamId] || {}).newrelic || {}).bearerToken;
			const tokenType = (((this.user.get('providerInfo') || {})[teamId] || {}).newrelic || {}).tokenType;
			if (!token || !bearerToken) return;
			this.possibleAuthDomains = await this.api.services.idp.getPossibleAuthDomains(token, tokenType, { 
				request: this.request,
				mockResponse
			});
		} catch (error) {
			const message = error instanceof Error ? error.message : JSON.stringify(error);
			this.request.warn(`Unable to determine possible auth domains: ${message}`);
			this.possibleAuthDomains = [];
		}
	}

	// set flag indicating whether this user's New Relic account is connected to a CodeStream company
	async getAccountIsConnected () {
		if (!this.nrAccountId || (this.eligibleJoinCompanies || []).length > 0) {
			// doesn't apply if no NR account ID is given, or there are companies the user is
			// already eligible to join by domain
			return;
		}

		// first check to see if any companies are directly tied to this account
		let company = await this.request.data.companies.getOneByQuery(
			{ nrAccountIds: this.nrAccountId },
			{ hint: CompanyIndexes.byNRAccountId }
		);
		if (company) {
			this.accountIsConnected = true;
			return;
		}

		// now lookup the NR org associated with this account
		const nrOrgInfo = await this.request.api.data.newRelicOrgs.getOneByQuery(
			{ accountId: this.nrAccountId },
			{ hint: NewRelicOrgIndexes.byAccountId }
		);
		if (!nrOrgInfo) {
			this.accountIsConnected = false;
			return;
		}

		// if we found a match, see if any companies match the org
		company = await this.request.data.companies.getOneByQuery(
			{ nrOrgIds: nrOrgInfo.orgId },
			{ hint: CompanyIndexes.byNROrgId }
		);
		this.accountIsConnected = !!company;
	}
}

module.exports = LoginHelper;
