// Atomic Node rename: keep cx- stable, move folder + identity note, rewrite path links.
// On any post-move failure: restore every completed note write, then restore the tree.

import { withTentMutation, type FsAdapter } from "./adapter.js";
import { parseFrontmatter, serializeFrontmatter } from "./frontmatter.js";
import { buildNodeIndex, resolveNode, type OkfNode } from "./okf.js";
import { normalizeTarget } from "./link-target.js";
import type { OpsEnv } from "./ops-context.js";
import { isOperationalPath } from "./paths.js";
import { validateNodeName } from "./scaffold.js";
import type { Node } from "./types.js";
import {
  nodeNotePath,
  dirName,
  assertContentMutable,
  isUsableNode,
  join,
  loadTent,
  type LoadedTent,
} from "./tree.js";

export interface RenameNodeResult {
  id: string;
  oldPath: string;
  path: string;
  name: string;
  /** Full old→new path map for the moved subtree (root + descendants). */
  pathMap: Record<string, string>;
  rewrittenNotes: string[];
}

type PlannedWrite = {
  /** Path written after the tree move (mapped if note is in the moved subtree). */
  writePath: string;
  /** Original note path before the tree move. */
  originalPath: string;
  /** Byte-for-byte original file content (frontmatter + body). */
  originalContent: string;
  /** Full rewritten content to write after the move. */
  newContent: string;
};

/**
 * Rename a Node folder (and its same-named identity note).
 * - `cx-` / frontmatter id never change
 * - entire directory tree moves; child relative structure preserved
 * - path-based Markdown / wiki links rewritten in the same mutation
 * - unqualified wiki/name targets rewrite only when Tent resolution uniquely hits this node
 * - refuses target collision, illegal names, operational paths
 * - on post-move failure: restore every touched note + tree
 */
export async function renameNode(
  env: OpsEnv,
  nodeIdOrPath: string,
  newNameRaw: string
): Promise<RenameNodeResult> {
  return withTentMutation(env.fs, async () => renameNodeUnlocked(env, nodeIdOrPath, newNameRaw));
}

