import { isApiError } from "./client";

export type RevisionConflictDetail = {
  code: "revision_conflict";
  resource: {
    type: string;
    id: string;
  };
  current_revision: number;
  submitted_base_revision: number;
  retryable: boolean;
  refresh_path?: string;
  current_etag?: string;
};

export type MergeConflict = {
  path: string;
  kind: string;
  baseValue: unknown;
  localValue: unknown;
  currentValue: unknown;
};

export type ThreeWayMergeResult<T = unknown> = {
  value: T;
  conflicts: MergeConflict[];
  appliedPaths: string[];
};

export type ConflictChoice = "current" | "local";

type MissingValue = { readonly missing: true };
const MISSING: MissingValue = Object.freeze({ missing: true });

export function revisionConflictFromError(error: unknown): RevisionConflictDetail | null {
  if (!isApiError(error, 409, 412)) return null;
  try {
    const parsed = JSON.parse(error.body) as { detail?: unknown };
    const detail = asRecord(parsed.detail);
    const resource = asRecord(detail.resource);
    if (
      detail.code !== "revision_conflict"
      || typeof resource.type !== "string"
      || typeof resource.id !== "string"
      || !Number.isInteger(detail.current_revision)
      || !Number.isInteger(detail.submitted_base_revision)
    ) return null;
    return {
      code: "revision_conflict",
      resource: {
        type: resource.type,
        id: resource.id
      },
      current_revision: Number(detail.current_revision),
      submitted_base_revision: Number(detail.submitted_base_revision),
      retryable: detail.retryable !== false,
      refresh_path: typeof detail.refresh_path === "string" ? detail.refresh_path : undefined,
      current_etag: typeof detail.current_etag === "string" ? detail.current_etag : undefined
    };
  } catch {
    return null;
  }
}

export function threeWayMerge<T>(
  base: T,
  local: T,
  current: T,
  options: {
    protectedPaths?: readonly string[];
    stableIdFields?: readonly string[];
  } = {}
): ThreeWayMergeResult<T> {
  return mergeValue(
    cloneValue(base),
    cloneValue(local),
    cloneValue(current),
    "",
    options.protectedPaths ?? [],
    options.stableIdFields ?? ["id"]
  ) as ThreeWayMergeResult<T>;
}

export function applyConflictChoices<T>(
  merge: ThreeWayMergeResult<T>,
  choices: Readonly<Record<string, ConflictChoice>>
): T {
  let result: unknown = cloneValue(merge.value);
  for (const conflict of merge.conflicts) {
    if ((choices[conflict.path] ?? "current") !== "local") continue;
    result = writePath(result, conflict.path, cloneValue(conflict.localValue));
  }
  return result as T;
}

function mergeValue(
  base: unknown,
  local: unknown,
  current: unknown,
  path: string,
  protectedPaths: readonly string[],
  stableIdFields: readonly string[]
): ThreeWayMergeResult {
  const localChanged = !deepEqual(local, base);
  const currentChanged = !deepEqual(current, base);
  if (!localChanged) return result(current);
  if (deepEqual(local, current)) return result(current);
  if (isProtected(path, protectedPaths)) {
    return conflictResult(path, "protected_path", base, local, current);
  }
  if (!currentChanged) return result(local, [], [path || "/"]);
  if (isRecord(base) && isRecord(local) && isRecord(current)) {
    return mergeRecord(base, local, current, path, protectedPaths, stableIdFields);
  }
  if (Array.isArray(base) && Array.isArray(local) && Array.isArray(current)) {
    return mergeList(base, local, current, path, protectedPaths, stableIdFields);
  }
  return conflictResult(path, "same_path_changed", base, local, current);
}

function mergeRecord(
  base: Record<string, unknown>,
  local: Record<string, unknown>,
  current: Record<string, unknown>,
  path: string,
  protectedPaths: readonly string[],
  stableIdFields: readonly string[]
): ThreeWayMergeResult<Record<string, unknown>> {
  const value: Record<string, unknown> = {};
  const conflicts: MergeConflict[] = [];
  const appliedPaths: string[] = [];
  const keys = [...new Set([...Object.keys(current), ...Object.keys(local), ...Object.keys(base)])];
  for (const key of keys) {
    const childPath = joinPath(path, key);
    const merged = mergePresence(
      hasOwn(base, key) ? base[key] : MISSING,
      hasOwn(local, key) ? local[key] : MISSING,
      hasOwn(current, key) ? current[key] : MISSING,
      childPath,
      protectedPaths,
      stableIdFields
    );
    conflicts.push(...merged.conflicts);
    appliedPaths.push(...merged.appliedPaths);
    if (!isMissing(merged.value)) value[key] = merged.value;
  }
  return result(value, conflicts, appliedPaths);
}

