// Format `InlineNode[]` : représentation du contenu inline d'un bloc texte.
// Tableau plat de nœuds `text`, chacun pouvant porter des marks (gras,
// italique, lien, couleur…). Un seul type de nœud — pas d'arbre imbriqué.
//
// Schéma :
//   InlineNode = { type: 'text', text: string, marks?: Mark[] }
//   Mark =
//     | { type: 'bold' }
//     | { type: 'italic' }
//     | { type: 'underline' }
//     | { type: 'strike' }
//     | { type: 'code' }
//     | { type: 'color', color: string }     // clé palette
//     | { type: 'highlight', color: string } // clé palette
//     | { type: 'link', href: string }
//
// Le contenu vide est `[]` (jamais `[{type:'text', text:''}]`).

// Palette des couleurs sémantiques du DESIGN system (cf. docs/DESIGN.md).
// Bleu / vert / ambre / rouge — utilisées avec parcimonie. Les classes
// Tailwind sont appliquées au rendu via `inlineToDom`.
//
// Le champ `color` des marks `color`/`highlight` accepte **soit** une clé
// de cette palette, **soit** une string `#rrggbb` (couleur libre choisie
// par l'utilisateur). À la sérialisation, un hex bascule sur un `style`
// inline ; une clé palette utilise les classes Tailwind ci-dessous.
export const INLINE_COLORS = {
  blue:  { text: 'text-blue-600 dark:text-blue-400',     highlight: 'bg-blue-100 dark:bg-blue-900/40' },
  green: { text: 'text-green-600 dark:text-green-400',   highlight: 'bg-green-100 dark:bg-green-900/40' },
  amber: { text: 'text-amber-600 dark:text-amber-400',   highlight: 'bg-amber-100 dark:bg-amber-900/40' },
  red:   { text: 'text-red-600 dark:text-red-400',       highlight: 'bg-red-100 dark:bg-red-900/40' },
};

export const INLINE_COLOR_KEYS = Object.keys(INLINE_COLORS);

const HEX_RE = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;

export function isHexColor(c) {
  return typeof c === 'string' && HEX_RE.test(c);
}

// Parcourt tous les blocs et collecte les valeurs de `color` et `highlight`
// utilisées dans le contenu inline. Conserve l'ordre d'apparition, déduplique,
// et exclut les clés de la palette par défaut (la palette est déjà affichée
// au-dessus dans le toolbar).
export function collectUsedColors(blocks) {
  const out = { color: [], highlight: [] };
  if (!Array.isArray(blocks)) return out;
  const seen = { color: new Set(), highlight: new Set() };
  const palette = new Set(INLINE_COLOR_KEYS);
  for (const block of blocks) {
    const content = block?.content;
    if (!Array.isArray(content)) continue;
    for (const node of content) {
      if (!node?.marks) continue;
      for (const mark of node.marks) {
        if (mark.type !== 'color' && mark.type !== 'highlight') continue;
        const value = mark.color;
        if (!value || palette.has(value)) continue;
        if (seen[mark.type].has(value)) continue;
        seen[mark.type].add(value);
        out[mark.type].push(value);
      }
    }
  }
  return out;
}

const SIMPLE_MARK_TYPES = ['bold', 'italic', 'underline', 'strike', 'code'];

function marksEqual(a, b) {
  if (a === b) return true;
  if (!a && !b) return true;
  if (!a || !b) return (a?.length ?? 0) === 0 && (b?.length ?? 0) === 0;
  if (a.length !== b.length) return false;
  // Comparaison ensemble (ordre indifférent), via sérialisation déterministe.
  const ka = a.map(markKey).sort();
  const kb = b.map(markKey).sort();
  for (let i = 0; i < ka.length; i++) if (ka[i] !== kb[i]) return false;
  return true;
}

export function markKey(mark) {
  switch (mark.type) {
    case 'color':
    case 'highlight':
      return `${mark.type}:${mark.color}`;
    case 'link':
      return `link:${mark.newTab ? '1' : '0'}:${mark.href}`;
    default:
      return mark.type;
  }
}

function cloneMarks(marks) {
  return marks ? marks.map(m => ({ ...m })) : undefined;
}

function normalizeMarks(marks) {
  if (!marks || marks.length === 0) return undefined;
  // Déduplique : pour les marks paramétrées (color/highlight/link), garder la
  // dernière occurrence ; pour les simples, garder une seule.
  const byBucket = new Map();
  for (const m of marks) {
    const bucket = m.type === 'color' || m.type === 'highlight' || m.type === 'link' ? m.type : m.type;
    byBucket.set(bucket, m);
  }
  const out = Array.from(byBucket.values()).map(m => ({ ...m }));
  return out.length === 0 ? undefined : out;
}

function makeNode(text, marks) {
  const node = { type: 'text', text };
  const norm = normalizeMarks(marks);
  if (norm) node.marks = norm;
  return node;
}

