import stringify from 'fast-json-stable-stringify';
import { FastifyReply, FastifyRequest } from '@hive/service-common';
import type { User } from '../../../shared/entities';
import { AccessError } from '../../../shared/errors';
import { isUUID } from '../../../shared/is-uuid';
import { Logger } from '../../shared/providers/logger';

export type AuthorizationPolicyStatement = {
  effect: 'allow' | 'deny';
  action: ActionStrings | ActionStrings[];
  resource: string | string[];
};

/**
 * Parses a Hive Resource identifier into an object containing a organization path and resourceId path.
 * e.g. `"hrn:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa:target/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"`
 * becomes
 * ```json
 * {
 *   "organizationId": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
 *   "resourceId": "target/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
 * }
 * ```
 */
function parseResourceIdentifier(resource: string) {
  const parts = resource.split(':');
  if (parts.length < 2) {
    throw new Error('Invalid resource identifier (1)');
  }
  if (parts[0] !== 'hrn') {
    throw new Error('Invalid resource identifier. Expected string to start with hrn: (2)');
  }

  if (!parts[1] || (!isUUID(parts[1]) && parts[1] !== '*')) {
    throw new Error('Invalid resource identifier. Expected UUID or * (3)');
  }
  const organizationId = parts[1];

  if (!parts[2]) {
    throw new Error('Invalid resource identifier. Expected type or * (4)');
  }

  // TODO: maybe some stricter validation of the resource id characters

  return { organizationId, resourceId: parts[2] };
}

/**
 * Abstract session class that is implemented by various ways to identify a session.
 * A session is a way to identify a user and their permissions for a specific organization.
 *
 * The `Session.loadPolicyStatementsForOrganization` method must be implemented by the subclass.
 */
export abstract class Session {
  private policyStatementCache = new Map<
    string,
    Promise<AuthorizationPolicyStatement[]> | Array<AuthorizationPolicyStatement>
  >();
  private performActionCache = new Map<string, Promise<void>>();
  protected logger: Logger;

  constructor(args: { logger: Logger }) {
    this.logger = args.logger.child({
      module: this.constructor.name,
    });
  }

  /** Load policy statements for a specific organization. */
  protected abstract loadPolicyStatementsForOrganization(
    organizationId: string,
  ): Promise<Array<AuthorizationPolicyStatement>> | Array<AuthorizationPolicyStatement>;

  /** Retrieve the current viewer. Implementations of the session need to implement this function */
  public getViewer(): Promise<User> {
    throw new AccessError('Authorization token is missing', 'UNAUTHENTICATED');
  }

  public isViewer(): boolean {
    return false;
  }

  /** Retrieve the access token of the request. */
  public getLegacySelector(): {
    token: string;
    organizationId: string;
    projectId: string;
    targetId: string;
  } {
    throw new AccessError('Authorization header is missing');
  }

  private async _loadPolicyStatementsForOrganization(organizationId: string) {
    let result = this.policyStatementCache.get(organizationId);
    if (result !== undefined) {
      return result;
    }

    result = this.loadPolicyStatementsForOrganization(organizationId);
    this.policyStatementCache.set(organizationId, result);
    return await result;
  }

  public async assertPerformAction<TAction extends keyof typeof actionDefinitions>(args: {
    action: TAction;
    organizationId: string;
    params: Parameters<(typeof actionDefinitions)[TAction]>[0];
  }): Promise<void> {
    this.logger.debug(
      'Assert performing action (action=%s, organizationId=%s, params=%o)',
      args.action,
      args.organizationId,
      args.params,
    );

    const argsStr = stringify(args);
    let result = this.performActionCache.get(argsStr);
    if (result !== undefined) {
      this.logger.debug(
        'Serve result from cache (action=%s, organizationId=%s, params=%o)',
        args.action,
        args.organizationId,
        args.params,
      );
      return result;
    }
    result = this._assertPerformAction(args);
    this.performActionCache.set(argsStr, result);
    return await result;
  }