function mergePresence(
  base: unknown | MissingValue,
  local: unknown | MissingValue,
  current: unknown | MissingValue,
  path: string,
  protectedPaths: readonly string[],
  stableIdFields: readonly string[]
): ThreeWayMergeResult {
  if (isMissing(local) && isMissing(current)) return result(MISSING);
  if (isMissing(base)) {
    if (isMissing(local)) return result(current);
    if (isMissing(current)) {
      if (isProtected(path, protectedPaths)) {
        return conflictResult(path, "protected_path", undefined, local, undefined);
      }
      return result(local, [], [path]);
    }
    if (deepEqual(local, current)) return result(current);
    return conflictResult(path, "concurrent_add", undefined, local, current);
  }
  if (isMissing(local)) {
    if (deepEqual(current, base)) {
      if (isProtected(path, protectedPaths)) {
        return conflictResult(path, "protected_path", base, undefined, current);
      }
      return result(MISSING, [], [path]);
    }
    return conflictResult(path, "delete_vs_edit", base, undefined, current);
  }
  if (isMissing(current)) {
    if (deepEqual(local, base)) return result(MISSING);
    return conflictResult(path, "edit_vs_delete", base, local, undefined);
  }
  return mergeValue(base, local, current, path, protectedPaths, stableIdFields);
}

function mergeList(
  base: unknown[],
  local: unknown[],
  current: unknown[],
  path: string,
  protectedPaths: readonly string[],
  stableIdFields: readonly string[]
): ThreeWayMergeResult<unknown[]> {
  const identityField = stableIdentityField([base, local, current], stableIdFields);
  if (!identityField) {
    return conflictResult(path, "unkeyed_collection", base, local, current);
  }
  const byId = (items: unknown[]) => new Map(
    items.map((item) => [String((item as Record<string, unknown>)[identityField]), item])
  );
  const baseById = byId(base);
  const localById = byId(local);
  const currentById = byId(current);
  const baseOrder = [...baseById.keys()];
  const localOrder = [...localById.keys()];
  const currentOrder = [...currentById.keys()];
  const localReordered = !deepEqual(commonOrder(localOrder, baseOrder), commonOrder(baseOrder, localOrder));
  const currentReordered = !deepEqual(commonOrder(currentOrder, baseOrder), commonOrder(baseOrder, currentOrder));
  if (localReordered && currentReordered && !deepEqual(localOrder, currentOrder)) {
    return conflictResult(path, "collection_reorder", baseOrder, localOrder, currentOrder);
  }

  const mergedById = new Map<string, unknown>();
  const conflicts: MergeConflict[] = [];
  const appliedPaths: string[] = [];
  const identities = [...new Set([...currentOrder, ...localOrder, ...baseOrder])];
  for (const identity of identities) {
    const merged = mergePresence(
      baseById.has(identity) ? baseById.get(identity) : MISSING,
      localById.has(identity) ? localById.get(identity) : MISSING,
      currentById.has(identity) ? currentById.get(identity) : MISSING,
      joinPath(path, `${identityField}=${identity}`),
      protectedPaths,
      stableIdFields
    );
    conflicts.push(...merged.conflicts);
    appliedPaths.push(...merged.appliedPaths);
    if (!isMissing(merged.value)) mergedById.set(identity, merged.value);
  }

  const orderSource = localReordered && !currentReordered ? localOrder : currentOrder;
  const mergedOrder = orderSource.filter((identity) => mergedById.has(identity));
  for (const identity of identities) {
    if (mergedById.has(identity) && !mergedOrder.includes(identity)) mergedOrder.push(identity);
  }
  return result(
    mergedOrder.map((identity) => mergedById.get(identity)),
    conflicts,
    appliedPaths
  );
}