async function renameNodeUnlocked(
  env: OpsEnv,
  nodeIdOrPath: string,
  newNameRaw: string
): Promise<RenameNodeResult> {
  const newName = validateNodeName(newNameRaw);
  const tent = await loadTent(env.fs);
  const target = resolveRenameTarget(tent, nodeIdOrPath);
  if (!isUsableNode(target)) {
    throw new Error("Invalid or archived nodes cannot be renamed.");
  }
  assertContentMutable(target, "renamed");
  if (target.invalid || target.archived) {
    throw new Error("Invalid or archived nodes cannot be renamed.");
  }
  const oldPath = target.path;
  const oldName = target.name;
  if (newName === oldName) {
    return {
      id: target.id,
      oldPath,
      path: oldPath,
      name: oldName,
      pathMap: { [oldPath]: oldPath },
      rewrittenNotes: [],
    };
  }

  const parentPath = dirName(oldPath);
  const newPath = join(parentPath, newName);
  assertNotOperationalPath(oldPath);
  assertNotOperationalPath(newPath);
  if (await env.fs.exists(newPath)) {
    throw new Error(`Rename target already exists: ${newPath}.`);
  }
  const siblings = target.parent ? target.parent.children : tent.roots;
  if (siblings.some((node) => node.id !== target.id && node.name === newName)) {
    throw new Error(`A sibling Node already uses the name: ${newName}.`);
  }

  const subtree = collectSubtree(target);
  const pathMap = new Map<string, string>();
  for (const node of subtree) {
    const rel = relativePath(oldPath, node.path);
    const nextNodePath = rel ? join(newPath, rel) : newPath;
    // Node path (folder) and identity-note path stem (folder/Name) both appear in links.
    pathMap.set(node.path, nextNodePath);
    pathMap.set(
      nodeNotePath(node.path).replace(/\.md$/i, ""),
      nodeNotePath(nextNodePath).replace(/\.md$/i, "")
    );
  }

  const conceptIndex = buildNodeIndex(tent.byPath.values());
  const rewriteOpts: RewriteNodeLinksOptions = {
    renameNodeId: target.id,
    conceptIndex,
  };

  // Plan rewrites: resolve from pre-move note path; restyle relatives from post-move path when moved.
  const plannedWrites: PlannedWrite[] = [];
  const rewrittenNotes: string[] = [];
  for (const node of tent.byPath.values()) {
    const notePath = nodeNotePath(node.path);
    if (!(await env.fs.exists(notePath))) continue;
    const raw = await env.fs.readFile(notePath);
    const { data, body, keyOrder } = parseFrontmatter(raw);
    if (typeof data.id === "string" && data.id !== node.id) {
      throw new Error(`Refuse rename: frontmatter id drift on ${node.path}.`);
    }
    const afterNodePath = pathMap.get(node.path) ?? node.path;
    const restyleFromNotePath = nodeNotePath(afterNodePath);
    const rewritten = rewriteNodeLinks(body, notePath, pathMap, oldName, newName, {
      ...rewriteOpts,
      restyleFromNotePath,
    });
    if (!rewritten.changed) continue;
    plannedWrites.push({
      writePath: restyleFromNotePath,
      originalPath: notePath,
      originalContent: raw,
      newContent: serializeFrontmatter(data, rewritten.body, keyOrder),
    });
    rewrittenNotes.push(afterNodePath);
  }

  await env.fs.move(oldPath, newPath);

  let identityRenamed = false;
  const completedWrites: PlannedWrite[] = [];
  try {
    identityRenamed = await ensureIdentityFileName(env.fs, newPath, oldName);
    for (const write of plannedWrites) {
      await env.fs.writeFile(write.writePath, write.newContent);
      completedWrites.push(write);
    }
  } catch (error) {
    await rollbackRename(env.fs, {
      oldPath,
      newPath,
      oldName,
      identityRenamed,
      completedWrites,
    });
    throw error;
  }

  // order.json is id-keyed — no path rewrite. Attachments are cx-keyed and stay put.

  const pathMapRecord: Record<string, string> = {};
  for (const [from, to] of pathMap) pathMapRecord[from] = to;

  return {
    id: target.id,
    oldPath,
    path: newPath,
    name: newName,
    pathMap: pathMapRecord,
    rewrittenNotes: rewrittenNotes.sort(),
  };
}

/**
 * Restore every completed note write (reverse order), reverse identity rename if needed,
 * then move the folder tree back to oldPath.
 */
async function rollbackRename(
  fs: FsAdapter,
  args: {
    oldPath: string;
    newPath: string;
    oldName: string;
    identityRenamed: boolean;
    completedWrites: PlannedWrite[];
  }
): Promise<void> {
  const { oldPath, newPath, oldName, identityRenamed, completedWrites } = args;
  const restoreErrors: string[] = [];

  // 1) Restore note bodies at their post-move write paths (reverse order).
  for (let i = completedWrites.length - 1; i >= 0; i--) {
    const write = completedWrites[i]!;
    try {
      await fs.writeFile(write.writePath, write.originalContent);
    } catch (err) {
      restoreErrors.push(
        `note ${write.writePath}: ${err instanceof Error ? err.message : String(err)}`
      );
    }
  }

  // 2) Reverse identity-note rename if we renamed Old→New under newPath.
  try {
    const expectedNew = nodeNotePath(newPath);
    const legacyAfterMove = join(newPath, `${oldName}.md`);
    if (
      (identityRenamed || (await fs.exists(expectedNew))) &&
      (await fs.exists(expectedNew)) &&
      !(await fs.exists(legacyAfterMove))
    ) {
      await fs.move(expectedNew, legacyAfterMove);
    }
  } catch (err) {
    restoreErrors.push(`identity: ${err instanceof Error ? err.message : String(err)}`);
  }

  // 3) Move the tree back.
  try {
    if ((await fs.exists(newPath)) && !(await fs.exists(oldPath))) {
      const expectedNew = nodeNotePath(newPath);
      const legacyAfterMove = join(newPath, `${oldName}.md`);
      if (!(await fs.exists(legacyAfterMove)) && (await fs.exists(expectedNew))) {
        try {
          await fs.move(expectedNew, legacyAfterMove);
        } catch {
          // best-effort before folder move
        }
      }
      await fs.move(newPath, oldPath);
    }
  } catch (err) {
    restoreErrors.push(`tree: ${err instanceof Error ? err.message : String(err)}`);
  }

  if (restoreErrors.length > 0) {
    throw new Error(
      `Rename failed after filesystem move, and rollback also failed: ${restoreErrors.join("; ")}`
    );
  }
}

