const fs = require('fs')
const path = require('path')
const t = require('@babel/types')

const {
  findReturnRootJsxPath,
  findGroupIndex,
  findPrimaryComponentFunctionPath,
  findEnclosingFunctionPath,
  pascalNameFromFunctionPath,
} = require('./field-hoist')

const HEX_COLOR_RE = /^#(?:[0-9a-fA-F]{3,4}){1,2}$/
const RGB_COLOR_RE = /^rgba?\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*(?:,\s*(?:\d*\.?\d+)\s*)?\)$/
const URL_PROP_NAMES = new Set(['src', 'href', 'poster', 'action'])
const IMG_LIKE_TAGS = new Set(['img', 'Image', 'video', 'source', 'a', 'Link', 'iframe'])

/** Elements that the CMS surfaces as a single Image field (media library / upload picker). */
const IMAGE_FIELD_TAGS = new Set(['img', 'Image'])

/** Tags where inline text is treated as RichText (avoid tagging every div). Compared case-insensitively. */
const TEXT_RICH_TAGS = new Set([
  'p',
  'span',
  'div',
  'h1',
  'h2',
  'h3',
  'h4',
  'h5',
  'h6',
  'text',
  'label',
  'a',
  'link',
  'button',
  'strong',
  'em',
  'b',
  'i',
  'small',
  'li',
])

function isTextRichTag(tagName) {
  return typeof tagName === 'string' && TEXT_RICH_TAGS.has(tagName.toLowerCase())
}

const ASPECT_RATIO_RE = /^(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)$/
const ASPECT_CLASS_RE = /aspect-\[(\d+)\/(\d+)\]/

const TEMPLATE_EXPR_MARKER = '__BILDIT_TEMPLATE_EXPR__'

function loadFieldMap(fieldMapPath) {
  if (!fieldMapPath || !fs.existsSync(fieldMapPath)) {
    return { fields: {}, nextIndex: {} }
  }
  try {
    return JSON.parse(fs.readFileSync(fieldMapPath, 'utf8'))
  } catch {
    return { fields: {}, nextIndex: {} }
  }
}

function saveFieldMap(fieldMapPath, map) {
  if (!fieldMapPath) return
  fs.mkdirSync(path.dirname(fieldMapPath), { recursive: true })
  fs.writeFileSync(fieldMapPath, JSON.stringify(map, null, 2) + '\n', 'utf8')
}

/** First segment of a camelCase field id: `STEPS` → `steps`, `Hero` → `hero`. */
function normalizeFirstSegmentForCamel(seg) {
  if (typeof seg !== 'string' || !seg.length) return seg
  if (/^[A-Z][A-Z0-9_]*$/.test(seg)) {
    return seg.toLowerCase()
  }
  return seg.charAt(0).toLowerCase() + seg.slice(1)
}

function capitalize(seg) {
  if (typeof seg !== 'string' || !seg.length) return ''
  return seg.charAt(0).toUpperCase() + seg.slice(1)
}

/** e.g. `panelAltSrc` → `panelAltSource` (no extra kind suffixes). */
function expandReadableIdentifiers(base) {
  if (!base || typeof base !== 'string') return base
  let s = base
  if (/Src$/i.test(s) && !/Source$/i.test(s)) s = s.replace(/Src$/i, 'Source')
  return s
}

function segmentsToCamelIdentifier(segments) {
  if (!segments.length) return ''
  return segments
    .map((seg, idx) =>
      idx === 0 ? normalizeFirstSegmentForCamel(seg) : capitalize(seg),
    )
    .join('')
    .replace(/[^A-Za-z0-9_$]/g, '')
}