function writePath(root: unknown, path: string, value: unknown): unknown {
  if (!path || path === "/") return value;
  const segments = path.slice(1).split("/").map(decodePointerSegment);
  const cloned = cloneValue(root);
  let target: unknown = cloned;
  for (let index = 0; index < segments.length - 1; index += 1) {
    target = descend(target, segments[index]);
  }
  const finalSegment = segments[segments.length - 1];
  if (Array.isArray(target)) {
    const itemIndex = arraySegmentIndex(target, finalSegment);
    if (itemIndex < 0) return cloned;
    if (value === undefined) target.splice(itemIndex, 1);
    else target[itemIndex] = value;
  } else if (isRecord(target)) {
    if (value === undefined) delete target[finalSegment];
    else target[finalSegment] = value;
  }
  return cloned;
}

function descend(target: unknown, segment: string): unknown {
  if (Array.isArray(target)) {
    const index = arraySegmentIndex(target, segment);
    return index >= 0 ? target[index] : undefined;
  }
  return isRecord(target) ? target[segment] : undefined;
}

function arraySegmentIndex(items: unknown[], segment: string): number {
  const separator = segment.indexOf("=");
  if (separator > 0) {
    const key = segment.slice(0, separator);
    const value = segment.slice(separator + 1);
    return items.findIndex((item) => isRecord(item) && String(item[key]) === value);
  }
  const index = Number(segment);
  return Number.isInteger(index) ? index : -1;
}

function stableIdentityField(values: unknown[][], candidates: readonly string[]): string | null {
  const allItems = values.flat();
  if (allItems.length === 0 || !allItems.every(isRecord)) return null;
  for (const candidate of candidates) {
    const valid = values.every((items) => {
      const identities = items.map((item) => String((item as Record<string, unknown>)[candidate] ?? "").trim());
      return identities.every(Boolean) && new Set(identities).size === identities.length;
    });
    if (valid) return candidate;
  }
  return null;
}

function commonOrder(left: string[], right: string[]): string[] {
  const rightSet = new Set(right);
  return left.filter((item) => rightSet.has(item));
}

function isProtected(path: string, protectedPaths: readonly string[]): boolean {
  const normalized = path || "/";
  return protectedPaths.some((rawPrefix) => {
    const prefix = rawPrefix.replace(/\/+$/, "") || "/";
    return normalized === prefix || normalized.startsWith(`${prefix}/`);
  });
}

function conflictResult<T>(
  path: string,
  kind: string,
  baseValue: unknown,
  localValue: unknown,
  currentValue: T
): ThreeWayMergeResult<T> {
  return result(currentValue, [{
    path: path || "/",
    kind,
    baseValue,
    localValue,
    currentValue
  }]);
}

function result<T>(
  value: T,
  conflicts: MergeConflict[] = [],
  appliedPaths: string[] = []
): ThreeWayMergeResult<T> {
  return { value, conflicts, appliedPaths };
}

function joinPath(parent: string, segment: string): string {
  const escaped = segment.replace(/~/g, "~0").replace(/\//g, "~1");
  return parent ? `${parent}/${escaped}` : `/${escaped}`;
}

function decodePointerSegment(segment: string): string {
  return segment.replace(/~1/g, "/").replace(/~0/g, "~");
}

function isMissing(value: unknown): value is MissingValue {
  return value === MISSING;
}

function isRecord(value: unknown): value is Record<string, unknown> {
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}

function asRecord(value: unknown): Record<string, unknown> {
  return isRecord(value) ? value : {};
}

function cloneValue<T>(value: T): T {
  if (value === undefined || value === null || typeof value !== "object") return value;
  if (typeof structuredClone === "function") return structuredClone(value);
  return JSON.parse(JSON.stringify(value)) as T;
}

function deepEqual(left: unknown, right: unknown): boolean {
  if (Object.is(left, right)) return true;
  if (Array.isArray(left) && Array.isArray(right)) {
    return left.length === right.length && left.every((item, index) => deepEqual(item, right[index]));
  }
  if (isRecord(left) && isRecord(right)) {
    const leftKeys = Object.keys(left);
    const rightKeys = Object.keys(right);
    return leftKeys.length === rightKeys.length
      && leftKeys.every((key) => hasOwn(right, key) && deepEqual(left[key], right[key]));
  }
  return false;
}

function hasOwn(value: object, key: PropertyKey): boolean {
  return Object.prototype.hasOwnProperty.call(value, key);
}
