/**
 * CSS custom properties formatter.
 *
 * Converts a TokenDocument to one or more CSS files containing `:root { ... }`
 * (and optionally per-mode selectors like `.dark { ... }`). The first
 * "real" non-DTCG output, completing the canonical-to-runtime pipeline.
 *
 * Behavior:
 *   - Each token becomes a CSS custom property. Path joined with `-`,
 *     optionally prefixed. `color/primary` → `--color-primary`.
 *   - Aliases resolve to `var(--target-token)` so CSS cascading still works.
 *   - Composite tokens (typography, shadow) expand into multiple primitive
 *     custom properties since CSS doesn't natively express composites.
 *   - Single-mode tokens go in `:root`.
 *   - Multi-mode tokens emit per-mode selectors. Heuristic: a mode named
 *     `Light`/`Default` becomes `:root`; `Dark` becomes `.dark` (Tailwind
 *     convention); other modes become `[data-theme="<name>"]`.
 *   - splitByMode emits one file per mode with just that mode's values.
 *   - splitByCollection emits one file per set.
 */

import type { Token, TokenDocument, TokenSet, TokenValue } from "../types.js";
import { buildTokenIndex, referenceTargetPath } from "../alias-resolver.js";
import type { FormatOptions, FormatResult } from "./index.js";

export function formatCssVars(
  doc: TokenDocument,
  opts: FormatOptions,
): FormatResult {
  const warnings: string[] = [];
  const files: FormatResult["files"] = [];

  // Index over the WHOLE document (not per-file subsets) so set-qualified
  // alias references ({set-slug.path}) resolve to their target token's own
  // path when generating var(--...) names.
  const tokenIndex = buildTokenIndex(doc, warnings);

  const splitByMode = opts.target.splitByMode ?? false;
  const splitByCollection = opts.target.splitByCollection ?? false;
  const prefix = opts.target.prefix ?? "";

  if (splitByMode && splitByCollection) {
    // One file per (set, mode) pair.
    for (const set of doc.sets) {
      for (const mode of set.modes) {
        files.push({
          path: filenameFor(opts, set, mode),
          content: renderSingleSelector(
            doc.sets.filter((s) => s.name === set.name),
            mode,
            selectorFor(mode),
            prefix,
            tokenIndex,
            warnings,
          ),
        });
      }
    }
  } else if (splitByMode) {
    // One file per mode, all sets combined under that mode's selector.
    const allModes = new Set<string>();
    for (const set of doc.sets) for (const m of set.modes) allModes.add(m);
    for (const mode of allModes) {
      const setsWithMode = doc.sets.filter((s) => s.modes.includes(mode));
      files.push({
        path: filenameFor(opts, undefined, mode),
        content: renderSingleSelector(
          setsWithMode,
          mode,
          selectorFor(mode),
          prefix,
          tokenIndex,
          warnings,
        ),
      });
    }
  } else if (splitByCollection) {
    // One file per set, all modes combined as separate selectors within.
    for (const set of doc.sets) {
      files.push({
        path: filenameFor(opts, set),
        content: renderMultiSelector([set], prefix, tokenIndex, warnings),
      });
    }
  } else {
    // Single file with everything.
    files.push({
      path: filenameFor(opts),
      content: renderMultiSelector(doc.sets, prefix, tokenIndex, warnings),
    });
  }

  return { files, warnings };
}

function filenameFor(
  opts: FormatOptions,
  set?: TokenSet,
  mode?: string,
): string {
  if (opts.target.filename) return opts.target.filename;
  const parts: string[] = [];
  if (set) parts.push(slugify(set.name));
  if (mode) parts.push(slugify(mode));
  if (parts.length === 0) parts.push("tokens");
  return `${parts.join(".")}.css`;
}

function slugify(s: string): string {
  return s
    .trim()
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/^-+|-+$/g, "");
}

/**
 * Map a mode name to a CSS selector. Conventional defaults that match what
 * most CSS frameworks expect:
 *   - Default / Light / Value → `:root` (the document root, runs always)
 *   - Dark → `.dark` (matches Tailwind's darkMode: "class" config)
 *   - anything else → `[data-theme="<name>"]` (general escape hatch)
 */
function selectorFor(mode: string): string {
  const lower = mode.toLowerCase();
  if (lower === "default" || lower === "light" || lower === "value") {
    return ":root";
  }
  if (lower === "dark") {
    return ".dark";
  }
  return `[data-theme="${slugify(mode)}"]`;
}

