/**
 * ProseMirror-level content-control (SDT) addressing for the live editor.
 *
 * The headless equivalents in `agent/contentControls` operate on the parsed
 * Document model; these operate on the editor's PM state so the editor adapters
 * (React/Vue) can discover and edit a control by tag without a full reload and
 * with normal undo. Shared by both adapters to keep them in lockstep.
 */

import type { EditorState, Transaction } from 'prosemirror-state';
import type { Node as PMNode, Schema } from 'prosemirror-model';

import {
  ContentControlNotFoundError,
  ContentControlLockedError,
  ContentControlTypeError,
  ContentControlBoundError,
  isTextReplaceable,
  isContentLocked,
  isDeletionLocked,
  isDataBound,
  clearShowingPlaceholderXml,
  type ContentControlFilter,
} from '../agent/contentControls';
import { applyContentControlValue, type ContentControlValue } from '../agent/contentControlValues';
import {
  RepeatingSectionError,
  rawIsRepeatingSectionItem,
  patchRawId,
} from '../agent/repeatingSection';
import type { FontFamilyAttrs } from './schema/marks';
import { sdtAttrsToProps, sdtPropsToAttrs } from './conversion/sdtAttrs';
import type { SdtType, SdtProperties, SdtDataBinding } from '../types/document';

/** A control discovered in the PM doc, with its PM position for scroll/edit. */
export interface PMContentControl {
  tag?: string;
  alias?: string;
  id?: number;
  sdtType: SdtType;
  lock?: SdtProperties['lock'];
  /** Whether the control is currently showing placeholder text. */
  showingPlaceholder?: boolean;
  /** Checkbox state, for checkbox controls. */
  checked?: boolean;
  /** Date format, for date controls. */
  dateFormat?: string;
  /** Dropdown/combobox list items, if modeled. */
  listItems?: { displayText: string; value: string }[];
  /** XML data binding (`w:dataBinding`), if the control is bound. */
  dataBinding?: SdtDataBinding;
  /** Current date value (ISO `yyyy-mm-dd`) for a date control, from `w:fullDate`. */
  dateValue?: string;
  /** Plain text of the control's content. */
  text: string;
  /** PM position of the `blockSdt` or inline `sdt` node (its `before` position). */
  pos: number;
  /** Nesting depth among content controls (0 = not inside another control). */
  depth: number;
}

/** Safe JSON.parse for a cached attr string; returns undefined on miss/throw. */
function parseAttrJson<T>(value: unknown): T | undefined {
  if (typeof value !== 'string' || !value) return undefined;
  try {
    return JSON.parse(value) as T;
  } catch {
    return undefined;
  }
}

function attrsMatch(attrs: Record<string, unknown>, filter: ContentControlFilter): boolean {
  if (filter.tag !== undefined && attrs.tag !== filter.tag) return false;
  if (filter.alias !== undefined && attrs.alias !== filter.alias) return false;
  if (filter.id !== undefined && attrs.id !== filter.id) return false;
  if (filter.type !== undefined && (attrs.sdtType ?? 'richText') !== filter.type) return false;
  return true;
}

function isContentControlNode(node: PMNode): boolean {
  return node.type.name === 'blockSdt' || node.type.name === 'sdt';
}

function controlInfo(node: PMNode, pos: number, depth: number): PMContentControl {
  const a = node.attrs as Record<string, unknown>;
  return {
    tag: a.tag != null ? String(a.tag) : undefined,
    alias: a.alias != null ? String(a.alias) : undefined,
    id: typeof a.id === 'number' ? a.id : undefined,
    sdtType: (a.sdtType as SdtType) ?? 'richText',
    lock: (a.lock as SdtProperties['lock']) ?? undefined,
    showingPlaceholder: a.showingPlaceholder === true ? true : undefined,
    checked: typeof a.checked === 'boolean' ? a.checked : undefined,
    dateFormat: a.dateFormat != null ? String(a.dateFormat) : undefined,
    listItems: parseAttrJson<{ displayText: string; value: string }[]>(a.listItems),
    dataBinding: parseAttrJson<SdtDataBinding>(a.dataBinding),
    dateValue: /<w:date\b[^>]*\bw:fullDate="(\d{4}-\d{2}-\d{2})/.exec(
      String(a.rawPropertiesXml ?? '')
    )?.[1],
    text: node.textBetween(0, node.content.size, '\n'),
    pos,
    depth,
  };
}

