import { PG_INSUFFICIENT_PRIVILEGE } from "@drdgvhbh/postgres-error-codes";
import { literal } from "pg-format";
import postgres from "postgres";
import { assert } from "../../../../../../shared/src/asserts.js";
import { jsonObjectSchema, stringify } from "../../../../../../shared/src/bigint-json.js";
import { parse } from "../../../../../../shared/src/valita.js";
import { Default } from "../../../../db/postgres-replica-identity-enum.js";
import { appSchema, upstreamSchema, check } from "../../../../types/shards.js";
import { id } from "../../../../types/sql.js";
import { createEventTriggerStatements } from "./ddl.js";
import { publishedSchema, getPublicationInfo } from "./published.js";
import { validate } from "./validation.js";
import { object, boolean, array, string } from "@badrap/valita";
const VALID_PUBLICATION_NAME = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
function validatePublicationName(name) {
  if (!VALID_PUBLICATION_NAME.test(name)) {
    throw new Error(
      `Invalid publication name "${name}". Publication names must start with a letter or underscore and contain only letters, digits, and underscores.`
    );
  }
  if (name.length > 63) {
    throw new Error(
      `Publication name "${name}" exceeds PostgreSQL's 63-character identifier limit.`
    );
  }
}
function internalPublicationPrefix({ appID }) {
  return `_${appID}_`;
}
function legacyReplicationSlot({ appID, shardNum }) {
  return `${appID}_${shardNum}`;
}
function replicationSlotPrefix(shard) {
  const { appID, shardNum } = check(shard);
  return `${appID}_${shardNum}_`;
}
function replicationSlotExpression(shard) {
  return `${replicationSlotPrefix(shard)}%`.replaceAll("_", "\\_");
}
function newReplicationSlot(shard) {
  return replicationSlotPrefix(shard) + Date.now();
}
function defaultPublicationName(appID, shardID) {
  return `_${appID}_public_${shardID}`;
}
function metadataPublicationName(appID, shardID) {
  return `_${appID}_metadata_${shardID}`;
}
function globalSetup(appID) {
  const app = id(appSchema(appID));
  return (
    /*sql*/
    `
  CREATE SCHEMA IF NOT EXISTS ${app};

  CREATE TABLE IF NOT EXISTS ${app}.permissions (
    "permissions" JSONB,
    "hash"        TEXT,

    -- Ensure that there is only a single row in the table.
    -- Application code can be agnostic to this column, and
    -- simply invoke UPDATE statements on the version columns.
    "lock" BOOL PRIMARY KEY DEFAULT true CHECK (lock)
  );

  CREATE OR REPLACE FUNCTION ${app}.set_permissions_hash()
  RETURNS TRIGGER AS $$
  BEGIN
      NEW.hash = md5(NEW.permissions::text);
      RETURN NEW;
  END;
  $$ LANGUAGE plpgsql;

  CREATE OR REPLACE TRIGGER on_set_permissions 
    BEFORE INSERT OR UPDATE ON ${app}.permissions
    FOR EACH ROW
    EXECUTE FUNCTION ${app}.set_permissions_hash();

  INSERT INTO ${app}.permissions (permissions) VALUES (NULL) ON CONFLICT DO NOTHING;
`
  );
}
async function ensureGlobalTables(db, appID) {
  await db.unsafe(globalSetup(appID));
}
function getClientsTableDefinition(schema) {
  return (
    /*sql*/
    `
  CREATE TABLE ${schema}."clients" (
    "clientGroupID"  TEXT NOT NULL,
    "clientID"       TEXT NOT NULL,
    "lastMutationID" BIGINT NOT NULL,
    "userID"         TEXT,
    PRIMARY KEY("clientGroupID", "clientID")
  );`
  );
}
function getMutationsTableDefinition(schema) {
  return (
    /*sql*/
    `
  CREATE TABLE ${schema}."mutations" (
    "clientGroupID"  TEXT NOT NULL,
    "clientID"       TEXT NOT NULL,
    "mutationID"     BIGINT NOT NULL,
    "result"         JSON NOT NULL,
    PRIMARY KEY("clientGroupID", "clientID", "mutationID")
  );`
  );
}
const SHARD_CONFIG_TABLE = "shardConfig";
function shardSetup(shardConfig, metadataPublication) {
  const app = id(appSchema(shardConfig));
  const shard = id(upstreamSchema(shardConfig));
  const pubs = [...shardConfig.publications].sort();
  assert(
    pubs.includes(metadataPublication),
    () => `Publications must include ${metadataPublication}`
  );
  return (
    /*sql*/
    `
  CREATE SCHEMA IF NOT EXISTS ${shard};

  ${getClientsTableDefinition(shard)}
  ${getMutationsTableDefinition(shard)}

  DROP PUBLICATION IF EXISTS ${id(metadataPublication)};
  CREATE PUBLICATION ${id(metadataPublication)}
    FOR TABLE ${app}."permissions", TABLE ${shard}."clients", ${shard}."mutations";

  CREATE TABLE ${shard}."${SHARD_CONFIG_TABLE}" (
    "publications"  TEXT[] NOT NULL,
    "ddlDetection"  BOOL NOT NULL,

    -- Ensure that there is only a single row in the table.
    "lock" BOOL PRIMARY KEY DEFAULT true CHECK (lock)
  );

  INSERT INTO ${shard}."${SHARD_CONFIG_TABLE}" (
      "publications",
      "ddlDetection" 
    ) VALUES (
      ARRAY[${literal(pubs)}], 
      false  -- set in SAVEPOINT with triggerSetup() statements
    );

  CREATE TABLE ${shard}.replicas (
    "slot"               TEXT PRIMARY KEY,
    "version"            TEXT NOT NULL,
    "initialSchema"      JSON NOT NULL,
    "initialSyncContext" JSON,
    "subscriberContext"  JSON
  );
  `
  );
}
function dropShard(appID, shardID) {
  const schema = `${appID}_${shardID}`;
  const metadataPublication = metadataPublicationName(appID, shardID);
  const defaultPublication = defaultPublicationName(appID, shardID);
  return (
    /*sql*/
    `
    DROP PUBLICATION IF EXISTS ${id(defaultPublication)};
    DROP PUBLICATION IF EXISTS ${id(metadataPublication)};
    DROP SCHEMA IF EXISTS ${id(schema)} CASCADE;
  `
  );
}
const internalShardConfigSchema = object({
  publications: array(string()),
  ddlDetection: boolean()
});
const replicaSchema = internalShardConfigSchema.extend({
  slot: string(),
  version: string(),
  initialSchema: publishedSchema,
  initialSyncContext: jsonObjectSchema.nullable(),
  subscriberContext: jsonObjectSchema.nullable()
});
function triggerSetup(shard) {
  const schema = id(upstreamSchema(shard));
  return createEventTriggerStatements(shard) + /*sql*/
  `UPDATE ${schema}."shardConfig" SET "ddlDetection" = true;`;
}
async function addReplica(sql, shard, slot, replicaVersion, { tables, indexes }, initialSyncContext) {
  const schema = upstreamSchema(shard);
  const synced = { tables, indexes };
  await sql`
    INSERT INTO ${sql(schema)}.replicas
      ("slot", "version", "initialSchema", "initialSyncContext")
      VALUES (${slot}, ${replicaVersion}, ${synced}, ${initialSyncContext})`;
}
async function getReplicaAtVersion(lc, sql, shard, replicaVersion, context) {
  const schema = sql(upstreamSchema(shard));
  const result = await sql`
    SELECT * FROM ${schema}.replicas JOIN ${schema}."shardConfig" ON true
      WHERE version = ${replicaVersion};
  `;
  if (result.length === 0) {
    const allReplicas = await sql`
      SELECT slot, version, "initialSyncContext", "subscriberContext" 
        FROM ${schema}.replicas`;
    lc.info?.(
      `Replica ${replicaVersion} ` + (context ? `(context: ${stringify(context)}) ` : "") + `not found in: ${stringify(allReplicas)}`
    );
    return null;
  }
  return parse(result[0], replicaSchema, "passthrough");
}
async function getInternalShardConfig(sql, shard) {
  const result = await sql`
    SELECT "publications", "ddlDetection"
      FROM ${sql(upstreamSchema(shard))}."shardConfig";
  `;
  assert(
    result.length === 1,
    () => `Expected exactly one shardConfig row, got ${result.length}`
  );
  return parse(result[0], internalShardConfigSchema, "passthrough");
}
async function setupTablesAndReplication(lc, sql, requested) {
  const { publications } = requested;
  for (const pub of publications) {
    validatePublicationName(pub);
    if (pub.startsWith("_")) {
      throw new Error(
        `Publication names starting with "_" are reserved for internal use.
Please use a different name for publication "${pub}".`
      );
    }
  }
  const allPublications = [];
  if (publications.length) {
    const results = await sql`
    SELECT pubname from pg_publication WHERE pubname IN ${sql(
      publications
    )}`.values();
    if (results.length !== publications.length) {
      throw new Error(
        `Unknown or invalid publications. Specified: [${publications}]. Found: [${results.flat()}]`
      );
    }
    allPublications.push(...publications);
  } else {
    const defaultPublication = defaultPublicationName(
      requested.appID,
      requested.shardNum
    );
    await sql`
      DROP PUBLICATION IF EXISTS ${sql(defaultPublication)}`;
    await sql`
      CREATE PUBLICATION ${sql(defaultPublication)} 
        FOR TABLES IN SCHEMA public
        WITH (publish_via_partition_root = true)`;
    allPublications.push(defaultPublication);
  }
  const metadataPublication = metadataPublicationName(
    requested.appID,
    requested.shardNum
  );
  allPublications.push(metadataPublication);
  const shard = { ...requested, publications: allPublications };
  await sql.unsafe(globalSetup(shard) + shardSetup(shard, metadataPublication));
  const pubs = await getPublicationInfo(sql, allPublications);
  await replicaIdentitiesForTablesWithoutPrimaryKeys(pubs)?.apply(lc, sql);
  await setupTriggers(lc, sql, shard);
}
async function setupTriggers(lc, tx, shard) {
  try {
    await tx.savepoint((sub) => sub.unsafe(triggerSetup(shard)));
  } catch (e) {
    if (!(e instanceof postgres.PostgresError && e.code === PG_INSUFFICIENT_PRIVILEGE)) {
      throw e;
    }
    lc.warn?.(
      `Unable to create event triggers for schema change detection:

"${e.hint ?? e.message}"

Proceeding in degraded mode: schema changes will halt replication,
requiring the replica to be reset (manually or with --auto-reset).`
    );
  }
}
function validatePublications(lc, published) {
  published.publications.forEach((pub) => {
    if (!pub.pubinsert || !pub.pubtruncate || !pub.pubdelete || !pub.pubtruncate) {
      throw new Error(
        `PUBLICATION ${pub.pubname} must publish insert, update, delete, and truncate`
      );
    }
  });
  published.tables.forEach((table) => validate(lc, table));
}
function replicaIdentitiesForTablesWithoutPrimaryKeys(pubs) {
  const replicaIdentities = [];
  for (const table of pubs.tables) {
    if (!table.primaryKey?.length && table.replicaIdentity === Default) {
      const { schema, name: tableName } = table;
      for (const { columns, name: indexName } of pubs.indexes.filter(
        (idx) => idx.schema === schema && idx.tableName === tableName && idx.unique && idx.isImmediate
      )) {
        if (Object.keys(columns).some((col) => !table.columns[col].notNull)) {
          continue;
        }
        replicaIdentities.push({ schema, tableName, indexName });
        break;
      }
    }
  }
  if (replicaIdentities.length === 0) {
    return void 0;
  }
  return {
    apply: async (lc, sql) => {
      for (const { schema, tableName, indexName } of replicaIdentities) {
        lc.info?.(
          `setting "${indexName}" as the REPLICA IDENTITY for "${tableName}"`
        );
        await sql`
        ALTER TABLE ${sql(schema)}.${sql(tableName)} 
          REPLICA IDENTITY USING INDEX ${sql(indexName)}`;
      }
    }
  };
}
export {
  SHARD_CONFIG_TABLE,
  addReplica,
  dropShard,
  ensureGlobalTables,
  getClientsTableDefinition,
  getInternalShardConfig,
  getMutationsTableDefinition,
  getReplicaAtVersion,
  internalPublicationPrefix,
  legacyReplicationSlot,
  metadataPublicationName,
  newReplicationSlot,
  replicaIdentitiesForTablesWithoutPrimaryKeys,
  replicationSlotExpression,
  replicationSlotPrefix,
  setupTablesAndReplication,
  setupTriggers,
  shardSetup,
  validatePublicationName,
  validatePublications
};
//# sourceMappingURL=shard.js.map