/**
 * Render all tokens across the given sets under a single selector. Used when
 * one file holds one mode's worth of vars (splitByMode output).
 */
function renderSingleSelector(
  sets: TokenSet[],
  mode: string,
  selector: string,
  prefix: string,
  tokenIndex: Map<string, Token>,
  warnings: string[],
): string {
  const lines: string[] = [];
  lines.push(`/* Generated by figma-console-mcp — do not edit by hand */`);
  lines.push(`${selector} {`);
  for (const set of sets) {
    for (const token of set.tokens) {
      const value = token.values[mode];
      if (!value) continue;
      emitTokenLines(token, value, prefix, tokenIndex, lines, warnings);
    }
  }
  lines.push(`}`);
  lines.push("");
  return lines.join("\n");
}

/**
 * Render multiple sets across multiple modes in one file. Each mode gets its
 * own selector block.
 */
function renderMultiSelector(
  sets: TokenSet[],
  prefix: string,
  tokenIndex: Map<string, Token>,
  warnings: string[],
): string {
  const lines: string[] = [];
  lines.push(`/* Generated by figma-console-mcp — do not edit by hand */`);

  // Collect all (mode, selector) pairs across all sets.
  const modeToSelector = new Map<string, string>();
  for (const set of sets) {
    for (const mode of set.modes) {
      if (!modeToSelector.has(mode)) {
        modeToSelector.set(mode, selectorFor(mode));
      }
    }
  }

  for (const [mode, selector] of modeToSelector) {
    lines.push(`${selector} {`);
    for (const set of sets) {
      if (!set.modes.includes(mode)) continue;
      for (const token of set.tokens) {
        const value = token.values[mode];
        if (!value) continue;
        emitTokenLines(token, value, prefix, tokenIndex, lines, warnings);
      }
    }
    lines.push(`}`);
    lines.push("");
  }

  return lines.join("\n");
}

/**
 * Emit one or more CSS custom property declarations for a single token.
 * Primitives emit one line; composite tokens (typography, shadow) expand
 * into multiple lines.
 */
function emitTokenLines(
  token: Token,
  value: TokenValue,
  prefix: string,
  tokenIndex: Map<string, Token>,
  out: string[],
  warnings: string[],
): void {
  // Every path segment must be a valid CSS identifier — slugify each to
  // normalize spaces, dots, and other special characters that show up in
  // real Figma variable names (e.g. "tailwind colors/purple/50").
  const cssName = `--${prefix}${pathToCssName(token.path)}`;

  if (value.reference) {
    // Detect the cross-library alias sentinel ({__library:VariableID:...}).
    // Emitting var() for these produces broken CSS — the target doesn't
    // live in this file's variable set. Emit a clear comment that names
    // the original library variable ID instead, so the user can decide
    // how to handle it (manual literal, library import, etc.).
    const bareRef = value.reference.replace(/^\{|\}$/g, "");
    const libMatch = bareRef.match(/^__library:(.+)$/);
    if (libMatch || bareRef === "unknown") {
      const originalId = libMatch ? libMatch[1] : "unknown";
      warnings.push(
        `Skipped ${token.path.join(".")} in CSS — references cross-library variable ${originalId}.`,
      );
      out.push(
        `  /* ${cssName}: skipped — cross-library alias to ${originalId} */`,
      );
      return;
    }

    // Alias → var(--other) so CSS cascading semantics are preserved.
    // Resolve set-qualified references ({set-slug.path}) to the target
    // token's own path via the index (the target's declaration is named
    // from its path, without the set qualifier); bare/unresolvable refs
    // fall back to the raw reference path. Either way the segments go
    // through the same slugify treatment as the source.
    const refPath = referenceTargetPath(value.reference, tokenIndex);
    const targetCssName = pathToCssName(refPath);
    out.push(`  ${cssName}: var(--${prefix}${targetCssName});`);
    return;
  }

  if (value.literal === undefined || value.literal === null) {
    warnings.push(
      `Token ${token.path.join(".")} has no value — emitting nothing.`,
    );
    return;
  }

  // Composite tokens — expand into multiple primitive vars.
  if (token.type === "typography" && typeof value.literal === "object") {
    const t = value.literal as Record<string, unknown>;
    for (const subField of ["fontFamily", "fontSize", "fontWeight", "lineHeight", "letterSpacing"]) {
      if (t[subField] !== undefined) {
        out.push(`  ${cssName}-${kebab(subField)}: ${formatCssValue(t[subField], token.type)};`);
      }
    }
    return;
  }
  if (token.type === "shadow" && typeof value.literal === "object") {
    const shadowCss = renderShadow(value.literal);
    if (shadowCss) {
      out.push(`  ${cssName}: ${shadowCss};`);
    } else {
      warnings.push(`Token ${token.path.join(".")} has an unparseable shadow value — skipping.`);
    }
    return;
  }

  // Primitives.
  out.push(`  ${cssName}: ${formatCssValue(value.literal, token.type)};`);
}

