import {parsePermissions} from 'app/common/ACLPermissions';
import {ILogger} from 'app/common/BaseAPI';
import {DocData} from 'app/common/DocData';
import {AclMatchFunc, ParsedAclFormula, RulePart, RuleSet, UserAttributeRule} from 'app/common/GranularAccessClause';
import {getSetMapValue} from 'app/common/gutil';
import {MetaRowRecord} from 'app/common/TableData';
import {decodeObject} from 'app/plugin/objtypes';
import sortBy = require('lodash/sortBy');

const defaultMatchFunc: AclMatchFunc = () => true;

export const SPECIAL_RULES_TABLE_ID = '*SPECIAL';

// This is the hard-coded default RuleSet that's added to any user-created default rule.
const DEFAULT_RULE_SET: RuleSet = {
  tableId: '*',
  colIds: '*',
  body: [{
    aclFormula: "user.Access in [EDITOR, OWNER]",
    matchFunc:  (input) => ['editors', 'owners'].includes(String(input.user.Access)),
    permissions: parsePermissions('all'),
    permissionsText: 'all',
  }, {
    aclFormula: "user.Access in [VIEWER]",
    matchFunc:  (input) => ['viewers'].includes(String(input.user.Access)),
    permissions: parsePermissions('+R-CUDS'),
    permissionsText: '+R',
  }, {
    aclFormula: "",
    matchFunc: defaultMatchFunc,
    permissions: parsePermissions('none'),
    permissionsText: 'none',
  }],
};

const SPECIAL_RULE_SETS: Record<string, RuleSet> = {
  AccessRules: {
    tableId: SPECIAL_RULES_TABLE_ID,
    colIds: ['AccessRules'],
    body: [{
      aclFormula: "user.Access in [OWNER]",
      matchFunc:  (input) => ['owners'].includes(String(input.user.Access)),
      permissions: parsePermissions('+R'),
      permissionsText: '+R',
    }, {
      aclFormula: "",
      matchFunc: defaultMatchFunc,
      permissions: parsePermissions('-R'),
      permissionsText: '-R',
    }],
  },
  FullCopies: {
    tableId: SPECIAL_RULES_TABLE_ID,
    colIds: ['FullCopies'],
    body: [{
      aclFormula: "user.Access in [OWNER]",
      matchFunc:  (input) => ['owners'].includes(String(input.user.Access)),
      permissions: parsePermissions('+R'),
      permissionsText: '+R',
    }, {
      aclFormula: "",
      matchFunc: defaultMatchFunc,
      permissions: parsePermissions('-R'),
      permissionsText: '-R',
    }],
  }
};

// If the user-created rules become dysfunctional, we can swap in this emergency set.
// It grants full access to owners, and no access to anyone else.
const EMERGENCY_RULE_SET: RuleSet = {
  tableId: '*',
  colIds: '*',
  body: [{
    aclFormula: "user.Access in [OWNER]",
    matchFunc:  (input) => ['owners'].includes(String(input.user.Access)),
    permissions: parsePermissions('all'),
    permissionsText: 'all',
  }, {
    aclFormula: "",
    matchFunc: defaultMatchFunc,
    permissions: parsePermissions('none'),
    permissionsText: 'none',
  }],
};

export class ACLRuleCollection {
  // Store error if one occurs while reading rules.  Rules are replaced with emergency rules
  // in this case.
  public ruleError: Error|undefined;

  // In the absence of rules, some checks are skipped. For now this is important to maintain all
  // existing behavior. TODO should make sure checking access against default rules is equivalent
  // and efficient.
  private _haveRules = false;

  // Map of tableId to list of column RuleSets (those with colIds other than '*')
  // Includes also SPECIAL_RULES_TABLE_ID.
  private _columnRuleSets = new Map<string, RuleSet[]>();

  // Maps 'tableId:colId' to one of the RuleSets in the list _columnRuleSets.get(tableId).
  private _tableColumnMap = new Map<string, RuleSet>();

  // Rules for SPECIAL_RULES_TABLE_ID "columns".
  private _specialRuleSets = new Map<string, RuleSet>();

  // Map of tableId to the single default RuleSet for the table (colIds of '*')
  private _tableRuleSets = new Map<string, RuleSet>();

  // The default RuleSet (tableId '*', colIds '*')
  private _defaultRuleSet: RuleSet = DEFAULT_RULE_SET;

  // List of all tableIds mentioned in rules.
  private _tableIds: string[] = [];

