import { assert } from "../../../shared/src/asserts.js";
import { must } from "../../../shared/src/must.js";
import { parse } from "../../../shared/src/valita.js";
import { primaryKeyValueSchema } from "../../../zero-protocol/src/primary-key.js";
import { bindStaticParameters, buildPipeline } from "../../../zql/src/builder/builder.js";
import { consume } from "../../../zql/src/ivm/stream.js";
import { simplifyCondition } from "../../../zql/src/query/expression.js";
import { asQueryInternals } from "../../../zql/src/query/query-internals.js";
import { newStaticQuery } from "../../../zql/src/query/static-query.js";
import { sql, compile } from "../../../zqlite/src/internal/sql.js";
import { TableSource, fromSQLiteTypes } from "../../../zqlite/src/table-source.js";
import { computeZqlSpecs } from "../db/lite-tables.js";
import { StatementRunner } from "../db/statements.js";
import { mapLiteDataTypeToZqlSchemaValue } from "../types/lite.js";
import { getSchema, reloadPermissionsIfChanged } from "./load-permissions.js";
class WriteAuthorizerImpl {
  #schema;
  #replica;
  #builderDelegate;
  #tableSpecs;
  #tables = /* @__PURE__ */ new Map();
  #statementRunner;
  #lc;
  #appID;
  #logConfig;
  #cgStorage;
  #config;
  #loadedPermissions = null;
  constructor(lc, config, replica, appID, cgID, writeAuthzStorage) {
    this.#appID = appID;
    this.#config = config;
    this.#lc = lc.withContext("class", "WriteAuthorizerImpl");
    this.#logConfig = config.log;
    this.#schema = getSchema(this.#lc, replica);
    this.#replica = replica;
    this.#cgStorage = writeAuthzStorage.createClientGroupStorage(cgID);
    this.#builderDelegate = {
      getSource: (name) => this.#getSource(name),
      createStorage: () => this.#cgStorage.createStorage(),
      decorateSourceInput: (input) => input,
      decorateInput: (input) => input,
      addEdge() {
      },
      decorateFilterInput: (input) => input
    };
    this.#tableSpecs = computeZqlSpecs(this.#lc, replica, {
      includeBackfillingColumns: false
    });
    this.#statementRunner = new StatementRunner(replica);
    this.reloadPermissions();
  }
  reloadPermissions() {
    this.#loadedPermissions = reloadPermissionsIfChanged(
      this.#lc,
      this.#statementRunner,
      this.#appID,
      this.#loadedPermissions,
      this.#config
    ).permissions;
  }
  destroy() {
    this.#cgStorage.destroy();
  }
  async canPreMutation(authData, ops) {
    for (const op of ops) {
      switch (op.op) {
        case "insert":
          break;
        case "update":
          if (!await this.#canUpdate("preMutation", authData, op)) {
            return false;
          }
          break;
        case "delete":
          if (!await this.#canDelete("preMutation", authData, op)) {
            return false;
          }
          break;
      }
    }
    return true;
  }
  async canPostMutation(authData, ops) {
    this.#statementRunner.beginConcurrent();
    try {
      for (const op of ops) {
        const source = this.#getSource(op.tableName);
        switch (op.op) {
          case "insert": {
            consume(
              source.push({
                type: "add",
                row: op.value
              })
            );
            break;
          }
          // TODO(mlaw): what if someone updates the same thing twice?
          // TODO(aa): It seems like it will just work? source.push()
          // is going to push the row into the table source, and then the
          // next requirePreMutationRow will just return the row that was
          // pushed in.
          case "update": {
            consume(
              source.push({
                type: "edit",
                oldRow: this.#requirePreMutationRow(op),
                row: op.value
              })
            );
            break;
          }
          case "delete": {
            consume(
              source.push({
                type: "remove",
                row: this.#requirePreMutationRow(op)
              })
            );
            break;
          }
        }
      }
      for (const op of ops) {
        switch (op.op) {
          case "insert":
            if (!await this.#canInsert("postMutation", authData, op)) {
              return false;
            }
            break;
          case "update":
            if (!await this.#canUpdate("postMutation", authData, op)) {
              return false;
            }
            break;
          case "delete":
            break;
        }
      }
    } finally {
      this.#statementRunner.rollback();
    }
    return true;
  }
  normalizeOps(ops) {
    return ops.map((op) => {
      if (op.op === "upsert") {
        const preMutationRow = this.#getPreMutationRow(op);
        if (preMutationRow) {
          return {
            op: "update",
            tableName: op.tableName,
            primaryKey: op.primaryKey,
            value: op.value
          };
        }
        return {
          op: "insert",
          tableName: op.tableName,
          primaryKey: op.primaryKey,
          value: op.value
        };
      }
      return op;
    });
  }
  validateTableNames(ops) {
    for (const op of ops) {
      if (!this.#tableSpecs.has(op.tableName)) {
        throw new Error(`Table '${op.tableName}' is not a valid table.`);
      }
    }
  }
  #canInsert(phase, authData, op) {
    return this.#timedCanDo(phase, "insert", authData, op);
  }
  #canUpdate(phase, authData, op) {
    return this.#timedCanDo(phase, "update", authData, op);
  }
  #canDelete(phase, authData, op) {
    return this.#timedCanDo(phase, "delete", authData, op);
  }
  /**
   * Gets schema-defined primary key and validates that operation contains required PK values.
   *
   * @returns Record where keys are column names and values are client-provided values
   * @throws Error if operation value is missing required primary key columns
   */
  #getPrimaryKey(tableName, opValue) {
    const tableSpec = this.#tableSpecs.get(tableName);
    if (!tableSpec) {
      throw new Error(`Table ${tableName} not found`);
    }
    const columns = tableSpec.tableSpec.primaryKey;
    const values = {};
    for (const col of columns) {
      const val = opValue[col];
      if (val === void 0) {
        throw new Error(
          `Primary key column '${col}' is missing from operation value for table ${tableName}`
        );
      }
      values[col] = val;
    }
    return values;
  }
  #getSource(tableName) {
    let source = this.#tables.get(tableName);
    if (source) {
      return source;
    }
    const tableSpec = this.#tableSpecs.get(tableName);
    if (!tableSpec) {
      throw new Error(`Table ${tableName} not found`);
    }
    const { columns, primaryKey } = tableSpec.tableSpec;
    assert(
      primaryKey.length,
      () => `Table ${tableName} must have a primary key`
    );
    source = new TableSource(
      this.#lc,
      this.#logConfig,
      this.#replica,
      tableName,
      Object.fromEntries(
        Object.entries(columns).map(([name, { dataType }]) => [
          name,
          mapLiteDataTypeToZqlSchemaValue(dataType)
        ])
      ),
      [primaryKey[0], ...primaryKey.slice(1)]
    );
    this.#tables.set(tableName, source);
    return source;
  }
  async #timedCanDo(phase, action, authData, op) {
    const start = performance.now();
    try {
      const ret = await this.#canDo(phase, action, authData, op);
      return ret;
    } finally {
      this.#lc.info?.(
        "action:",
        action,
        "duration:",
        performance.now() - start,
        "tableName:",
        op.tableName,
        "primaryKey:",
        op.primaryKey
      );
    }
  }
  /**
   * Evaluation order is from static to dynamic, broad to specific.
   * table -> column -> row -> cell.
   *
   * If any step fails, the entire operation is denied.
   *
   * That is, table rules supersede column rules, which supersede row rules,
   *
   * All steps must allow for the operation to be allowed.
   */
  async #canDo(phase, action, authData, op) {
    const rules = must(this.#loadedPermissions)?.permissions?.tables?.[op.tableName];
    const rowPolicies = rules?.row;
    let rowQuery = newStaticQuery(this.#schema, op.tableName);
    const primaryKeyValues = this.#getPrimaryKey(op.tableName, op.value);
    for (const pk in primaryKeyValues) {
      rowQuery = rowQuery.where(pk, "=", primaryKeyValues[pk]);
    }
    let applicableRowPolicy;
    switch (action) {
      case "insert":
        if (phase === "postMutation") {
          applicableRowPolicy = rowPolicies?.insert;
        }
        break;
      case "update":
        if (phase === "preMutation") {
          applicableRowPolicy = rowPolicies?.update?.preMutation;
        } else if (phase === "postMutation") {
          applicableRowPolicy = rowPolicies?.update?.postMutation;
        }
        break;
      case "delete":
        if (phase === "preMutation") {
          applicableRowPolicy = rowPolicies?.delete;
        }
        break;
    }
    const cellPolicies = rules?.cell;
    const applicableCellPolicies = [];
    if (cellPolicies) {
      for (const [column, policy] of Object.entries(cellPolicies)) {
        if (action === "update" && op.value[column] === void 0) {
          continue;
        }
        switch (action) {
          case "insert":
            if (policy.insert && phase === "postMutation") {
              applicableCellPolicies.push(policy.insert);
            }
            break;
          case "update":
            if (phase === "preMutation" && policy.update?.preMutation) {
              applicableCellPolicies.push(policy.update.preMutation);
            }
            if (phase === "postMutation" && policy.update?.postMutation) {
              applicableCellPolicies.push(policy.update.postMutation);
            }
            break;
          case "delete":
            if (policy.delete && phase === "preMutation") {
              applicableCellPolicies.push(policy.delete);
            }
            break;
        }
      }
    }
    if (!await this.#passesPolicyGroup(
      applicableRowPolicy,
      applicableCellPolicies,
      authData,
      rowQuery
    )) {
      this.#lc.warn?.(
        `Permission check failed for ${JSON.stringify(
          op
        )}, action ${action}, phase ${phase}, authData: ${JSON.stringify(
          authData
        )}, rowPolicies: ${JSON.stringify(
          applicableRowPolicy
        )}, cellPolicies: ${JSON.stringify(applicableCellPolicies)}`
      );
      return false;
    }
    return true;
  }
  #getPreMutationRow(op) {
    const { value } = op;
    const primaryKeyValues = this.#getPrimaryKey(op.tableName, value);
    const spec = this.#tableSpecs.get(op.tableName);
    if (!spec) {
      throw new Error(`Table ${op.tableName} not found`);
    }
    const conditions = [];
    const values = [];
    for (const pk in primaryKeyValues) {
      conditions.push(sql`${sql.ident(pk)}=?`);
      values.push(parse(primaryKeyValues[pk], primaryKeyValueSchema));
    }
    const ret = this.#statementRunner.get(
      compile(
        sql`SELECT ${sql.join(
          Object.keys(spec.zqlSpec).map((c) => sql.ident(c)),
          sql`,`
        )} FROM ${sql.ident(op.tableName)} WHERE ${sql.join(
          conditions,
          sql` AND `
        )}`
      ),
      ...values
    );
    if (ret === void 0) {
      return ret;
    }
    return fromSQLiteTypes(spec.zqlSpec, ret, op.tableName);
  }
  #requirePreMutationRow(op) {
    const ret = this.#getPreMutationRow(op);
    assert(
      ret !== void 0,
      () => `Pre-mutation row not found for ${JSON.stringify(op.value)}`
    );
    return ret;
  }
  async #passesPolicyGroup(applicableRowPolicy, applicableCellPolicies, authData, rowQuery) {
    if (!await this.#passesPolicy(applicableRowPolicy, authData, rowQuery)) {
      return false;
    }
    for (const policy of applicableCellPolicies) {
      if (!await this.#passesPolicy(policy, authData, rowQuery)) {
        return false;
      }
    }
    return true;
  }
  /**
   * Defaults to *false* if the policy is empty. At least one rule has to pass
   * for the policy to pass.
   */
  #passesPolicy(policy, authData, rowQuery) {
    if (policy === void 0) {
      return false;
    }
    if (policy.length === 0) {
      return false;
    }
    let rowQueryAst = asQueryInternals(rowQuery).ast;
    rowQueryAst = bindStaticParameters(
      {
        ...rowQueryAst,
        where: updateWhere(rowQueryAst.where, policy)
      },
      {
        authData,
        preMutationRow: void 0
      }
    );
    const input = buildPipeline(rowQueryAst, this.#builderDelegate, "query-id");
    try {
      const res = input.fetch({});
      for (const _ of res) {
        return true;
      }
    } finally {
      input.destroy();
    }
    return false;
  }
}
function updateWhere(where, policy) {
  assert(where, "A where condition must exist for RowQuery");
  return simplifyCondition({
    type: "and",
    conditions: [
      where,
      {
        type: "or",
        conditions: policy.map(([action, rule]) => {
          assert(action, "action must be defined in policy");
          return rule;
        })
      }
    ]
  });
}
export {
  WriteAuthorizerImpl
};
//# sourceMappingURL=write-authorizer.js.map