function kebab(s: string): string {
  return s.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase();
}

/**
 * Convert a path array to a CSS-safe custom property name. Each segment is
 * slugified (lowercase, non-alphanumerics → hyphens) before joining.
 *
 * Examples:
 *   ["color", "primary"]                  → "color-primary"
 *   ["tailwind colors", "purple", "50"]   → "tailwind-colors-purple-50"
 *   ["UI", "background-default"]          → "ui-background-default"
 */
function pathToCssName(path: string[]): string {
  return path.map((seg) => slugify(seg)).join("-");
}

/**
 * Render a primitive value as a CSS literal.
 *   - Numbers gain a "px" suffix when the token type is `dimension`.
 *   - Color-typed strings pass through (hex, oklch, rgba, hsl).
 *   - fontFamily-typed strings get quoted (multi-word family names are
 *     invalid CSS otherwise — e.g. "Geist Mono" must be `"Geist Mono"`).
 *   - Plain string tokens get quoted too, since CSS treats unquoted
 *     identifiers as keywords. Strings that look like a CSS color or unit
 *     stay unquoted.
 *   - Booleans render as their string form.
 */
function formatCssValue(value: unknown, type: string): string {
  if (typeof value === "number") {
    if (type === "dimension") return `${value}px`;
    return String(value);
  }
  if (typeof value === "string") {
    // Color-typed values are always pre-formatted CSS color literals.
    if (type === "color") return value;
    // fontFamily and plain strings need quoting unless they look like a
    // CSS-safe identifier with no special characters. Quote conservatively
    // — over-quoting is fine, under-quoting breaks the cascade.
    if (type === "fontFamily" || type === "string") {
      return needsQuoting(value) ? JSON.stringify(value) : value;
    }
    // Dimensions and other types: pass through (already formatted upstream).
    return value;
  }
  if (typeof value === "boolean") return String(value);
  return JSON.stringify(value);
}

/**
 * Returns true if a string value needs to be wrapped in CSS quotes. Multi-word
 * values, values with special characters, or values that aren't pure
 * alphanumeric identifiers all need quoting.
 */
function needsQuoting(s: string): boolean {
  // Anything that already has quotes is fine.
  if (/^["']/.test(s)) return false;
  // Pure-numeric or unit-bearing values don't need quotes (they're not
  // identifiers).
  if (/^[\d.]+([a-z%]+)?$/.test(s)) return false;
  // CSS keyword identifiers (single word, alphanumerics + hyphen only).
  if (/^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(s)) {
    // Reserved CSS keywords that shouldn't be quoted (the user may have
    // legitimately written e.g. "inherit", "currentColor", "transparent").
    const cssKeywords = new Set([
      "inherit",
      "initial",
      "unset",
      "revert",
      "currentColor",
      "transparent",
      "none",
      "auto",
    ]);
    if (cssKeywords.has(s)) return false;
    // Any other single identifier — for font-family this is OK unquoted
    // ("Inter" works) but it's safer to quote for clarity. Keep unquoted
    // to match the more common convention.
    return false;
  }
  // Multi-word, special chars, etc. → quote.
  return true;
}

function renderShadow(shadow: unknown): string | null {
  if (Array.isArray(shadow)) {
    return shadow
      .map((s) => renderShadow(s))
      .filter(Boolean)
      .join(", ");
  }
  if (!shadow || typeof shadow !== "object") return null;
  const s = shadow as Record<string, unknown>;
  const inset = s.inset ? "inset " : "";
  const x = withPx(s.offsetX);
  const y = withPx(s.offsetY);
  const blur = withPx(s.blur);
  const spread = s.spread !== undefined ? ` ${withPx(s.spread)}` : "";
  const color = s.color;
  if (!x || !y || !blur || typeof color !== "string") return null;
  return `${inset}${x} ${y} ${blur}${spread} ${color}`;
}

function withPx(v: unknown): string {
  if (typeof v === "number") return `${v}px`;
  if (typeof v === "string") return v;
  return "";
}