function resolveRenameTarget(tent: LoadedTent, nodeIdOrPath: string): Node {
  const key = nodeIdOrPath.trim().replace(/\\/g, "/");
  const byId = tent.byId.get(key);
  if (byId) return byId;
  const byPath = tent.byPath.get(key);
  if (byPath) return byPath;
  throw new Error(`Node not found: ${nodeIdOrPath}.`);
}

function assertNotOperationalPath(path: string): void {
  if (isOperationalPath(path) || path === "temp" || path.startsWith("temp/")) {
    throw new Error("temp/ and other system pipelines cannot be renamed as Nodes.");
  }
  const top = path.split("/")[0] ?? "";
  if (top === "attachments" || top === ".tent") {
    throw new Error("System directories cannot be renamed as Nodes.");
  }
}

function collectSubtree(node: Node, out: Node[] = []): Node[] {
  out.push(node);
  for (const child of node.children) collectSubtree(child, out);
  return out;
}

function relativePath(root: string, child: string): string {
  if (child === root) return "";
  return child.slice(root.length + 1);
}

/**
 * Ensure identity note is `New/New.md` after folder move.
 * @returns true when a rename of the identity file occurred.
 */
async function ensureIdentityFileName(
  fs: FsAdapter,
  newNodePath: string,
  oldName: string
): Promise<boolean> {
  const expected = nodeNotePath(newNodePath);
  if (await fs.exists(expected)) return false;
  const legacy = join(newNodePath, `${oldName}.md`);
  if (await fs.exists(legacy)) {
    await fs.move(legacy, expected);
    return true;
  }
  const entries = await fs.listDir(newNodePath);
  const candidates = entries
    .filter((e) => !e.isDir && e.name.endsWith(".md") && e.name !== "index.md")
    .map((e) => join(newNodePath, e.name));
  if (candidates.length === 1) {
    await fs.move(candidates[0]!, expected);
    return true;
  }
  throw new Error(`Identity note missing after rename: expected ${expected}.`);
}

export type RewriteNodeLinksOptions = {
  /** Immutable cx- of the node being renamed/moved. */
  renameNodeId: string;
  /** Pre-rename concept index (name resolution uses Tent's unique-match rules). */
  conceptIndex: Map<string, OkfNode[]>;
  /**
   * Note path used when restyling `./` and `../` destinations after a tree move.
   * Resolve still uses `fromNotePath` (pre-move disk location). When the note itself
   * moves, pass the post-move note path so relatives stay valid at the write location.
   * Defaults to `fromNotePath`.
   */
  restyleFromNotePath?: string;
};

/**
 * Rewrite Markdown / wiki destinations that targeted a moved path.
 * Unqualified wiki/name targets rewrite only when resolution uniquely targets the renamed node.
 *
 * Relative (`./` / `../`) links are resolved against `fromNotePath`, then restyled from
 * `opts.restyleFromNotePath` (post-move when the source note moves). Outbound relatives to
 * unmoved targets are restyled when source depth/path changes even if the target is outside pathMap.
 */
