export type CandidateChangeKind = "add" | "modify" | "delete";

export interface CandidatePathChange {
  readonly path: string;
  readonly kind: CandidateChangeKind;
}

export interface CandidateManifest {
  readonly treeDigest: string;
  readonly patchDigest: string;
  readonly changedPaths: readonly string[];
  readonly changes: readonly CandidatePathChange[];
}

export class InvalidCandidateManifestError extends Error {
  constructor(message: string) {
    super(message);
    this.name = "InvalidCandidateManifestError";
  }
}

function nonEmpty(value: string, field: string): void {
  if (value.length === 0) throw new InvalidCandidateManifestError(`${field} must not be empty`);
}

export function createCandidateManifest(input: CandidateManifest): CandidateManifest {
  nonEmpty(input.treeDigest, "treeDigest");
  nonEmpty(input.patchDigest, "patchDigest");

  const paths = [...input.changedPaths];
  const changes = input.changes.map((change) => ({ ...change }));
  if (new Set(paths).size !== paths.length) {
    throw new InvalidCandidateManifestError("changedPaths must not contain duplicates");
  }
  for (const path of paths) nonEmpty(path, "changed path");
  for (const change of changes) nonEmpty(change.path, "change path");

  const changePaths = changes.map((change) => change.path);
  if (new Set(changePaths).size !== changePaths.length) {
    throw new InvalidCandidateManifestError("changes must contain exactly one record per path");
  }
  const declared = new Set(paths);
  const structured = new Set(changePaths);
  const missing = paths.filter((path) => !structured.has(path));
  const extra = changePaths.filter((path) => !declared.has(path));
  if (missing.length > 0 || extra.length > 0) {
    throw new InvalidCandidateManifestError(
      `candidate path metadata is incomplete: missing=[${missing.join(",")}] extra=[${extra.join(",")}]`,
    );
  }

  for (const change of changes) Object.freeze(change);
  Object.freeze(paths);
  Object.freeze(changes);
  return Object.freeze({
    treeDigest: input.treeDigest,
    patchDigest: input.patchDigest,
    changedPaths: paths,
    changes,
  });
}

export function candidateManifestEquals(
  left: CandidateManifest,
  right: CandidateManifest,
): boolean {
  if (left.treeDigest !== right.treeDigest || left.patchDigest !== right.patchDigest) return false;
  if (
    left.changedPaths.length !== right.changedPaths.length ||
    left.changes.length !== right.changes.length
  )
    return false;
  const rightPaths = new Set(right.changedPaths);
  if (left.changedPaths.some((path) => !rightPaths.has(path))) return false;
  const rightChanges = new Map(right.changes.map((change) => [change.path, change.kind]));
  return left.changes.every((change) => rightChanges.get(change.path) === change.kind);
}

export function assertCandidateIdentity(
  candidateDigest: string,
  manifest: CandidateManifest,
): void {
  if (candidateDigest !== manifest.treeDigest) {
    throw new InvalidCandidateManifestError(
      `candidate digest ${candidateDigest} does not match manifest tree ${manifest.treeDigest}`,
    );
  }
  createCandidateManifest(manifest);
}

export function assertCandidateIdentityImmutable(
  previousDigest: string,
  previousManifest: CandidateManifest,
  nextDigest: string,
  nextManifest: CandidateManifest,
): void {
  assertCandidateIdentity(previousDigest, previousManifest);
  assertCandidateIdentity(nextDigest, nextManifest);
  if (previousDigest !== nextDigest || !candidateManifestEquals(previousManifest, nextManifest)) {
    throw new InvalidCandidateManifestError("ChangeSet candidate identity is immutable");
  }
}