  // Maps name to the corresponding UserAttributeRule.
  private _userAttributeRules = new Map<string, UserAttributeRule>();

  // Whether there are ANY user-defined rules.
  public haveRules(): boolean {
    return this._haveRules;
  }

  // Return the RuleSet for "tableId:colId", or undefined if there isn't one for this column.
  public getColumnRuleSet(tableId: string, colId: string): RuleSet|undefined {
    if (tableId === SPECIAL_RULES_TABLE_ID) { return this._specialRuleSets.get(colId); }
    return this._tableColumnMap.get(`${tableId}:${colId}`);
  }

  // Return all RuleSets for "tableId:<any colId>", not including "tableId:*".
  public getAllColumnRuleSets(tableId: string): RuleSet[] {
    return this._columnRuleSets.get(tableId) || [];
  }

  // Return the RuleSet for "tableId:*".
  public getTableDefaultRuleSet(tableId: string): RuleSet|undefined {
    return this._tableRuleSets.get(tableId);
  }

  // Return the RuleSet for "*:*".
  public getDocDefaultRuleSet(): RuleSet {
    return this._defaultRuleSet;
  }

  // Return the list of all tableId mentions in ACL rules.
  public getAllTableIds(): string[] {
    return this._tableIds;
  }

  // Returns a Map of user attribute name to the corresponding UserAttributeRule.
  public getUserAttributeRules(): Map<string, UserAttributeRule> {
    return this._userAttributeRules;
  }

  /**
   * Update granular access from DocData.
   */
  public async update(docData: DocData, options: ReadAclOptions) {
    const {ruleSets, userAttributes} = this._safeReadAclRules(docData, options);

    // Build a map of user characteristics rules.
    const userAttributeMap = new Map<string, UserAttributeRule>();
    for (const userAttr of userAttributes) {
      userAttributeMap.set(userAttr.name, userAttr);
    }

    // Build maps of ACL rules.
    const colRuleSets = new Map<string, RuleSet[]>();
    const tableColMap = new Map<string, RuleSet>();
    const tableRuleSets = new Map<string, RuleSet>();
    const tableIds = new Set<string>();
    let defaultRuleSet: RuleSet = DEFAULT_RULE_SET;

    // Collect special rules, combining them with corresponding defaults.
    const specialRuleSets = new Map<string, RuleSet>(Object.entries(SPECIAL_RULE_SETS));
    for (const ruleSet of ruleSets) {
      if (ruleSet.tableId === SPECIAL_RULES_TABLE_ID) {
        const specialType = String(ruleSet.colIds);
        const specialDefault = specialRuleSets.get(specialType);
        if (!specialDefault) {
          throw new Error(`Invalid rule for ${ruleSet.tableId}:${ruleSet.colIds}`);
        }
        specialRuleSets.set(specialType, {...ruleSet, body: [...ruleSet.body, ...specialDefault.body]});
      }
    }

    // Insert the special rule sets into colRuleSets.
    for (const ruleSet of specialRuleSets.values()) {
      getSetMapValue(colRuleSets, SPECIAL_RULES_TABLE_ID, () => []).push(ruleSet);
    }

    this._haveRules = (ruleSets.length > 0);
    for (const ruleSet of ruleSets) {
      if (ruleSet.tableId === '*') {
        if (ruleSet.colIds === '*') {
          defaultRuleSet = {
            ...ruleSet,
            body: [...ruleSet.body, ...DEFAULT_RULE_SET.body],
          };
        } else {
          // tableId of '*' cannot list particular columns.
          throw new Error(`Invalid rule for tableId ${ruleSet.tableId}, colIds ${ruleSet.colIds}`);
        }
      } else if (ruleSet.tableId === SPECIAL_RULES_TABLE_ID) {
        // Skip, since we handled these separately earlier.
      } else if (ruleSet.colIds === '*') {
        tableIds.add(ruleSet.tableId);
        if (tableRuleSets.has(ruleSet.tableId)) {
          throw new Error(`Invalid duplicate default rule for ${ruleSet.tableId}`);
        }
        tableRuleSets.set(ruleSet.tableId, ruleSet);
      } else {
        tableIds.add(ruleSet.tableId);
        getSetMapValue(colRuleSets, ruleSet.tableId, () => []).push(ruleSet);
        for (const colId of ruleSet.colIds) {
          tableColMap.set(`${ruleSet.tableId}:${colId}`, ruleSet);
        }
      }
    }

    // Update GranularAccess state.
    this._columnRuleSets = colRuleSets;
    this._tableColumnMap = tableColMap;
    this._tableRuleSets = tableRuleSets;
    this._defaultRuleSet = defaultRuleSet;
    this._tableIds = [...tableIds];
    this._userAttributeRules = userAttributeMap;
    this._specialRuleSets = specialRuleSets;
  }