export function rewriteNodeLinks(
  body: string,
  fromNotePath: string,
  pathMap: Map<string, string>,
  oldName: string,
  newName: string,
  opts?: RewriteNodeLinksOptions
): { body: string; changed: boolean } {
  if (pathMap.size === 0) return { body, changed: false };
  const oldPaths = [...pathMap.keys()].sort((a, b) => b.length - a.length);
  let next = body;
  let changed = false;

  // Markdown inline links: [label](dest) / [label](<dest>)
  next = next.replace(/\[([^\]]*)\]\((<[^>\n]+>|[^)\n]+)\)/g, (full, label: string, destRaw: string) => {
    const angled = destRaw.startsWith("<") && destRaw.endsWith(">");
    const inner = angled ? destRaw.slice(1, -1) : destRaw;
    const { url, titleTail } = splitMdUrlAndTitle(inner);
    if (!url || isExternalOrAnchor(url)) return full;
    const mapped = mapLinkTarget(url, fromNotePath, pathMap, oldPaths, oldName, newName, opts);
    if (!mapped) return full;
    changed = true;
    const dest = angled ? `<${mapped}${titleTail}>` : `${mapped}${titleTail}`;
    return `[${label}](${dest})`;
  });

  // Wiki links (not image embeds).
  next = next.replace(
    /(^|[^!])\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g,
    (full, prefix: string, rawTarget: string, rawLabel: string | undefined) => {
      const target = rawTarget.trim();
      if (!target) return full;
      const { head, suffix } = splitWikiTarget(target);
      if (!head || isExternalOrAnchor(head)) return full;

      const nextHead = mapLinkTarget(head, fromNotePath, pathMap, oldPaths, oldName, newName, opts);
      if (!nextHead || nextHead === head) return full;
      changed = true;
      const labelPart = rawLabel !== undefined ? `|${rawLabel}` : "";
      return `${prefix}[[${nextHead}${suffix}${labelPart}]]`;
    }
  );

  return { body: next, changed };
}

/**
 * Map a link destination to its post-move/rename form.
 * - Path-like targets: resolve against pre-move fromNotePath; apply pathMap; restyle from restyleFromNotePath.
 * - Relative links with a moved source: restyle even when the absolute target is outside pathMap.
 * - Unqualified bare names: rewrite only when Tent resolveNode uniquely hits renameNodeId.
 */