// Longueur texte totale.
export function inlineLength(nodes) {
  if (!Array.isArray(nodes)) return 0;
  let n = 0;
  for (const node of nodes) n += node.text?.length ?? 0;
  return n;
}

// Texte concaténé (pour copier/coller, comparaisons rapides).
export function inlineToPlainText(nodes) {
  if (!Array.isArray(nodes)) return '';
  let out = '';
  for (const node of nodes) out += node.text ?? '';
  return out;
}

// Construit un tableau `InlineNode[]` à partir d'une string brute.
// `[]` si la string est vide — pas de nœud vide.
export function inlineFromText(text) {
  if (!text) return [];
  return [{ type: 'text', text }];
}

// Sous-tableau couvrant l'intervalle [start, end[. Découpe les nœuds aux
// frontières si besoin. Préserve les marks.
export function sliceInline(nodes, start, end) {
  if (!Array.isArray(nodes)) return [];
  const total = inlineLength(nodes);
  const a = Math.max(0, Math.min(start, total));
  const b = Math.max(a, Math.min(end ?? total, total));
  if (a === b) return [];
  const out = [];
  let pos = 0;
  for (const node of nodes) {
    const len = node.text?.length ?? 0;
    const nodeStart = pos;
    const nodeEnd = pos + len;
    pos = nodeEnd;
    if (nodeEnd <= a) continue;
    if (nodeStart >= b) break;
    const localStart = Math.max(0, a - nodeStart);
    const localEnd = Math.min(len, b - nodeStart);
    if (localEnd <= localStart) continue;
    out.push(makeNode(node.text.slice(localStart, localEnd), cloneMarks(node.marks)));
  }
  return normalize(out);
}

// Concatène deux tableaux en fusionnant le dernier nœud de `a` et le premier
// de `b` s'ils ont les mêmes marks.
export function concatInline(a, b) {
  if (!Array.isArray(a) || a.length === 0) return Array.isArray(b) ? normalize(b) : [];
  if (!Array.isArray(b) || b.length === 0) return normalize(a);
  return normalize([...a.map(n => ({ ...n, marks: cloneMarks(n.marks) })),
                    ...b.map(n => ({ ...n, marks: cloneMarks(n.marks) }))]);
}

// Fusionne les nœuds adjacents identiques et supprime les nœuds vides.
export function normalize(nodes) {
  if (!Array.isArray(nodes)) return [];
  const out = [];
  for (const node of nodes) {
    if (!node.text) continue;
    const last = out[out.length - 1];
    if (last && marksEqual(last.marks, node.marks)) {
      last.text += node.text;
    } else {
      out.push({ type: 'text', text: node.text, ...(node.marks ? { marks: cloneMarks(node.marks) } : {}) });
    }
  }
  return out;
}

// Retourne les marks actives à un offset donné (utile pour le toolbar).
// À une frontière entre deux nœuds, on prend les marks du nœud à droite,
// sauf en fin de tableau où on prend le nœud de gauche.
export function marksAtOffset(nodes, offset) {
  if (!Array.isArray(nodes) || nodes.length === 0) return [];
  let pos = 0;
  for (const node of nodes) {
    const len = node.text?.length ?? 0;
    if (offset < pos + len) return node.marks ? node.marks.map(m => ({ ...m })) : [];
    pos += len;
  }
  // offset au-delà du dernier nœud → marks du dernier nœud.
  const last = nodes[nodes.length - 1];
  return last.marks ? last.marks.map(m => ({ ...m })) : [];
}

// Trouve le début et la fin continus du span qui porte un lien à `offset`.
// Retourne { start, end, mark } ou null si aucun lien à cet offset.
export function linkRangeAt(nodes, offset) {
  if (!Array.isArray(nodes) || nodes.length === 0) return null;
  const marks = marksAtOffset(nodes, offset);
  const linkMark = marks.find(m => m.type === 'link');
  if (!linkMark) return null;
  const key = markKey(linkMark);
  let pos = 0;
  let start = null;
  let end = null;
  for (const node of nodes) {
    const len = node.text?.length ?? 0;
    const nodeEnd = pos + len;
    const hasLink = (node.marks ?? []).some(m => markKey(m) === key);
    if (hasLink) {
      if (start === null) start = pos;
      end = nodeEnd;
    } else if (start !== null) {
      break;
    }
    pos = nodeEnd;
  }
  if (start === null) return null;
  return { start, end, mark: linkMark };
}