  /**
   * Check that all references to table and column IDs in ACL rules are valid.
   */
  public checkDocEntities(docData: DocData) {
    const tablesTable = docData.getMetaTable('_grist_Tables');
    const columnsTable = docData.getMetaTable('_grist_Tables_column');

    // Collect valid tableIds and check rules against those.
    const validTableIds = new Set(tablesTable.getColValues('tableId'));
    const invalidTables = this.getAllTableIds().filter(t => !validTableIds.has(t));
    if (invalidTables.length > 0) {
      throw new Error(`Invalid tables in rules: ${invalidTables.join(', ')}`);
    }

    // Collect valid columns, grouped by tableRef (rowId of table record).
    const validColumns = new Map<number, Set<string>>();   // Map from tableRef to set of colIds.
    const colTableRefs = columnsTable.getColValues('parentId');
    for (const [i, colId] of columnsTable.getColValues('colId').entries()) {
      getSetMapValue(validColumns, colTableRefs[i], () => new Set()).add(colId);
    }

    // For each table, check that any explicitly mentioned columns are valid.
    for (const tableId of this.getAllTableIds()) {
      const tableRef = tablesTable.findRow('tableId', tableId);
      const validTableCols = validColumns.get(tableRef);
      for (const ruleSet of this.getAllColumnRuleSets(tableId)) {
        if (Array.isArray(ruleSet.colIds)) {
          const invalidColIds = ruleSet.colIds.filter(c => !validTableCols?.has(c));
          if (invalidColIds.length > 0) {
            throw new Error(`Invalid columns in rules for table ${tableId}: ${invalidColIds.join(', ')}`);
          }
        }
      }
    }

    // Check for valid tableId/lookupColId combinations in UserAttribute rules.
    const invalidUAColumns: string[] = [];
    for (const rule of this.getUserAttributeRules().values()) {
      const tableRef = tablesTable.findRow('tableId', rule.tableId);
      const colRef = columnsTable.findMatchingRowId({parentId: tableRef, colId: rule.lookupColId});
      if (!colRef) {
        invalidUAColumns.push(`${rule.tableId}.${rule.lookupColId}`);
      }
    }
    if (invalidUAColumns.length > 0) {
      throw new Error(`Invalid columns in User Attribute rules: ${invalidUAColumns.join(', ')}`);
    }
  }

  private _safeReadAclRules(docData: DocData, options: ReadAclOptions): ReadAclResults {
    try {
      this.ruleError = undefined;
      return readAclRules(docData, options);
    } catch (e) {
      this.ruleError = e;  // Report the error indirectly.
      return {ruleSets: [EMERGENCY_RULE_SET], userAttributes: []};
    }
  }
}

export interface ReadAclOptions {
  log: ILogger;     // For logging warnings during rule processing.
  compile?: (parsed: ParsedAclFormula) => AclMatchFunc;
  // If true, call addHelperCols to add helper columns of restricted columns to rule sets.
  // Used in the server for extra filtering, but not in the client, because:
  // 1. They would show in the UI
  // 2. They would be saved back after editing, causing them to accumulate
  includeHelperCols?: boolean;
}

export interface ReadAclResults {
  ruleSets: RuleSet[];
  userAttributes: UserAttributeRule[];
}

/**
 * For each column in colIds, return the colIds of any hidden helper columns it has,
 * i.e. display columns of references, and conditional formatting rule columns.
 */
