import { Store } from 'n3';
import type { Credential, CredentialSet } from '../authentication/Credentials';
import { CredentialGroup } from '../authentication/Credentials';
import type { AuxiliaryIdentifierStrategy } from '../http/auxiliary/AuxiliaryIdentifierStrategy';
import type { ResourceIdentifier } from '../http/representation/ResourceIdentifier';
import { getLoggerFor } from '../logging/LogUtil';
import type { ResourceSet } from '../storage/ResourceSet';
import type { ResourceStore } from '../storage/ResourceStore';
import { INTERNAL_QUADS } from '../util/ContentTypes';
import { createErrorMessage } from '../util/errors/ErrorUtil';
import { ForbiddenHttpError } from '../util/errors/ForbiddenHttpError';
import { InternalServerError } from '../util/errors/InternalServerError';
import type { IdentifierStrategy } from '../util/identifiers/IdentifierStrategy';
import { IdentifierMap, IdentifierSetMultiMap } from '../util/map/IdentifierMap';
import { readableToQuads } from '../util/StreamUtil';
import { ACL, RDF } from '../util/Vocabularies';
import type { AccessChecker } from './access/AccessChecker';
import type { PermissionReaderInput } from './PermissionReader';
import { PermissionReader } from './PermissionReader';
import type { AclPermission } from './permissions/AclPermission';
import { AclMode } from './permissions/AclPermission';
import type { PermissionMap } from './permissions/Permissions';
import { AccessMode } from './permissions/Permissions';

// Maps WebACL-specific modes to generic access modes.
const modesMap: Record<string, Readonly<(keyof AclPermission)[]>> = {
  [ACL.Read]: [ AccessMode.read ],
  [ACL.Write]: [ AccessMode.append, AccessMode.write ],
  [ACL.Append]: [ AccessMode.append ],
  [ACL.Control]: [ AclMode.control ],
} as const;

/**
 * Finds the permissions of a resource as defined in the corresponding ACL resource.
 * Does not make any deductions such as checking parent containers for create permissions
 * or applying control permissions for ACL resources.
 *
 * Specific access checks are done by the provided {@link AccessChecker}.
 */
export class WebAclReader extends PermissionReader {
  protected readonly logger = getLoggerFor(this);

  private readonly aclStrategy: AuxiliaryIdentifierStrategy;
  private readonly resourceSet: ResourceSet;
  private readonly aclStore: ResourceStore;
  private readonly identifierStrategy: IdentifierStrategy;
  private readonly accessChecker: AccessChecker;

  public constructor(aclStrategy: AuxiliaryIdentifierStrategy, resourceSet: ResourceSet, aclStore: ResourceStore,
    identifierStrategy: IdentifierStrategy, accessChecker: AccessChecker) {
    super();
    this.aclStrategy = aclStrategy;
    this.resourceSet = resourceSet;
    this.aclStore = aclStore;
    this.identifierStrategy = identifierStrategy;
    this.accessChecker = accessChecker;
  }

  /**
   * Checks if an agent is allowed to execute the requested actions.
   * Will throw an error if this is not the case.
   * @param input - Relevant data needed to check if access can be granted.
   */
  public async handle({ credentials, requestedModes }: PermissionReaderInput): Promise<PermissionMap> {
    // Determine the required access modes
    this.logger.debug(`Retrieving permissions of ${credentials.agent?.webId ?? 'an unknown agent'}`);
    const aclMap = await this.getAclMatches(requestedModes.distinctKeys());
    const storeMap = await this.findAuthorizationStatements(aclMap);
    return await this.findPermissions(storeMap, credentials);
  }

  /**
   * Finds the permissions in the provided WebACL quads.
   *
   * Rather than restricting the search to only the required modes,
   * we collect all modes in order to have complete metadata (for instance, for the WAC-Allow header).
   *
   * @param aclMap - A map containing stores of ACL data linked to their relevant identifiers.
   * @param credentials - Credentials to check permissions for.
   */
  private async findPermissions(aclMap: Map<Store, ResourceIdentifier[]>, credentials: CredentialSet):
  Promise<PermissionMap> {
    const result: PermissionMap = new IdentifierMap();
    for (const [ store, aclIdentifiers ] of aclMap) {
      // WebACL only supports public and agent permissions
      const publicPermissions = await this.determinePermissions(store, credentials.public);
      const agentPermissions = await this.determinePermissions(store, credentials.agent);
      for (const identifier of aclIdentifiers) {
        result.set(identifier, {
          [CredentialGroup.public]: publicPermissions,
          [CredentialGroup.agent]: agentPermissions,
        });
      }
    }

    return result;
  }

  /**
   * Determines the available permissions for the given credentials.
   * Will deny all permissions if credentials are not defined
   * @param acl - Store containing all relevant authorization triples.
   * @param credential - Credentials to find the permissions for.
   */
  private async determinePermissions(acl: Store, credential?: Credential): Promise<AclPermission> {
    const aclPermissions: AclPermission = {};
    if (!credential) {
      return aclPermissions;
    }

    // Apply all ACL rules
    const aclRules = acl.getSubjects(RDF.type, ACL.Authorization, null);
    for (const rule of aclRules) {
      const hasAccess = await this.accessChecker.handleSafe({ acl, rule, credential });
      if (hasAccess) {
        // Set all allowed modes to true
        const modes = acl.getObjects(rule, ACL.mode, null);
        for (const { value: aclMode } of modes) {
          if (aclMode in modesMap) {
            for (const mode of modesMap[aclMode]) {
              aclPermissions[mode] = true;
            }
          }
        }
      }
    }

    return aclPermissions;
  }