  /**
   * Check whether a session is allowed to perform a specific action.
   * Throws a AccessError if the action is not allowed.
   */
  private async _assertPerformAction<TAction extends keyof typeof actionDefinitions>(args: {
    action: TAction;
    organizationId: string;
    params: Parameters<(typeof actionDefinitions)[TAction]>[0];
  }): Promise<void> {
    const permissions = await this._loadPolicyStatementsForOrganization(args.organizationId);

    const resourceIdsForAction = actionDefinitions[args.action](args.params as any);
    let isAllowed = false;

    for (const permission of permissions) {
      const parsedResources = (
        Array.isArray(permission.resource) ? permission.resource : [permission.resource]
      ).map(parseResourceIdentifier);

      /** If no resource matches, we skip this permission */
      if (
        !parsedResources.some(resource => {
          if (resource.organizationId !== '*' && resource.organizationId !== args.organizationId) {
            return false;
          }

          for (const resourceActionId of resourceIdsForAction) {
            if (isResourceIdMatch(resource.resourceId, resourceActionId)) {
              return true;
            }
          }

          return false;
        })
      ) {
        continue;
      }

      const actions = Array.isArray(permission.action) ? permission.action : [permission.action];

      // check if action matches
      for (const action of actions) {
        if (isActionMatch(action, args.action)) {
          if (permission.effect === 'deny') {
            this.logger.debug(
              'Session not authorized to perform action. Action explicitly denied. (action=%s, organizationId=%s, params=%o)',
              args.action,
              args.organizationId,
              args.params,
            );
            throw new AccessError(`Missing permission for performing '${args.action}' on resource`);
          } else {
            isAllowed = true;
          }
        }
      }
    }

    if (!isAllowed) {
      this.logger.debug(
        'Session not authorized to perform action. Action not allowed. (action=%s, organizationId=%s, params=%o)',
        args.action,
        args.organizationId,
        args.params,
      );

      throw new AccessError(`Missing permission for performing '${args.action}' on resource`);
    }
  }

  /**
   * Check whether a session is allowed to perform a specific action.
   * Returns a boolean that indicates whether the action is allowed or not allowed.
   */
  public async canPerformAction<TAction extends keyof typeof actionDefinitions>(args: {
    action: TAction;
    organizationId: string;
    params: Parameters<(typeof actionDefinitions)[TAction]>[0];
  }): Promise<boolean> {
    return await this.assertPerformAction(args)
      .then(() => true)
      .catch(err => {
        if (err instanceof AccessError) {
          return false;
        }
        return Promise.reject(err);
      });
  }

  /** Reset the permissions cache. */
  public reset() {
    this.logger.debug('Reset cache.');
    this.performActionCache.clear();
    this.policyStatementCache.clear();
  }
}

/** Check whether a action definition (using wildcards) matches a action */
function isActionMatch(actionContainingWildcard: string, action: string) {
  // any action
  if (actionContainingWildcard === '*') {
    return true;
  }
  // exact match
  if (actionContainingWildcard === action) {
    return true;
  }

  const [actionScope] = action.split(':');
  const [userSpecifiedActionScope, userSpecifiedActionId] = actionContainingWildcard.split(':');

  // wildcard match "scope:*"
  if (actionScope === userSpecifiedActionScope && userSpecifiedActionId === '*') {
    return true;
  }

  return false;
}

/** Check whether a resource id path (containing wildcards) matches a resource id path */
function isResourceIdMatch(
  /** The resource id path containing wildcards */
  resourceIdContainingWildcards: string,
  /** The Resource id without wildcards */
  resourceId: string,
): boolean {
  const wildcardIdParts = resourceIdContainingWildcards.split('/');
  const resourceIdParts = resourceId.split('/');

  do {
    const wildcardIdPart = wildcardIdParts.shift();
    const resourceIdPart = resourceIdParts.shift();

    if (wildcardIdPart === '*' && wildcardIdParts.length === 0) {
      return true;
    }

    if (wildcardIdPart !== resourceIdPart) {
      return false;
    }
  } while (wildcardIdParts.length || resourceIdParts.length);

  return true;
}

function defaultOrgIdentity(args: { organizationId: string }) {
  return [`organization/${args.organizationId}`];
}

function defaultProjectIdentity(
  args: { projectId: string } & Parameters<typeof defaultOrgIdentity>[0],
) {
  return [...defaultOrgIdentity(args), `project/${args.projectId}`];
}

function defaultTargetIdentity(
  args: { targetId: string } & Parameters<typeof defaultProjectIdentity>[0],
) {
  return [...defaultProjectIdentity(args), `target/${args.targetId}`];
}

function defaultAppDeploymentIdentity(
  args: { appDeploymentName: string | null } & Parameters<typeof defaultTargetIdentity>[0],
) {
  const ids = defaultTargetIdentity(args);

  if (args.appDeploymentName !== null) {
    ids.push(`target/${args.targetId}/appDeployment/${args.appDeploymentName}`);
  }

  return ids;
}

function schemaCheckOrPublishIdentity(
  args: { serviceName: string | null } & Parameters<typeof defaultTargetIdentity>[0],
) {
  const ids = defaultTargetIdentity(args);

  if (args.serviceName !== null) {
    ids.push(`target/${args.targetId}/service/${args.serviceName}`);
  }

  return ids;
}