// Marks communes à toute la plage [start, end[. Si la plage est vide,
// retourne les marks à l'offset `start`. Utile pour afficher l'état actif
// du toolbar.
export function marksInRange(nodes, start, end) {
  if (start >= end) return marksAtOffset(nodes, start);
  if (!Array.isArray(nodes) || nodes.length === 0) return [];
  let common = null;
  let pos = 0;
  for (const node of nodes) {
    const len = node.text?.length ?? 0;
    const nodeStart = pos;
    const nodeEnd = pos + len;
    pos = nodeEnd;
    if (nodeEnd <= start) continue;
    if (nodeStart >= end) break;
    const ks = (node.marks || []).map(markKey);
    if (common === null) {
      common = new Set(ks);
    } else {
      for (const k of Array.from(common)) if (!ks.includes(k)) common.delete(k);
    }
    if (common.size === 0) return [];
  }
  if (!common) return [];
  // Reconstruit les objets mark depuis les nœuds couverts.
  const result = [];
  pos = 0;
  for (const node of nodes) {
    const len = node.text?.length ?? 0;
    const nodeStart = pos;
    const nodeEnd = pos + len;
    pos = nodeEnd;
    if (nodeEnd <= start) continue;
    if (nodeStart >= end) break;
    for (const m of node.marks || []) {
      if (common.has(markKey(m)) && !result.some(r => markKey(r) === markKey(m))) {
        result.push({ ...m });
      }
    }
  }
  return result;
}

function addMarkToNode(node, mark) {
  const existing = node.marks || [];
  // Pour les marks paramétrées, on remplace (color/highlight/link sont
  // exclusives entre elles dans la même catégorie).
  const filtered = existing.filter(m => m.type !== mark.type);
  return makeNode(node.text, [...filtered, { ...mark }]);
}

function removeMarkFromNode(node, type) {
  const existing = node.marks || [];
  const filtered = existing.filter(m => m.type !== type);
  return makeNode(node.text, filtered);
}

function mapRange(nodes, start, end, fn) {
  if (start >= end || !Array.isArray(nodes)) return Array.isArray(nodes) ? normalize(nodes) : [];
  const out = [];
  let pos = 0;
  for (const node of nodes) {
    const len = node.text?.length ?? 0;
    const nodeStart = pos;
    const nodeEnd = pos + len;
    pos = nodeEnd;
    if (len === 0) continue;
    if (nodeEnd <= start || nodeStart >= end) {
      out.push(makeNode(node.text, cloneMarks(node.marks)));
      continue;
    }
    const localStart = Math.max(0, start - nodeStart);
    const localEnd = Math.min(len, end - nodeStart);
    if (localStart > 0) {
      out.push(makeNode(node.text.slice(0, localStart), cloneMarks(node.marks)));
    }
    if (localEnd > localStart) {
      const middle = makeNode(node.text.slice(localStart, localEnd), cloneMarks(node.marks));
      out.push(fn(middle));
    }
    if (localEnd < len) {
      out.push(makeNode(node.text.slice(localEnd), cloneMarks(node.marks)));
    }
  }
  return normalize(out);
}

export function applyMark(nodes, start, end, mark) {
  return mapRange(nodes, start, end, n => addMarkToNode(n, mark));
}

export function removeAllMarks(nodes, start, end) {
  return mapRange(nodes, start, end, n => makeNode(n.text));
}

export function removeMark(nodes, start, end, type) {
  return mapRange(nodes, start, end, n => removeMarkFromNode(n, type));
}

// Retire le type existant et applique la nouvelle mark — utile pour remplacer
// un lien sans passer par le toggle (qui retirerait si même href).
export function setMark(nodes, start, end, mark) {
  const removed = removeMark(nodes, start, end, mark.type);
  return applyMark(removed, start, end, mark);
}

// Toggle : si toute la plage porte déjà la mark (au sens markKey strict),
// on la retire ; sinon on l'applique partout.
export function toggleMark(nodes, start, end, mark) {
  const active = marksInRange(nodes, start, end).some(m => markKey(m) === markKey(mark));
  if (active) {
    if (mark.type === 'color' || mark.type === 'highlight' || mark.type === 'link') {
      return removeMark(nodes, start, end, mark.type);
    }
    return removeMark(nodes, start, end, mark.type);
  }
  return applyMark(nodes, start, end, mark);
}

// Insère du texte brut à un offset, héritant des marks de l'environnement.
// Utilisé après une transformation pour réinjecter du texte sans perdre le
// contexte. Phase 2 ne s'en sert pas en édition (la frappe passe par le
// DOM puis `domToInline`), mais c'est utile pour les helpers programmatiques.
export function insertText(nodes, offset, text) {
  if (!text) return normalize(nodes ?? []);
  const before = sliceInline(nodes, 0, offset);
  const after = sliceInline(nodes, offset, inlineLength(nodes));
  // Hérite des marks du nœud à gauche (continuité de la frappe).
  const marks = marksAtOffset(nodes, Math.max(0, offset - 1));
  const inserted = [makeNode(text, marks)];
  return concatInline(concatInline(before, inserted), after);
}