/** All content controls in the PM doc (document order), optionally filtered. */
export function findContentControlsInPM(
  doc: PMNode,
  filter: ContentControlFilter = {}
): PMContentControl[] {
  const out: PMContentControl[] = [];
  // `base` is the position of `node`'s first child; a child at `offset` has
  // `before` position `base + offset`, and its own children start one past it.
  // `depth` counts only enclosing content controls.
  const walk = (node: PMNode, base: number, depth: number): void => {
    node.forEach((child, offset) => {
      const childPos = base + offset;
      const isSdt = isContentControlNode(child);
      if (isSdt && attrsMatch(child.attrs as Record<string, unknown>, filter)) {
        out.push(controlInfo(child, childPos, depth));
      }
      walk(child, childPos + 1, isSdt ? depth + 1 : depth);
    });
  };
  walk(doc, 0, 0);
  return out;
}

/** PM position of the first control matching `filter`, or `null`. */
export function findContentControlPos(doc: PMNode, filter: ContentControlFilter): number | null {
  return findContentControlsInPM(doc, filter)[0]?.pos ?? null;
}

/** Locate the first matching blockSdt node + its position in the PM doc. */
function locateBlockSdt(
  doc: PMNode,
  filter: ContentControlFilter
): { node: PMNode; pos: number } | null {
  let found: { node: PMNode; pos: number } | null = null;
  doc.descendants((node, pos) => {
    if (found) return false;
    if (
      node.type.name === 'blockSdt' &&
      attrsMatch(node.attrs as Record<string, unknown>, filter)
    ) {
      found = { node, pos };
      return false;
    }
    return true;
  });
  return found;
}

/** Locate the first matching block or inline SDT node + its position in the PM doc. */
function locateValueControl(
  doc: PMNode,
  filter: ContentControlFilter
): { node: PMNode; pos: number } | null {
  let found: { node: PMNode; pos: number } | null = null;
  doc.descendants((node, pos) => {
    if (found) return false;
    if (isContentControlNode(node) && attrsMatch(node.attrs as Record<string, unknown>, filter)) {
      found = { node, pos };
      return false;
    }
    return true;
  });
  return found;
}

function locateContentControlAtPos(doc: PMNode, pos: number): { node: PMNode; pos: number } | null {
  const node = doc.nodeAt(pos);
  return node && isContentControlNode(node) ? { node, pos } : null;
}

/**
 * Build a transaction that replaces the first matching control's content with
 * `text` (newlines become paragraphs; a `plainText` control stays one
 * paragraph). Throws if nothing matches, the control is content-locked, a typed
 * (dropdown/date/…) control, or data-bound (unless `force`). The control's
 * identity/raw props are kept; a `w:showingPlcHdr` placeholder flag is cleared
 * so the new content isn't rendered as placeholder.
 */
export function setContentControlContentTr(
  state: EditorState,
  filter: ContentControlFilter,
  text: string,
  options: { force?: boolean } = {}
): Transaction {
  const target = locateBlockSdt(state.doc, filter);
  if (!target) throw new ContentControlNotFoundError(filter);
  const attrs = target.node.attrs as Record<string, unknown>;
  if (!options.force && isContentLocked(attrs.lock as SdtProperties['lock'])) {
    throw new ContentControlLockedError(attrs.lock as SdtProperties['lock'], 'edit');
  }
  const sdtType = (attrs.sdtType as SdtType) ?? 'richText';
  if (!options.force && !isTextReplaceable(sdtType)) {
    throw new ContentControlTypeError(sdtType);
  }
  if (!options.force && attrs.dataBinding != null) {
    throw new ContentControlBoundError();
  }
  const { schema } = state;
  const lines = sdtType === 'plainText' ? [text] : text.split('\n');
  const paragraphs = lines.map((line) =>
    schema.nodes.paragraph.create(null, line ? schema.text(line) : null)
  );
  const from = target.pos + 1;
  const to = target.pos + 1 + target.node.content.size;
  const tr = state.tr.replaceWith(from, to, paragraphs);
  // Clear placeholder state so the written content isn't styled as placeholder.
  // The node's own start position is unaffected by the inner content replace.
  if (attrs.showingPlaceholder || /showingPlcHdr/.test(String(attrs.rawPropertiesXml ?? ''))) {
    tr.setNodeMarkup(target.pos, undefined, {
      ...attrs,
      showingPlaceholder: false,
      rawPropertiesXml: clearShowingPlaceholderXml(
        attrs.rawPropertiesXml == null ? undefined : String(attrs.rawPropertiesXml)
      ),
    });
  }
  return tr;
}