function normalizeWordsToCamelIdentifier(input) {
  const s = input == null ? '' : String(input)
  const cleaned = s
    .replace(/&[a-zA-Z]+;/g, ' ')
    .replace(/['’]/g, '')
    .replace(/[^A-Za-z0-9]+/g, ' ')
    .trim()
  if (!cleaned) return ''
  const parts = cleaned.split(/\s+/g).filter(Boolean)
  if (!parts.length) return ''
  // Keep it readable: "Ship Faster" -> shipFaster; avoid mega-identifiers.
  const limited = parts.slice(0, 5)
  let id = segmentsToCamelIdentifier(limited)
  if (!id) return ''
  if (/^\d/.test(id)) id = `text${capitalize(id)}`
  return id
}

function ensureRichTextSuffix(base) {
  if (!base || typeof base !== 'string') return ''
  if (/Text$/.test(base)) return base
  return `${base}Text`
}

function ensureImageSuffix(base) {
  if (!base || typeof base !== 'string') return ''
  if (/Image$/.test(base)) return base
  return `${base}Image`
}

/** "https://cdn/foo/hero-banner.png?x=1" → "heroBanner" (best-effort fallback for naming). */
function identifierFromImageUrl(url) {
  if (!url || typeof url !== 'string') return ''
  let stem = url.split('?')[0].split('#')[0]
  const lastSlash = stem.lastIndexOf('/')
  if (lastSlash !== -1) stem = stem.slice(lastSlash + 1)
  const dot = stem.lastIndexOf('.')
  if (dot > 0) stem = stem.slice(0, dot)
  return normalizeWordsToCamelIdentifier(stem)
}

function looksLikeTagDerivedIdentifier(name) {
  if (!name || typeof name !== 'string') return false
  // Common output from path-based naming, e.g. span, p1, span1Span, div2, h1, etc.
  // This is intentionally conservative: we only auto-migrate names that strongly look tag-derived.
  const TAGS = '(?:div|span|p|h[1-6]|Text|label|a|Link|Button|strong|em|b|i|small|li|el)'
  return new RegExp(`^${TAGS}\\d*(?:${TAGS}\\d*)*$`).test(name)
}

function deriveImageFieldNameFromValue({
  alt,
  url,
  jsxPath,
  strategy,
  fieldMap,
  componentName,
  nestingPascalName = null,
}) {
  const mapKey = nestingPascalName
    ? `${nestingPascalName}::${jsxPath}|Image`
    : `${jsxPath}|Image`
  const existing = fieldMap.fields[mapKey]
  const usedNames = new Set(Object.values(fieldMap.fields))

  const altBase = alt ? normalizeWordsToCamelIdentifier(alt) : ''
  const urlBase = url ? identifierFromImageUrl(url) : ''
  const desiredBase = altBase ? ensureImageSuffix(altBase) : urlBase ? ensureImageSuffix(urlBase) : ''

  // Migrate older mappings: prefer a value-derived name over tag-derived `imgImage`/`image1`.
  if (existing && desiredBase && looksLikeTagDerivedIdentifier(existing.replace(/Image$/, ''))) {
    if (!usedNames.has(desiredBase)) {
      fieldMap.fields[mapKey] = desiredBase
      return desiredBase
    }
    let i = 2
    let candidate = `${desiredBase}${i}`
    while (usedNames.has(candidate)) {
      i += 1
      candidate = `${desiredBase}${i}`
    }
    fieldMap.fields[mapKey] = candidate
    return candidate
  }

  if (existing) return existing

  if (desiredBase && !usedNames.has(desiredBase)) {
    fieldMap.fields[mapKey] = desiredBase
    return desiredBase
  }
  if (desiredBase) {
    let i = 2
    let candidate = `${desiredBase}${i}`
    while (usedNames.has(candidate)) {
      i += 1
      candidate = `${desiredBase}${i}`
    }
    fieldMap.fields[mapKey] = candidate
    return candidate
  }

  // Fall back to the path-based name derivation (mirrors the RichText fallback).
  const fallback = deriveFieldName({
    jsxPath,
    kind: 'Image',
    strategy,
    fieldMap,
    componentName,
    nestingPascalName,
  })
  fieldMap.fields[mapKey] = fallback
  return fallback
}

function deriveRichTextFieldNameFromValue({
  value,
  jsxPath,
  strategy,
  fieldMap,
  componentName,
  nestingPascalName = null,
}) {
  const mapKey = nestingPascalName
    ? `${nestingPascalName}::${jsxPath}|RichText`
    : `${jsxPath}|RichText`
  const existing = fieldMap.fields[mapKey]

  const usedNames = new Set(Object.values(fieldMap.fields))
  const baseFromValue = normalizeWordsToCamelIdentifier(value)
  const desiredBase = baseFromValue ? ensureRichTextSuffix(baseFromValue) : ''

  // If we have a good value-derived name and the existing mapping is tag-derived,
  // auto-migrate to the better name.
  if (existing && desiredBase && looksLikeTagDerivedIdentifier(existing)) {
    if (!usedNames.has(desiredBase)) {
      fieldMap.fields[mapKey] = desiredBase
      return desiredBase
    }
    let i = 2
    let candidate = `${desiredBase}${i}`
    while (usedNames.has(candidate)) {
      i += 1
      candidate = `${desiredBase}${i}`
    }
    fieldMap.fields[mapKey] = candidate
    return candidate
  }

  // If we can derive a value-based name and the existing is the older unsuffixed value name
  // (e.g. "experiences" -> "experiencesText"), auto-migrate.
  if (existing && desiredBase && existing === baseFromValue) {
    if (!usedNames.has(desiredBase)) {
      fieldMap.fields[mapKey] = desiredBase
      return desiredBase
    }
    let i = 2
    let candidate = `${desiredBase}${i}`
    while (usedNames.has(candidate)) {
      i += 1
      candidate = `${desiredBase}${i}`
    }
    fieldMap.fields[mapKey] = candidate
    return candidate
  }

  // Keep stable mapping if it exists and doesn't match the cases above.
  if (existing) return existing

  if (desiredBase && !usedNames.has(desiredBase)) {
    fieldMap.fields[mapKey] = desiredBase
    return desiredBase
  }
  if (desiredBase) {
    let i = 2
    let candidate = `${desiredBase}${i}`
    while (usedNames.has(candidate)) {
      i += 1
      candidate = `${desiredBase}${i}`
    }
    fieldMap.fields[mapKey] = candidate
    return candidate
  }

  // Fallback to the existing path-based name derivation.
  const fallback = deriveFieldName({
    jsxPath,
    kind: 'RichText',
    strategy,
    fieldMap,
    componentName,
    nestingPascalName,
  })
  fieldMap.fields[mapKey] = fallback
  return fallback
}

const ORDINAL_PREFIXES = [
  'first',
  'second',
  'third',
  'fourth',
  'fifth',
  'sixth',
  'seventh',
  'eighth',
  'ninth',
  'tenth',
  'eleventh',
  'twelfth',
  'thirteenth',
  'fourteenth',
  'fifteenth',
  'sixteenth',
  'seventeenth',
  'eighteenth',
  'nineteenth',
  'twentieth',
]

function ordinalPrefixForIndex(index) {
  if (index >= 0 && index < ORDINAL_PREFIXES.length) return ORDINAL_PREFIXES[index]
  return `item${index}`
}

/**
 * Names for fields lifted from module-scope arrays: `firstTitle`, `secondPanelAlt`, …
 * Uses ordinal + property key; adds a numeric suffix only if the name is already taken.
 */
function deriveArrayEntryFieldName({ arrayName, elementIndex, propertyKey, kind, fieldMap }) {
  const mapKey = `${arrayName}[${elementIndex}].${propertyKey}|${kind}`
  if (fieldMap.fields[mapKey]) return fieldMap.fields[mapKey]

  const ord = ordinalPrefixForIndex(elementIndex)
  const pk = String(propertyKey || 'field').replace(/[^A-Za-z0-9_$]/g, '')
  let base = `${ord}${capitalize(pk)}`
  base = expandReadableIdentifiers(base)
  if (!base) base = 'field'

  const usedNames = new Set(Object.values(fieldMap.fields))
  let candidate = base
  let i = 2
  while (usedNames.has(candidate)) {
    candidate = `${base}${i}`
    i += 1
  }

  fieldMap.fields[mapKey] = candidate
  return candidate
}

function pascalNameToNestingKey(name) {
  if (!name || typeof name !== 'string') return ''
  // StatSevenGradient → statSevenGradient (Babel name segments, one camel chunk)
  return name.charAt(0).toLowerCase() + name.slice(1)
}

/**
 * Turn path segment `p_0`, `span_1` into a stable part for field ids (`p`, `span1`). Keeps
 * map keys (raw `name_index`) unique while favoring short readable names in output.
 */
function segmentForName(seg) {
  if (typeof seg !== 'string' || !seg.length) return seg
  const m = /^([A-Za-z][A-Za-z0-9$]*)_(\d+)$/.exec(seg)
  if (!m) return seg
  const name = m[1]
  const num = m[2]
  if (num === '0') return name
  return `${name}${num}`
}

function deriveFieldName({ jsxPath, kind, strategy, fieldMap, componentName, nestingPascalName = null }) {
  const mapKey = nestingPascalName
    ? `${nestingPascalName}::${jsxPath}|${kind}`
    : `${jsxPath}|${kind}`
  if (fieldMap.fields[mapKey]) return fieldMap.fields[mapKey]

  const rawSegments = jsxPath.split('.').filter(Boolean)
  const segments = rawSegments.map(segmentForName)

  const nestKey = nestingPascalName ? pascalNameToNestingKey(nestingPascalName) : null
  const componentPrefix = componentName
    ? componentName.charAt(0).toLowerCase() + componentName.slice(1)
    : null
  const effective = nestKey
    ? [nestKey, ...segments]
    : componentPrefix && segments[0] !== componentPrefix
    ? [componentPrefix, ...segments]
    : segments

  const usedNames = new Set(Object.values(fieldMap.fields))

  if (strategy === 'layerPath') {
    let candidate = expandReadableIdentifiers(segmentsToCamelIdentifier(effective)) || 'field'
    let i = 2
    let final = candidate
    while (usedNames.has(final)) {
      final = `${candidate}${i}`
      i += 1
    }
    fieldMap.fields[mapKey] = final
    return final
  }

  let candidate = null
  for (let take = 1; take <= effective.length; take += 1) {
    const subset = effective.slice(-take)
    const base = expandReadableIdentifiers(segmentsToCamelIdentifier(subset))
    if (!base) continue
    // Avoid single-letter ids when a longer path is available (e.g. section + p → sectionP).
    if (base.length < 2 && take < effective.length) continue
    if (!usedNames.has(base)) {
      candidate = base
      break
    }
  }

  if (!candidate) {
    const full = expandReadableIdentifiers(segmentsToCamelIdentifier(effective)) || 'field'
    let c = full
    let i = 2
    while (usedNames.has(c)) {
      c = `${full}${i}`
      i += 1
    }
    candidate = c
  }

  fieldMap.fields[mapKey] = candidate
  return candidate
}

function getJsxElementSiblingIndex(jsxPath) {
  const parent = jsxPath.parentPath
  if (!parent || !parent.isJSXElement()) return 0
  let i = 0
  for (const ch of parent.node.children) {
    if (!t.isJSXElement(ch)) continue
    if (ch === jsxPath.node) return i
    i += 1
  }
  return 0
}

/**
 * A dotted path of `${tagName}_${siblingIndex}` from root to the current element,
 * so sibling/ nested identical tags (e.g. h1 > span > span) stay distinct in field ids.
 * `openingName` is kept for call-site compatibility but unused (path already ends at the leaf).
 */
function getJsxPath(elementPath) {
  const parts = []
  let p = elementPath
  while (p) {
    if (p.isJSXElement()) {
      const oe = p.node.openingElement
      const n = t.isJSXIdentifier(oe.name) ? oe.name.name : 'el'
      const idx = getJsxElementSiblingIndex(p)
      parts.unshift(`${n}_${idx}`)
    }
    p = p.parentPath
  }
  return parts.join('.')
}

function findComponentName(ast, traverse) {
  let name = null
  traverse(ast, {
    ExportDefaultDeclaration(p) {
      const d = p.node.declaration
      if (t.isIdentifier(d)) name = d.name
      else if (t.isFunctionDeclaration(d) && d.id) name = d.id.name
      p.stop()
    },
  })
  if (name) return name
  traverse(ast, {
    ExportNamedDeclaration(p) {
      const d = p.node.declaration
      if (t.isFunctionDeclaration(d) && d.id && /^[A-Z]/.test(d.id.name)) {
        name = d.id.name
        p.stop()
      }
    },
  })
  if (name) return name
  traverse(ast, {
    VariableDeclarator(p) {
      if (name) return
      if (!t.isIdentifier(p.node.id)) return
      const init = p.node.init
      if (t.isArrowFunctionExpression(init) || t.isFunctionExpression(init)) {
        const n = p.node.id.name
        if (n[0] === n[0].toUpperCase()) name = n
      }
    },
  })
  return name
}

function templatePlaceholder(fieldName, kind, defaultValue) {
  const payload = JSON.stringify({ fieldName, kind, defaultValue })
  return t.stringLiteral(`${TEMPLATE_EXPR_MARKER}${payload}${TEMPLATE_EXPR_MARKER}`)
}

/**
 * Coerce mixed Image defaults (string URL or `{ url, alt }` object) to a normalized
 * `{ url, alt }` shape so v1/v2 emitters can both rely on the same fields.
 */
function normalizeImageDefault(defaultValue) {
  if (defaultValue && typeof defaultValue === 'object' && !Array.isArray(defaultValue)) {
    const url = typeof defaultValue.url === 'string' ? defaultValue.url : ''
    const alt = typeof defaultValue.alt === 'string' ? defaultValue.alt : ''
    return { url, alt }
  }
  const url = defaultValue == null ? '' : String(defaultValue)
  return { url, alt: '' }
}

/**
 * v2 templates: valid JS/TS — const initializers are plain literals; field types live in JSDoc (@type).
 * Legacy templates use templatePlaceholder + replaceTemplatePlaceholders → $() syntax.
 */
function literalFieldInitializer(_fieldName, kind, defaultValue) {
  if (kind === 'Boolean') {
    const b = defaultValue === true || defaultValue === 'true'
    return t.booleanLiteral(b)
  }
  if (kind === 'Number') {
    const n = typeof defaultValue === 'number' ? defaultValue : Number(defaultValue)
    if (Number.isFinite(n)) return t.numericLiteral(n)
    return t.numericLiteral(0)
  }
  if (kind === 'Image') {
    const { url, alt } = normalizeImageDefault(defaultValue)
    return t.objectExpression([
      t.objectProperty(t.identifier('url'), t.stringLiteral(url)),
      t.objectProperty(t.identifier('alt'), t.stringLiteral(alt)),
    ])
  }
  const s = defaultValue == null ? '' : String(defaultValue)
  return t.stringLiteral(s)
}

function isTextRichElement(jsxElementPath) {
  const oe = jsxElementPath.node.openingElement
  if (!t.isJSXOpeningElement(oe) || !t.isJSXIdentifier(oe.name)) return false
  return isTextRichTag(oe.name.name)
}

/**
 * True when this JSX element has direct text descendants we tag as RichText (same rules as JSXText walker).
 */
function jsxElementHostsDirectRichTextContent(jsxElementPath) {
  if (!jsxElementPath || !jsxElementPath.node) return false
  if (!jsxElementPath.isJSXElement()) return false
  const oe = jsxElementPath.node.openingElement
  if (!t.isJSXOpeningElement(oe) || !t.isJSXIdentifier(oe.name)) return false
  if (!isTextRichTag(oe.name.name)) return false

  const children = jsxElementPath.node.children || []
  for (const child of children) {
    if (t.isJSXText(child)) {
      if (String(child.value).trim()) return true
    }
    if (t.isJSXExpressionContainer(child) && t.isStringLiteral(child.expression)) {
      if (String(child.expression.value).trim()) return true
    }
  }
  return false
}

/** RichText values are objects: render `name.text`; typography stays on the element for CSS inheritance. */
function richTextTextExpressionContainer(fieldName) {
  return t.jsxExpressionContainer(
    t.memberExpression(t.identifier(fieldName), t.identifier('text'), false),
  )
}

/** True when inline `style={{ … }}` has literal typography props we mirror into RichText CMS defaults only. */
function hasInlineRichTextTypography(styleDefaults) {
  if (!styleDefaults || typeof styleDefaults !== 'object') return false
  return !!(
    (styleDefaults.color != null && styleDefaults.color !== '') ||
    (styleDefaults.fontWeight != null && styleDefaults.fontWeight !== '') ||
    (styleDefaults.fontStyle != null && styleDefaults.fontStyle !== '') ||
    (styleDefaults.textDecoration != null && styleDefaults.textDecoration !== '')
  )
}

function readInlineStyleTextDefaults(jsxElementPath) {
  const oe = jsxElementPath && jsxElementPath.node ? jsxElementPath.node.openingElement : null
  if (!t.isJSXOpeningElement(oe)) return {}
  const attrs = Array.isArray(oe.attributes) ? oe.attributes : []
  const styleAttr = attrs.find(
    (a) => t.isJSXAttribute(a) && t.isJSXIdentifier(a.name, { name: 'style' }),
  )
  if (!styleAttr || !styleAttr.value) return {}
  if (!t.isJSXExpressionContainer(styleAttr.value)) return {}
  const expr = styleAttr.value.expression
  if (!t.isObjectExpression(expr)) return {}

  const out = {}
  for (const prop of expr.properties) {
    if (!t.isObjectProperty(prop)) continue
    const key = t.isIdentifier(prop.key)
      ? prop.key.name
      : t.isStringLiteral(prop.key)
      ? prop.key.value
      : null
    if (key !== 'color' && key !== 'fontWeight' && key !== 'fontStyle' && key !== 'textDecoration') continue
    if (t.isStringLiteral(prop.value)) out[key] = prop.value.value
    else if (t.isNumericLiteral(prop.value)) out[key] = prop.value.value
  }
  return out
}

function isOnUrlElement(attrPath) {
  const oe = attrPath.parent
  if (!t.isJSXOpeningElement(oe)) return false
  if (!t.isJSXIdentifier(oe.name)) return false
  return IMG_LIKE_TAGS.has(oe.name.name)
}

/** Locate a JSXAttribute on a JSXOpeningElement by name. Returns null when absent. */
function findJsxAttributeByName(openingEl, attrName) {
  if (!t.isJSXOpeningElement(openingEl)) return null
  const attrs = Array.isArray(openingEl.attributes) ? openingEl.attributes : []
  for (const a of attrs) {
    if (!t.isJSXAttribute(a)) continue
    if (!t.isJSXIdentifier(a.name)) continue
    if (a.name.name === attrName) return a
  }
  return null
}

/** Read the literal string value off a JSXAttribute (`x="foo"` or `x={"foo"}`); null otherwise. */
function readJsxAttrStringLiteral(attr) {
  if (!attr) return null
  const v = attr.value
  if (t.isStringLiteral(v)) return v.value
  if (t.isJSXExpressionContainer(v) && t.isStringLiteral(v.expression)) {
    return v.expression.value
  }
  return null
}

function tagTemplateFields({
  ast,
  traverse,
  fieldMapPath,
  namingStrategy = 'camelComponent',
  logger = () => {},
}) {
  const fieldMap = loadFieldMap(fieldMapPath)
  fieldMap.fields = fieldMap.fields || {}
  const componentName = findComponentName(ast, traverse)
  const primaryFnPath = findPrimaryComponentFunctionPath(ast, traverse)
  const rootPath = findReturnRootJsxPath(ast, traverse)
  const fieldRecords = []

  const report = {
    richTextCount: 0,
    colorCount: 0,
    stringCount: 0,
    booleanCount: 0,
    numberCount: 0,
    imageCount: 0,
    warnings: [],
  }

  function nestingPascalNameFromAnchor(anchorPath) {
    if (!primaryFnPath || !anchorPath) return null
    const enclosing = findEnclosingFunctionPath(anchorPath)
    if (!enclosing) return null
    if (enclosing.node === primaryFnPath.node) return null
    return pascalNameFromFunctionPath(enclosing)
  }

  function recordField(fieldName, kind, defaultValue, anchorPath) {
    const groupIndex = rootPath ? findGroupIndex(rootPath, anchorPath) : -1
    const hoistFunctionName = nestingPascalNameFromAnchor(anchorPath)
    fieldRecords.push({ fieldName, kind, defaultValue, groupIndex, hoistFunctionName })
  }

  traverse(ast, {
    JSXElement: {
      // Run on entry so we replace `src`/`alt` literals before the JSXAttribute visitor
      // (which would otherwise tag `src` as a standalone String field).
      enter(p) {
        const oe = p.node.openingElement
        if (!t.isJSXOpeningElement(oe) || !t.isJSXIdentifier(oe.name)) return
        if (!IMAGE_FIELD_TAGS.has(oe.name.name)) return

        const srcAttr = findJsxAttributeByName(oe, 'src')
        const srcLiteral = readJsxAttrStringLiteral(srcAttr)
        // Only operate on literal `src="..."`. Already-templated/dynamic src means the
        // author wired the image elsewhere — leave it alone.
        if (srcLiteral == null) return

        const altAttr = findJsxAttributeByName(oe, 'alt')
        const altLiteral = readJsxAttrStringLiteral(altAttr) || ''

        const elName = oe.name.name
        const jsxPath = getJsxPath(p, elName)
        const nest = nestingPascalNameFromAnchor(p)
        const fieldName = deriveImageFieldNameFromValue({
          alt: altLiteral,
          url: srcLiteral,
          jsxPath,
          strategy: namingStrategy,
          fieldMap,
          componentName,
          nestingPascalName: nest,
        })

        recordField(fieldName, 'Image', { url: srcLiteral, alt: altLiteral }, p)

        // Rewrite `src="..."` → `src={field.url}` on the same attribute node so source
        // ordering / surrounding attributes are preserved.
        srcAttr.value = t.jsxExpressionContainer(
          t.memberExpression(t.identifier(fieldName), t.identifier('url'), false),
        )

        // Bind `alt` to the same field. Add it when missing so editors always have an alt input.
        const altValue = t.jsxExpressionContainer(
          t.memberExpression(t.identifier(fieldName), t.identifier('alt'), false),
        )
        if (altAttr) {
          altAttr.value = altValue
        } else {
          oe.attributes.push(t.jsxAttribute(t.jsxIdentifier('alt'), altValue))
        }

        report.imageCount += 1
      },
    },

    JSXText(p) {
      const raw = p.node.value
      const trimmed = raw.trim()
      if (!trimmed) return
      if (!isTextRichElement(p.parentPath)) return

      const oe = p.parentPath.node.openingElement
      const elName = t.isJSXIdentifier(oe.name) ? oe.name.name : 'el'
      const jsxPath = getJsxPath(p.parentPath, elName)
      const nest = nestingPascalNameFromAnchor(p)
      const fieldName = deriveRichTextFieldNameFromValue({
        value: trimmed,
        jsxPath,
        strategy: namingStrategy,
        fieldMap,
        componentName,
        nestingPascalName: nest,
      })
      const styleDefaults = readInlineStyleTextDefaults(p.parentPath)
      const richTextDefault = hasInlineRichTextTypography(styleDefaults)
        ? { text: trimmed, ...styleDefaults }
        : { text: trimmed }
      recordField(fieldName, 'RichText', richTextDefault, p)

      const leading = raw.match(/^\s*/)[0]
      const trailing = raw.match(/\s*$/)[0]
      const replacement = []
      if (leading) replacement.push(t.jsxText(leading))
      replacement.push(richTextTextExpressionContainer(fieldName))
      if (trailing) replacement.push(t.jsxText(trailing))
      p.replaceWithMultiple(replacement)
      report.richTextCount += 1
    },

    JSXExpressionContainer(p) {
      if (!t.isStringLiteral(p.node.expression)) return
      const parent = p.parentPath
      if (!parent.isJSXElement()) return
      if (!isTextRichElement(parent)) return

      const str = p.node.expression.value
      if (!str.trim()) return

      const oe = parent.node.openingElement
      const elName = t.isJSXIdentifier(oe.name) ? oe.name.name : 'el'
      const jsxPath = getJsxPath(parent, elName)
      const nest = nestingPascalNameFromAnchor(p)
      const fieldName = deriveRichTextFieldNameFromValue({
        value: str,
        jsxPath,
        strategy: namingStrategy,
        fieldMap,
        componentName,
        nestingPascalName: nest,
      })
      const styleDefaults = readInlineStyleTextDefaults(parent)
      const richTextDefault = hasInlineRichTextTypography(styleDefaults)
        ? { text: str, ...styleDefaults }
        : { text: str }
      recordField(fieldName, 'RichText', richTextDefault, p)

      p.replaceWith(richTextTextExpressionContainer(fieldName))
      report.richTextCount += 1
    },

    JSXAttribute(p) {
      const attrName = t.isJSXIdentifier(p.node.name) ? p.node.name.name : null
      if (!attrName) return
      const value = p.node.value

      if (attrName === 'style' && t.isJSXExpressionContainer(value) && t.isObjectExpression(value.expression)) {
        const oe = p.parent
        const elName = t.isJSXIdentifier(oe.name) ? oe.name.name : 'el'
        const jsxPath = getJsxPath(p.parentPath.parentPath, elName)
        const nest = nestingPascalNameFromAnchor(p)
        for (const prop of value.expression.properties) {
          if (!t.isObjectProperty(prop)) continue
          const key = t.isIdentifier(prop.key)
            ? prop.key.name
            : t.isStringLiteral(prop.key)
            ? prop.key.value
            : null
          if (!key) continue
          if (!t.isStringLiteral(prop.value)) continue
          const v = prop.value.value

          if (key === 'aspectRatio' && ASPECT_RATIO_RE.test(v.trim())) {
            const m = v.trim().match(ASPECT_RATIO_RE)
            const w = parseFloat(m[1], 10)
            const h = parseFloat(m[2], 10)
            const wName = deriveFieldName({
              jsxPath: `${jsxPath}.aspectRatioWidth`,
              kind: 'Number',
              strategy: namingStrategy,
              fieldMap,
              componentName,
              nestingPascalName: nest,
            })
            const hName = deriveFieldName({
              jsxPath: `${jsxPath}.aspectRatioHeight`,
              kind: 'Number',
              strategy: namingStrategy,
              fieldMap,
              componentName,
              nestingPascalName: nest,
            })
            recordField(wName, 'Number', w, p)
            recordField(hName, 'Number', h, p)
            prop.value = t.templateLiteral(
              [
                t.templateElement({ raw: '', cooked: '' }, false),
                t.templateElement({ raw: ' / ', cooked: ' / ' }, false),
                t.templateElement({ raw: '', cooked: '' }, true),
              ],
              [t.identifier(wName), t.identifier(hName)],
            )
            report.numberCount += 2
            continue
          }

          if (!HEX_COLOR_RE.test(v) && !RGB_COLOR_RE.test(v)) continue
          const elementPath = p.parentPath.parentPath
          if (
            key === 'color' &&
            elementPath &&
            jsxElementHostsDirectRichTextContent(elementPath)
          )
            continue
          const subPath = `${jsxPath}.${key}`
          const fieldName = deriveFieldName({
            jsxPath: subPath,
            kind: 'Color',
            strategy: namingStrategy,
            fieldMap,
            componentName,
            nestingPascalName: nest,
          })
          recordField(fieldName, 'Color', v, p)
          prop.value = t.identifier(fieldName)
          report.colorCount += 1
        }
        return
      }

      if (
        attrName === 'className' &&
        t.isStringLiteral(value) &&
        ASPECT_CLASS_RE.test(value.value)
      ) {
        const oe = p.parent
        const elName = t.isJSXIdentifier(oe.name) ? oe.name.name : 'el'
        const jsxPath = getJsxPath(p.parentPath.parentPath, elName)
        const nest = nestingPascalNameFromAnchor(p)
        const m = value.value.match(ASPECT_CLASS_RE)
        const w = parseInt(m[1], 10)
        const h = parseInt(m[2], 10)
        const wName = deriveFieldName({
          jsxPath: `${jsxPath}.aspectRatioWidth`,
          kind: 'Number',
          strategy: namingStrategy,
          fieldMap,
          componentName,
          nestingPascalName: nest,
        })
        const hName = deriveFieldName({
          jsxPath: `${jsxPath}.aspectRatioHeight`,
          kind: 'Number',
          strategy: namingStrategy,
          fieldMap,
          componentName,
          nestingPascalName: nest,
        })
        recordField(wName, 'Number', w, p)
        recordField(hName, 'Number', h, p)
        const before = value.value.replace(ASPECT_CLASS_RE, '').replace(/\s+/g, ' ').trim()
        const prefix = before ? `${before} aspect-[` : 'aspect-['
        p.node.value = t.jsxExpressionContainer(
          t.templateLiteral(
            [
              t.templateElement({ raw: prefix, cooked: prefix }, false),
              t.templateElement({ raw: '/', cooked: '/' }, false),
              t.templateElement({ raw: ']', cooked: ']' }, true),
            ],
            [t.identifier(wName), t.identifier(hName)],
          ),
        )
        report.numberCount += 2
        return
      }

      if (
        attrName === 'className' &&
        t.isStringLiteral(value) &&
        /\brounded(?:-[a-z0-9]+)?\b/.test(value.value)
      ) {
        const oe = p.parent
        const elName = t.isJSXIdentifier(oe.name) ? oe.name.name : 'el'
        const jsxPath = getJsxPath(p.parentPath.parentPath, elName)
        const nest = nestingPascalNameFromAnchor(p)
        const fieldName = deriveFieldName({
          jsxPath: `${jsxPath}.roundedCorners`,
          kind: 'Boolean',
          strategy: namingStrategy,
          fieldMap,
          componentName,
          nestingPascalName: nest,
        })
        recordField(fieldName, 'Boolean', true, p)
        const stripped = value.value.replace(/\s*\brounded(?:-[a-z0-9]+)?\b\s*/g, ' ').replace(/\s+/g, ' ').trim()
        const roundedToken = (value.value.match(/\b(rounded(?:-[a-z0-9]+)?)\b/) || ['', 'rounded-lg'])[1]
        const head = stripped ? `${stripped} ` : ''
        p.node.value = t.jsxExpressionContainer(
          t.templateLiteral(
            [
              t.templateElement({ raw: head, cooked: head }, false),
              t.templateElement({ raw: '', cooked: '' }, true),
            ],
            [
              t.conditionalExpression(
                t.identifier(fieldName),
                t.stringLiteral(roundedToken),
                t.stringLiteral(''),
              ),
            ],
          ),
        )
        report.booleanCount += 1
        return
      }

      if (URL_PROP_NAMES.has(attrName) && t.isStringLiteral(value) && isOnUrlElement(p)) {
        const oe = p.parent
        const elName = t.isJSXIdentifier(oe.name) ? oe.name.name : 'el'
        const jsxPath = `${getJsxPath(p.parentPath.parentPath, elName)}.${attrName}`
        const nest = nestingPascalNameFromAnchor(p)
        const fieldName = deriveFieldName({
          jsxPath,
          kind: 'String',
          strategy: namingStrategy,
          fieldMap,
          componentName,
          nestingPascalName: nest,
        })
        recordField(fieldName, 'String', value.value, p)
        p.node.value = t.jsxExpressionContainer(t.identifier(fieldName))
        report.stringCount += 1
      }
    },
  })

  saveFieldMap(fieldMapPath, fieldMap)
  logger(report)
  return { report, fieldRecords }
}

function replaceTemplatePlaceholders(code) {
  const re = new RegExp(`["'\\\`]${TEMPLATE_EXPR_MARKER}([\\s\\S]+?)${TEMPLATE_EXPR_MARKER}["'\\\`]`, 'g')
  const escapeDoubleQuotedString = (s) =>
    String(s)
      .replace(/\\/g, '\\\\')
      .replace(/"/g, '\\"')
      .replace(/\n/g, '\\n')
      .replace(/\r/g, '\\r')
      .replace(/\t/g, '\\t')

  const richTextDefaultToObjectLiteral = (v) => {
    const obj =
      v && typeof v === 'object'
        ? v
        : {
            text: v == null ? '' : String(v),
          }
    const text = obj.text == null ? '' : String(obj.text)
    const hasExtras =
      (obj.color != null && obj.color !== '') ||
      (obj.fontWeight != null && obj.fontWeight !== '') ||
      (obj.fontStyle != null && obj.fontStyle !== '') ||
      (obj.textDecoration != null && obj.textDecoration !== '')
    const parts = []
    parts.push(`text: "${escapeDoubleQuotedString(text)}"`)
    if (obj.color != null && obj.color !== '') {
      parts.push(`color: "${escapeDoubleQuotedString(obj.color)}"`)
    }
    if (obj.fontWeight != null && obj.fontWeight !== '') {
      if (typeof obj.fontWeight === 'number') parts.push(`fontWeight: ${obj.fontWeight}`)
      else parts.push(`fontWeight: "${escapeDoubleQuotedString(obj.fontWeight)}"`)
    }
    if (obj.fontStyle != null && obj.fontStyle !== '') {
      parts.push(`fontStyle: "${escapeDoubleQuotedString(obj.fontStyle)}"`)
    }
    if (obj.textDecoration != null && obj.textDecoration !== '') {
      parts.push(`textDecoration: "${escapeDoubleQuotedString(obj.textDecoration)}"`)
    }
    // Always emit RichText as an object so downstream code can rely on `.text`.
    // Only include extras when present.
    if (!hasExtras) {
      return `{ ${parts[0]} }`
    }
    return `{ ${parts.join(', ')} }`
  }

  const imageDefaultToObjectLiteral = (v) => {
    const { url, alt } = normalizeImageDefault(v)
    return `{ url: "${escapeDoubleQuotedString(url)}", alt: "${escapeDoubleQuotedString(alt)}" }`
  }

  return code.replace(re, (_m, payload) => {
    try {
      // `payload` is JSON emitted by `templatePlaceholder()` but then embedded inside a JS string literal.
      // Babel's generator may represent some bytes as `\xNN`, which is valid JS but NOT valid JSON.
      // So we:
      // - normalize `\xNN` → `\u00NN`
      // - unescape the JSON text that was string-escaped for JS (`\"`, `\\`)
      // - JSON.parse the resulting JSON text
      const jsonText = payload
        .replace(/\\x([0-9A-Fa-f]{2})/g, '\\u00$1')
        .replace(/\\"/g, '"')
        .replace(/\\\\/g, '\\')
      const { fieldName, kind, defaultValue } = JSON.parse(jsonText)
      if (kind === 'RichText') {
        return `$(${fieldName}:${kind}=${richTextDefaultToObjectLiteral(defaultValue)})`
      }
      if (kind === 'Image') {
        return `$(${fieldName}:${kind}=${imageDefaultToObjectLiteral(defaultValue)})`
      }
      if (defaultValue === undefined || defaultValue === null || defaultValue === '') {
        return `$(${fieldName}:${kind})`
      }
      if (kind === 'Boolean') {
        const b = defaultValue === true || defaultValue === 'true'
        return `$(${fieldName}:${kind}=${b ? 'true' : 'false'})`
      }
      if (kind === 'Number' && typeof defaultValue === 'number') {
        return `$(${fieldName}:${kind}=${defaultValue})`
      }
      const safe = String(defaultValue).replace(/'/g, "\\'")
      return `$(${fieldName}:${kind}='${safe}')`
    } catch {
      return _m
    }
  })
}

module.exports = {
  tagTemplateFields,
  replaceTemplatePlaceholders,
  templatePlaceholder,
  literalFieldInitializer,
  deriveFieldName,
  deriveArrayEntryFieldName,
  loadFieldMap,
  saveFieldMap,
  TEMPLATE_EXPR_MARKER,
}