function getHelperCols(docData: DocData, tableId: string, colIds: string[], log: ILogger): string[] {
  const tablesTable = docData.getMetaTable('_grist_Tables');
  const columnsTable = docData.getMetaTable('_grist_Tables_column');
  const fieldsTable = docData.getMetaTable('_grist_Views_section_field');

  const tableRef = tablesTable.findRow('tableId', tableId);
  if (!tableRef) {
    return [];
  }

  const result: string[] = [];
  for (const colId of colIds) {
    const [column] = columnsTable.filterRecords({parentId: tableRef, colId});
    if (!column) {
      continue;
    }

    function addColsFromRefs(colRefs: unknown) {
      if (!Array.isArray(colRefs)) {
        return;
      }
      for (const colRef of colRefs) {
        if (typeof colRef !== 'number') {
          continue;
        }
        const extraCol = columnsTable.getRecord(colRef);
        if (!extraCol) {
          continue;
        }
        if (extraCol.colId.startsWith("gristHelper_") && extraCol.parentId === tableRef) {
          result.push(extraCol.colId);
        } else {
          log.error(`Invalid helper column ${extraCol.colId} of ${tableId}:${colId}`);
        }
      }
    }

    function addColsFromMetaRecord(rec: MetaRowRecord<'_grist_Tables_column' | '_grist_Views_section_field'>) {
      addColsFromRefs([rec.displayCol]);
      addColsFromRefs(decodeObject(rec.rules));
    }

    addColsFromMetaRecord(column);
    for (const field of fieldsTable.filterRecords({colRef: column.id})) {
      addColsFromMetaRecord(field);
    }
  }
  return result;
}


/**
 * Parse all ACL rules in the document from DocData into a list of RuleSets and of
 * UserAttributeRules. This is used by both client-side code and server-side.
 */
function readAclRules(docData: DocData, {log, compile, includeHelperCols}: ReadAclOptions): ReadAclResults {
  const resourcesTable = docData.getMetaTable('_grist_ACLResources');
  const rulesTable = docData.getMetaTable('_grist_ACLRules');

  const ruleSets: RuleSet[] = [];
  const userAttributes: UserAttributeRule[] = [];

  // Group rules by resource first, ordering by rulePos. Each group will become a RuleSet.
  const rulesByResource = new Map<number, Array<MetaRowRecord<'_grist_ACLRules'>>>();
  for (const ruleRecord of sortBy(rulesTable.getRecords(), 'rulePos')) {
    getSetMapValue(rulesByResource, ruleRecord.resource, () => []).push(ruleRecord);
  }

  for (const [resourceId, rules] of rulesByResource.entries()) {
    const resourceRec = resourcesTable.getRecord(resourceId);
    if (!resourceRec) {
      throw new Error(`ACLRule ${rules[0].id} refers to an invalid ACLResource ${resourceId}`);
      continue;
    }
    if (!resourceRec.tableId || !resourceRec.colIds) {
      // This should only be the case for the old-style default rule/resource, which we
      // intentionally ignore and skip.
      continue;
    }
    const tableId = resourceRec.tableId;
    const colIds = resourceRec.colIds === '*' ? '*' : resourceRec.colIds.split(',');

    if (includeHelperCols && Array.isArray(colIds)) {
      colIds.push(...getHelperCols(docData, tableId, colIds, log));
    }

    const body: RulePart[] = [];
    for (const rule of rules) {
      if (rule.userAttributes) {
        if (tableId !== '*' || colIds !== '*') {
          throw new Error(`ACLRule ${rule.id} invalid; user attributes must be on the default resource`);
        }
        const parsed = JSON.parse(String(rule.userAttributes));
        // TODO: could perhaps use ts-interface-checker here.
        if (!(parsed && typeof parsed === 'object' &&
          [parsed.name, parsed.tableId, parsed.lookupColId, parsed.charId]
          .every(p => p && typeof p === 'string'))) {
          throw new Error(`User attribute rule ${rule.id} is invalid`);
        }
        parsed.origRecord = rule;
        userAttributes.push(parsed as UserAttributeRule);
      } else if (body.length > 0 && !body[body.length - 1].aclFormula) {
        throw new Error(`ACLRule ${rule.id} invalid because listed after default rule`);
      } else if (rule.aclFormula && !rule.aclFormulaParsed) {
        throw new Error(`ACLRule ${rule.id} invalid because missing its parsed formula`);
      } else {
        const aclFormulaParsed = rule.aclFormula && JSON.parse(String(rule.aclFormulaParsed));
        body.push({
          origRecord: rule,
          aclFormula: String(rule.aclFormula),
          matchFunc: rule.aclFormula ? compile?.(aclFormulaParsed) : defaultMatchFunc,
          memo: aclFormulaParsed && aclFormulaParsed[0] === 'Comment' && aclFormulaParsed[2],
          permissions: parsePermissions(String(rule.permissionsText)),
          permissionsText: String(rule.permissionsText),
        });
      }
    }
    const ruleSet: RuleSet = {tableId, colIds, body};
    ruleSets.push(ruleSet);
  }
  return {ruleSets, userAttributes};
}