/**
 * Build a transaction that removes the first matching control. With
 * `keepContent` the inner blocks are unwrapped in place; otherwise the whole
 * region is deleted. Throws if nothing matches or the control is
 * deletion-locked (unless `force`).
 */
export function removeContentControlTr(
  state: EditorState,
  filter: ContentControlFilter,
  options: { force?: boolean; keepContent?: boolean } = {}
): Transaction {
  const target = locateBlockSdt(state.doc, filter);
  if (!target) throw new ContentControlNotFoundError(filter);
  const lock = target.node.attrs.lock as SdtProperties['lock'];
  if (!options.force && isDeletionLocked(lock)) {
    throw new ContentControlLockedError(lock, 'remove');
  }
  // Unwrapping a repeating-section item would orphan its (w15) structure.
  if (
    options.keepContent &&
    !options.force &&
    /<w15:repeatingSection(Item)?[\s/>]/.test(String(target.node.attrs.rawPropertiesXml ?? ''))
  ) {
    throw new ContentControlLockedError(lock, 'remove');
  }
  const start = target.pos;
  const end = target.pos + target.node.nodeSize;
  return options.keepContent
    ? state.tr.replaceWith(start, end, target.node.content)
    : state.tr.delete(start, end);
}

/**
 * Build a transaction that applies a typed value (dropdown selection, checkbox
 * toggle, or date) to the first control matching `filter`, updating both the
 * visible content and the control's structured attrs (checked / raw w:sdtPr).
 * Reuses the headless value-applier so the live editor and headless paths agree.
 * Throws as the headless {@link setContentControlValue} does.
 */
export function setContentControlValueTr(
  state: EditorState,
  filter: ContentControlFilter,
  value: ContentControlValue,
  options: { force?: boolean } = {}
): Transaction {
  const target = locateValueControl(state.doc, filter);
  if (!target) throw new ContentControlNotFoundError(filter);
  return setContentControlValueForTargetTr(state, target, value, options);
}

/**
 * Build a transaction that applies a typed value to the content control at a
 * specific PM node position. This is used by painted inline widgets because
 * Word templates may repeat or omit `w:tag` values.
 */
export function setContentControlValueAtPosTr(
  state: EditorState,
  pos: number,
  value: ContentControlValue,
  options: { force?: boolean } = {}
): Transaction {
  const target = locateContentControlAtPos(state.doc, pos);
  if (!target) throw new ContentControlNotFoundError({});
  return setContentControlValueForTargetTr(state, target, value, options);
}

function fontFamilyAttrs(font: FontFamilyAttrs | string | undefined): FontFamilyAttrs | undefined {
  if (!font) return undefined;
  return typeof font === 'string' ? { ascii: font, hAnsi: font } : font;
}

function textNodeForRun(
  schema: Schema,
  text: string,
  font: FontFamilyAttrs | string | undefined
): PMNode | null {
  if (!text) return null;
  const fontMark = schema.marks.fontFamily;
  const fontAttrs = fontFamilyAttrs(font);
  const marks = fontAttrs && fontMark ? [fontMark.create(fontAttrs)] : undefined;
  return schema.text(text, marks);
}

function blockNodesForValue(
  schema: Schema,
  content: ReturnType<typeof applyContentControlValue>['content']
) {
  return content.map((block) => {
    if (block.type !== 'paragraph') return schema.nodes.paragraph.create(null, null);
    const run = block.content.find((r) => r.type === 'run');
    const text =
      run?.type === 'run' ? run.content.map((t) => ('text' in t ? t.text : '')).join('') : '';
    // Carry the glyph font (e.g. checkbox symbol font) as a fontFamily mark.
    const font = run?.type === 'run' ? run.formatting?.fontFamily : undefined;
    return schema.nodes.paragraph.create(null, textNodeForRun(schema, text, font));
  });
}

function inlineNodesForValue(
  schema: Schema,
  content: ReturnType<typeof applyContentControlValue>['content']
): PMNode[] {
  const firstParagraph = content.find((block) => block.type === 'paragraph');
  if (!firstParagraph || firstParagraph.type !== 'paragraph') return [];
  const nodes: PMNode[] = [];
  for (const run of firstParagraph.content) {
    if (run.type !== 'run') continue;
    const text = run.content.map((t) => ('text' in t ? t.text : '')).join('');
    const node = textNodeForRun(schema, text, run.formatting?.fontFamily);
    if (node) nodes.push(node);
  }
  return nodes;
}