function mapLinkTarget(
  raw: string,
  fromNotePath: string,
  pathMap: Map<string, string>,
  oldPaths: string[],
  oldName: string,
  newName: string,
  opts?: RewriteNodeLinksOptions
): string | undefined {
  const { pathPart, tail } = splitDestTail(raw);
  if (!pathPart) return undefined;

  if (isUnqualifiedName(pathPart)) {
    return mapUnqualifiedName(pathPart, tail, oldName, newName, opts);
  }

  const restyleFrom = opts?.restyleFromNotePath ?? fromNotePath;
  const sourceMoved = restyleFrom.replace(/\\/g, "/") !== fromNotePath.replace(/\\/g, "/");
  const isRelativeForm = pathPart.startsWith("./") || pathPart.startsWith("../");

  // Resolve against the pre-move note path (link text was authored there).
  const normalized = normalizeTarget(pathPart, fromNotePath);
  const mapped = resolveMappedPath(normalized, pathMap, oldPaths);

  // Absolute / multi-segment system-root paths only rewrite on pathMap hits.
  // Relative forms also restyle when the source note itself moved (depth/path change).
  if (!mapped && !(isRelativeForm && sourceMoved)) {
    return undefined;
  }

  const newAbs = mapped ?? normalized;
  const sourceHadMd = /\.md$/i.test(pathPart.split(/[?#]/)[0] ?? pathPart);
  const absTarget = sourceHadMd
    ? newAbs.endsWith(".md")
      ? newAbs
      : `${newAbs}.md`
    : newAbs.replace(/\.md$/i, "");
  const styled = restyleRelative(pathPart, restyleFrom, absTarget, sourceHadMd);
  if (styled === pathPart) return undefined;
  return styled + tail;
}

function mapUnqualifiedName(
  pathPart: string,
  tail: string,
  oldName: string,
  newName: string,
  opts?: RewriteNodeLinksOptions
): string | undefined {
  if (!opts || oldName === newName) return undefined;
  const bare = pathPart.replace(/\.md$/i, "");
  const resolved = resolveNode(opts.conceptIndex, bare);
  if (!resolved || resolved.nodeId !== opts.renameNodeId) return undefined;

  const sourceHadMd = /\.md$/i.test(pathPart);
  // Authors used a bare name form; keep bare name form with the new display name.
  // Only rewrite when the resolved target is uniquely this node (resolveNode guarantee).
  const nextBare =
    bare === oldName || normalizeLookupLoose(bare) === normalizeLookupLoose(oldName)
      ? newName
      : newName;
  return (sourceHadMd ? `${nextBare}.md` : nextBare) + tail;
}

function resolveMappedPath(
  normalized: string,
  pathMap: Map<string, string>,
  oldPaths: string[]
): string | undefined {
  const clean = normalized.replace(/\\/g, "/").replace(/^\.\//, "");
  if (pathMap.has(clean)) return pathMap.get(clean);
  const noMd = clean.replace(/\.md$/i, "");
  if (pathMap.has(noMd)) return pathMap.get(noMd);
  for (const oldPath of oldPaths) {
    if (clean === oldPath || noMd === oldPath || clean === `${oldPath}.md`) {
      return pathMap.get(oldPath);
    }
  }
  return undefined;
}

function isUnqualifiedName(raw: string): boolean {
  const t = raw.trim().replace(/\\/g, "/");
  if (!t || t.includes("/") || t.startsWith(".")) return false;
  return true;
}

function normalizeLookupLoose(value: string): string {
  return value.toLowerCase().replace(/[\s、，,。:：;；/\\_\-.()[\]（）【】"'`]+/g, "");
}

function splitMdUrlAndTitle(inner: string): { url: string; titleTail: string } {
  const t = inner.trim();
  const m = t.match(/^(\S+?)(\s+(".*"|'.*'|\(.*\)))\s*$/);
  if (m) return { url: m[1]!, titleTail: m[2] ?? "" };
  return { url: t, titleTail: "" };
}

function splitWikiTarget(raw: string): { head: string; suffix: string } {
  const t = raw.trim();
  const caret = t.lastIndexOf("^");
  if (caret > 0) {
    const before = t.slice(0, caret);
    const hash = before.indexOf("#");
    if (hash >= 0) {
      return { head: before.slice(0, hash).trim(), suffix: before.slice(hash) + t.slice(caret) };
    }
    return { head: before.trim(), suffix: t.slice(caret) };
  }
  const hash = t.indexOf("#");
  if (hash >= 0) return { head: t.slice(0, hash).trim(), suffix: t.slice(hash) };
  return { head: t, suffix: "" };
}

function splitDestTail(dest: string): { pathPart: string; tail: string } {
  const t = dest.trim();
  const hash = t.indexOf("#");
  const query = t.indexOf("?");
  let cut = -1;
  if (hash >= 0 && query >= 0) cut = Math.min(hash, query);
  else if (hash >= 0) cut = hash;
  else if (query >= 0) cut = query;
  if (cut < 0) return { pathPart: t, tail: "" };
  return { pathPart: t.slice(0, cut), tail: t.slice(cut) };
}

function isExternalOrAnchor(dest: string): boolean {
  const t = dest.trim();
  if (!t || t.startsWith("#")) return true;
  return /^[a-z][a-z0-9+.-]*:/i.test(t);
}

function restyleRelative(
  originalPathPart: string,
  fromNotePath: string,
  absoluteNext: string,
  keepMd: boolean
): string {
  const orig = originalPathPart.replace(/\\/g, "/");
  if (orig.startsWith("./") || orig.startsWith("../")) {
    const toNote = absoluteNext.endsWith(".md") ? absoluteNext : `${absoluteNext}.md`;
    let rel = relativeMarkdownPath(fromNotePath, toNote);
    if (!keepMd) rel = rel.replace(/\.md$/i, "");
    return rel;
  }
  // Absolute-from-system-root style path
  if (!keepMd && absoluteNext.endsWith(".md")) return absoluteNext.replace(/\.md$/i, "");
  return absoluteNext;
}

function relativeMarkdownPath(fromNotePath: string, toNotePath: string): string {
  const fromParts = dirName(fromNotePath).split("/").filter(Boolean);
  const toParts = toNotePath.split("/").filter(Boolean);
  while (fromParts.length > 0 && toParts.length > 0 && fromParts[0] === toParts[0]) {
    fromParts.shift();
    toParts.shift();
  }
  const up = fromParts.map(() => "..");
  const rel = [...up, ...toParts].join("/");
  return rel.startsWith(".") ? rel : `./${rel}`;
}