/**
 * Object map containing all possible actions
 * and resource identifier builder functions required for checking whether an action can be performed.
 *
 * Used within the `Session.assertPerformAction` function for a fully type-safe experience.
 * If you are adding new permissions to the existing system.
 * This is the place to do so.
 */
const actionDefinitions = {
  'organization:describe': defaultOrgIdentity,
  'organization:modifySlug': defaultOrgIdentity,
  'organization:delete': defaultOrgIdentity,
  'gitHubIntegration:modify': defaultOrgIdentity,
  'slackIntegration:modify': defaultOrgIdentity,
  'oidc:modify': defaultOrgIdentity,
  'support:manageTickets': defaultOrgIdentity,
  'billing:describe': defaultOrgIdentity,
  'billing:update': defaultOrgIdentity,
  'targetAccessToken:modify': defaultTargetIdentity,
  'cdnAccessToken:modify': defaultTargetIdentity,
  'member:describe': defaultOrgIdentity,
  'member:assignRole': defaultOrgIdentity,
  'member:modifyRole': defaultOrgIdentity,
  'member:removeMember': defaultOrgIdentity,
  'member:manageInvites': defaultOrgIdentity,
  'project:create': defaultOrgIdentity,
  'project:describe': defaultProjectIdentity,
  'project:delete': defaultProjectIdentity,
  'project:modifySettings': defaultProjectIdentity,
  'alert:modify': defaultProjectIdentity,
  'schemaLinting:modifyOrganizationRules': defaultOrgIdentity,
  'schemaLinting:modifyProjectRules': defaultProjectIdentity,
  'target:create': defaultProjectIdentity,
  'target:delete': defaultTargetIdentity,
  'target:modifySettings': defaultTargetIdentity,
  'laboratory:describe': defaultTargetIdentity,
  'laboratory:modify': defaultTargetIdentity,
  'laboratory:modifyPreflightScript': defaultTargetIdentity,
  'appDeployment:describe': defaultTargetIdentity,
  'appDeployment:create': defaultAppDeploymentIdentity,
  'appDeployment:publish': defaultAppDeploymentIdentity,
  'appDeployment:retire': defaultAppDeploymentIdentity,
  'schemaCheck:create': schemaCheckOrPublishIdentity,
  'schemaCheck:approve': schemaCheckOrPublishIdentity,
  'schemaVersion:publish': schemaCheckOrPublishIdentity,
  'schemaVersion:approve': defaultTargetIdentity,
  'schemaVersion:deleteService': schemaCheckOrPublishIdentity,
  'schema:loadFromRegistry': defaultTargetIdentity,
  'schema:compose': defaultTargetIdentity,
  'auditLog:export': defaultOrgIdentity,
} satisfies ActionDefinitionMap;

type ActionDefinitionMap = {
  [key: `${string}:${string}`]: (args: any) => Array<string>;
};

type Actions = keyof typeof actionDefinitions;

type ActionStrings = Actions | '*';

/** Unauthenticated session that is returned by default. */
class UnauthenticatedSession extends Session {
  protected loadPolicyStatementsForOrganization(
    _: string,
  ): Promise<Array<AuthorizationPolicyStatement>> | Array<AuthorizationPolicyStatement> {
    return [];
  }
}

/**
 * Strategy to authenticate a session from an incoming request.
 * E.g. SuperTokens, JWT, etc.
 */
export abstract class AuthNStrategy<TSession extends Session> {
  /**
   * Parse a session from an incoming request.
   * Returns null if the strategy does not apply to the request.
   * Returns a session if the strategy applies to the request.
   * Rejects if the strategy applies to the request but the session could not be parsed.
   */
  public abstract parse(args: {
    req: FastifyRequest;
    reply: FastifyReply;
  }): Promise<TSession | null>;
}

/** Helper class to Authenticate an incoming request. */
export class AuthN {
  private strategies: Array<AuthNStrategy<Session>>;

  constructor(deps: {
    /** List of strategies for authentication a user */
    strategies: Array<AuthNStrategy<Session>>;
  }) {
    this.strategies = deps.strategies;
  }

  /**
   * Returns the first successful `Session` created by a authentication strategy.
   * If no authentication strategy succeeds a `UnauthenticatedSession` is returned instead.
   */
  async authenticate(args: { req: FastifyRequest; reply: FastifyReply }): Promise<Session> {
    for (const strategy of this.strategies) {
      const session = await strategy.parse(args);
      if (session) {
        return session;
      }
    }

    return new UnauthenticatedSession({
      logger: args.req.log,
    });
  }
}