function setContentControlValueForTargetTr(
  state: EditorState,
  target: { node: PMNode; pos: number },
  value: ContentControlValue,
  options: { force?: boolean }
): Transaction {
  const props = sdtAttrsToProps(target.node.attrs as Record<string, unknown>);
  if (!options.force && isContentLocked(props.lock)) {
    throw new ContentControlLockedError(props.lock, 'edit');
  }
  if (!options.force && isDataBound(props)) {
    throw new ContentControlBoundError();
  }
  const { properties, content } = applyContentControlValue(props, value);
  const { schema } = state;
  const from = target.pos + 1;
  const to = target.pos + 1 + target.node.content.size;
  const replacement =
    target.node.type.name === 'sdt'
      ? inlineNodesForValue(schema, content)
      : blockNodesForValue(schema, content);
  const tr = state.tr.replaceWith(from, to, replacement);
  // Sync structured attrs (checked / rawPropertiesXml); node start is stable.
  tr.setNodeMarkup(target.pos, undefined, {
    ...(target.node.attrs as Record<string, unknown>),
    ...sdtPropsToAttrs(properties),
  });
  return tr;
}

// ── repeating sections (w15:repeatingSection) ───────────────────────────────

/** Highest blockSdt `id` attr in the doc, for minting a fresh unique one. */
function maxPmControlId(doc: PMNode): number {
  let max = 0;
  doc.descendants((n) => {
    if (n.type.name === 'blockSdt' && typeof n.attrs.id === 'number') {
      max = Math.max(max, n.attrs.id);
    }
    return true;
  });
  return max;
}

/** Clone a repeating-section item node, assigning fresh ids to it + nested controls. */
function cloneItemNode(schema: EditorState['schema'], item: PMNode, startId: number): PMNode {
  let id = startId;
  type JsonNode = { type: string; attrs?: Record<string, unknown>; content?: JsonNode[] };
  const json = item.toJSON() as JsonNode;
  const walk = (n: JsonNode): void => {
    if (n.type === 'blockSdt' && n.attrs) {
      const newId = ++id;
      n.attrs = {
        ...n.attrs,
        id: newId,
        rawPropertiesXml: patchRawId(
          n.attrs.rawPropertiesXml == null ? undefined : String(n.attrs.rawPropertiesXml),
          newId
        ),
      };
    }
    n.content?.forEach(walk);
  };
  walk(json);
  return schema.nodeFromJSON(json);
}

/**
 * Build a transaction that adds a repeating-section item by cloning the item at
 * `itemPos` (its blockSdt `before` position) and inserting the copy after it.
 * Throws {@link RepeatingSectionError} if `itemPos` isn't a repeating item.
 */
export function addRepeatingSectionItemTr(state: EditorState, itemPos: number): Transaction {
  const item = state.doc.nodeAt(itemPos);
  if (
    !item ||
    item.type.name !== 'blockSdt' ||
    !rawIsRepeatingSectionItem(String(item.attrs.rawPropertiesXml ?? ''))
  ) {
    throw new RepeatingSectionError('Position is not a repeating-section item.');
  }
  const clone = cloneItemNode(state.schema, item, maxPmControlId(state.doc));
  return state.tr.insert(itemPos + item.nodeSize, clone);
}

/**
 * Build a transaction that removes the repeating-section item at `itemPos`.
 * Throws {@link RepeatingSectionError} if it isn't an item or is the only one in
 * its section.
 */
export function removeRepeatingSectionItemTr(state: EditorState, itemPos: number): Transaction {
  const item = state.doc.nodeAt(itemPos);
  if (
    !item ||
    item.type.name !== 'blockSdt' ||
    !rawIsRepeatingSectionItem(String(item.attrs.rawPropertiesXml ?? ''))
  ) {
    throw new RepeatingSectionError('Position is not a repeating-section item.');
  }
  const $pos = state.doc.resolve(itemPos);
  const parent = $pos.parent;
  let siblings = 0;
  parent.forEach((child) => {
    if (
      child.type.name === 'blockSdt' &&
      rawIsRepeatingSectionItem(String(child.attrs.rawPropertiesXml ?? ''))
    ) {
      siblings += 1;
    }
  });
  if (siblings <= 1) {
    throw new RepeatingSectionError('Cannot remove the last item of a repeating section.');
  }
  return state.tr.delete(itemPos, itemPos + item.nodeSize);
}