  /**
   * Finds the ACL data relevant for all the given resources.
   * The input set will be modified in place.
   *
   * @param targets - Targets to find ACL data for.
   *
   * @returns A map linking ACL resources to the relevant identifiers.
   */
  private async getAclMatches(targets: Iterable<ResourceIdentifier>):
  Promise<IdentifierSetMultiMap<ResourceIdentifier>> {
    const aclMap = new IdentifierSetMultiMap<ResourceIdentifier>();

    for (const target of targets) {
      this.logger.debug(`Searching ACL data for ${target.path}`);
      const aclIdentifier = await this.getAclRecursive(target);
      aclMap.add(aclIdentifier, target);
    }

    return aclMap;
  }

  /**
   * Finds the ACL document relevant for the given identifier,
   * following the steps defined in https://solidproject.org/TR/2021/wac-20210711#effective-acl-resource.
   *
   * @param identifier - {@link ResourceIdentifier} of which we need the ACL document.
   *
   * @returns The {@link ResourceIdentifier} of the relevant ACL document.
   */
  private async getAclRecursive(identifier: ResourceIdentifier): Promise<ResourceIdentifier> {
    // Obtain the direct ACL document for the resource, if it exists
    this.logger.debug(`Trying to read the direct ACL document of ${identifier.path}`);

    const acl = this.aclStrategy.getAuxiliaryIdentifier(identifier);
    this.logger.debug(`Determining existence of  ${acl.path}`);
    if (await this.resourceSet.hasResource(acl)) {
      this.logger.info(`Found applicable ACL document ${acl.path}`);
      return acl;
    }
    this.logger.debug(`No direct ACL document found for ${identifier.path}`);

    // Find the applicable ACL document of the parent container
    this.logger.debug(`Traversing to the parent of ${identifier.path}`);
    if (this.identifierStrategy.isRootContainer(identifier)) {
      this.logger.error(`No ACL document found for root container ${identifier.path}`);
      // https://solidproject.org/TR/2021/wac-20210711#acl-resource-representation
      // The root container MUST have an ACL resource with a representation.
      throw new ForbiddenHttpError('No ACL document found for root container');
    }
    const parent = this.identifierStrategy.getParentContainer(identifier);
    return this.getAclRecursive(parent);
  }

  /**
   * For every ACL/identifier combination it finds the relevant ACL triples for that identifier.
   * This is done in such a way that store results are reused for all matching identifiers.
   * The split is based on the `acl:accessTo` and `acl:default` triples.
   *
   * @param map - Map of matches that need to be filtered.
   */
  private async findAuthorizationStatements(map: IdentifierSetMultiMap<ResourceIdentifier>):
  Promise<Map<Store, ResourceIdentifier[]>> {
    // For every found ACL document, filter out triples that match for specific identifiers
    const result = new Map<Store, ResourceIdentifier[]>();
    for (const [ aclIdentifier, matchedTargets ] of map.entrySets()) {
      const subject = this.aclStrategy.getSubjectIdentifier(aclIdentifier);
      this.logger.debug(`Trying to read the ACL document ${aclIdentifier.path}`);
      let contents: Store;
      try {
        const data = await this.aclStore.getRepresentation(aclIdentifier, { type: { [INTERNAL_QUADS]: 1 }});
        contents = await readableToQuads(data.data);
      } catch (error: unknown) {
        // Something is wrong with the server if we can't read the resource
        const message = `Error reading ACL resource ${aclIdentifier.path}: ${createErrorMessage(error)}`;
        this.logger.error(message);
        throw new InternalServerError(message, { cause: error });
      }

      // SubjectIdentifiers are those that match the subject identifier of the found ACL document (so max 1).
      // Due to how the effective ACL document is found, all other identifiers must be (transitive) children.
      // This has impact on whether the `acl:accessTo` or `acl:default` predicate needs to be checked.
      const subjectIdentifiers: ResourceIdentifier[] = [];
      const childIdentifiers: ResourceIdentifier[] = [];
      for (const target of matchedTargets) {
        (target.path === subject.path ? subjectIdentifiers : childIdentifiers).push(target);
      }
      if (subjectIdentifiers.length > 0) {
        const subjectStore = await this.filterStore(contents, subject.path, true);
        result.set(subjectStore, subjectIdentifiers);
      }
      if (childIdentifiers.length > 0) {
        const childStore = await this.filterStore(contents, subject.path, false);
        result.set(childStore, childIdentifiers);
      }
    }
    return result;
  }

  /**
   * Extracts all rules from the store that are relevant for the given target,
   * based on either the `acl:accessTo` or `acl:default` predicates.
   * @param store - Store to filter.
   * @param target - The identifier of which the acl rules need to be known.
   * @param directAcl - If the store contains triples from the direct acl resource of the target or not.
   *                    Determines if `acl:accessTo` or `acl:default` are used.
   *
   * @returns A store containing the relevant triples for the given target.
   */
  private async filterStore(store: Store, target: string, directAcl: boolean): Promise<Store> {
    // Find subjects that occur with a given predicate/object, and collect all their triples
    const subjectData = new Store();
    const subjects = store.getSubjects(directAcl ? ACL.terms.accessTo : ACL.terms.default, target, null);
    for (const subject of subjects) {
      subjectData.addQuads(store.getQuads(subject, null, null, null));
    }
    return subjectData;
  }
}
