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

const {
  cssDeclarationsToStyleObject,
  mergeStyleIntoElement,
  appendClassName,
  hashString,
} = require('./utils')

function loadPostcss() {
  try {
    return {
      postcss: require('postcss'),
      selectorParser: require('postcss-selector-parser'),
    }
  } catch {
    return null
  }
}

function isCssModuleImport(specifier) {
  return specifier.endsWith('.module.css') || specifier.endsWith('.module.scss')
}

function isPlainCssImport(specifier) {
  return /\.(css|scss|sass|less)$/.test(specifier) && !isCssModuleImport(specifier)
}

function resolveCssPath(baseFile, specifier) {
  if (!specifier.startsWith('.') && !specifier.startsWith('/')) return null
  const resolved = path.resolve(path.dirname(baseFile), specifier)
  return fs.existsSync(resolved) ? resolved : null
}

/**
 * Parse a CSS file and return a map from className -> { decls, pseudoBlocks }.
 * Simple selectors only (e.g. ".foo"). Anything more complex (descendant,
 * combinator, pseudo-elements) is kept for the <style> fallback.
 */
function parseCssModule(cssText, postcssPkg) {
  const { postcss, selectorParser } = postcssPkg
  const root = postcss.parse(cssText)
  const simple = new Map()
  const complex = [] // keep original rules for style-tag fallback

  root.walkRules((rule) => {
    let simpleMatch = null
    try {
      selectorParser((selectors) => {
        if (selectors.nodes.length !== 1) return
        const sel = selectors.nodes[0]
        if (sel.nodes.length === 1 && sel.nodes[0].type === 'class') {
          simpleMatch = sel.nodes[0].value
        }
      }).processSync(rule.selector)
    } catch {
      // ignore selector parse errors
    }

    const hasPseudo = /:[:a-zA-Z-]/.test(rule.selector)
    if (simpleMatch && !hasPseudo && !rule.parent.name) {
      const decls = []
      rule.walkDecls((d) => {
        decls.push({ prop: d.prop, value: d.value, important: d.important })
      })
      if (!simple.has(simpleMatch)) simple.set(simpleMatch, [])
      simple.get(simpleMatch).push(...decls)
    } else {
      complex.push(rule.toString())
    }
  })

  root.walkAtRules((atRule) => {
    if (atRule.name === 'media' || atRule.name === 'keyframes') {
      complex.push(atRule.toString())
    }
  })

  return { simple, complex }
}

/**
 * Resolve CSS imports in-place inside an AST. Returns a report.
 *
 * @param {object} args
 * @param {object} args.ast Babel File node
 * @param {Function} args.traverse @babel/traverse default export
 * @param {string} args.filePath source file path
 * @param {"hybrid"|"inline"|"style-tag"} args.cssMode
 * @param {Function} [args.logger]
 */
function resolveCssImports({ ast, traverse, filePath, cssMode, logger = () => {} }) {
  const pcss = loadPostcss()
  const report = {
    resolved: [],
    inlinedRules: 0,
    styleTagSize: 0,
    skipped: [],
    warnings: [],
  }

  if (!pcss) {
    report.warnings.push('postcss not installed; CSS imports left untouched')
    return report
  }

  const cssImports = []

  traverse(ast, {
    ImportDeclaration(p) {
      const spec = p.node.source.value
      if (!isCssModuleImport(spec) && !isPlainCssImport(spec)) return
      const resolved = resolveCssPath(filePath, spec)
      if (!resolved) {
        report.warnings.push(`Could not resolve CSS import: ${spec}`)
        return
      }
      let localBinding = null
      const def = p.node.specifiers.find((s) => t.isImportDefaultSpecifier(s))
      const ns = p.node.specifiers.find((s) => t.isImportNamespaceSpecifier(s))
      if (def) localBinding = def.local.name
      else if (ns) localBinding = ns.local.name

      cssImports.push({
        path: p,
        specifier: spec,
        resolvedPath: resolved,
        isModule: isCssModuleImport(spec),
        localBinding,
      })
    },
  })

  if (!cssImports.length) return report

  const aggregatedStyles = []
  const classDeclMap = new Map() // className -> { decls[] }
  const globalRules = []

  for (const imp of cssImports) {
    let text
    try {
      text = fs.readFileSync(imp.resolvedPath, 'utf8')
    } catch (e) {
      report.warnings.push(`Failed to read ${imp.resolvedPath}: ${e.message}`)
      continue
    }
    let parsed
    try {
      parsed = parseCssModule(text, pcss)
    } catch (e) {
      report.warnings.push(`Failed to parse ${imp.resolvedPath}: ${e.message}`)
      continue
    }
    report.resolved.push(imp.specifier)

    if (imp.isModule) {
      for (const [cls, decls] of parsed.simple.entries()) {
        const existing = classDeclMap.get(cls) || []
        classDeclMap.set(cls, existing.concat(decls))
      }
      if (parsed.complex.length) {
        // Module styles with unsupported selectors: we keep them in aggregated
        // CSS under a stable namespace class. Consumers usually write simple
        // module CSS so this is rare.
        aggregatedStyles.push(...parsed.complex)
      }
    } else {
      for (const rule of parsed.complex) globalRules.push(rule)
      for (const [cls, decls] of parsed.simple.entries()) {
        const ruleText = `.${cls} { ${decls
          .map((d) => `${d.prop}: ${d.value}${d.important ? ' !important' : ''}`)
          .join('; ')} }`
        globalRules.push(ruleText)
      }
    }

    imp.path.remove()
  }

  // Rewrite className references for CSS Modules.
  if ((cssMode === 'hybrid' || cssMode === 'inline') && classDeclMap.size) {
    const moduleBindings = cssImports.filter((c) => c.isModule && c.localBinding)

    traverse(ast, {
      JSXAttribute(p) {
        if (!t.isJSXIdentifier(p.node.name, { name: 'className' })) return
        const valueNode = p.node.value
        if (!t.isJSXExpressionContainer(valueNode)) return
        const expr = valueNode.expression
        // Pattern: styles.foo  or  styles['foo']
        if (t.isMemberExpression(expr)) {
          const objectName = t.isIdentifier(expr.object) ? expr.object.name : null
          if (!moduleBindings.some((b) => b.localBinding === objectName)) return
          const key = !expr.computed && t.isIdentifier(expr.property)
            ? expr.property.name
            : expr.computed && t.isStringLiteral(expr.property)
            ? expr.property.value
            : null
          if (!key) return
          const decls = classDeclMap.get(key)
          if (!decls) return

          const openingElement = p.parent
          if (!t.isJSXOpeningElement(openingElement)) return

          const { objectExpression, skipped } = cssDeclarationsToStyleObject(decls)
          mergeStyleIntoElement(openingElement, objectExpression)
          // Remove the className prop entirely (CSS Module class no longer needed).
          const idx = openingElement.attributes.indexOf(p.node)
          if (idx >= 0) openingElement.attributes.splice(idx, 1)
          report.inlinedRules += 1
          if (skipped.length) report.skipped.push(...skipped.map((s) => ({ ...s, from: key })))
        }
      },
    })
  }

  // If hybrid or style-tag, emit a <style> block with global CSS + anything unresolved.
  const remainingCss = []
  if (aggregatedStyles.length) remainingCss.push(...aggregatedStyles)
  if (globalRules.length) remainingCss.push(...globalRules)

  if (remainingCss.length && cssMode !== 'inline') {
    const styleText = remainingCss.join('\n')
    report.styleTagSize = styleText.length

    // Insert <style>{`...`}</style> as the first child of the root JSX of the
    // default exported component. Fallback: prepend to first JSXElement in file.
    injectStyleTag({ ast, traverse, css: styleText })
  } else if (remainingCss.length && cssMode === 'inline') {
    report.warnings.push(
      `${remainingCss.length} CSS rule(s) cannot be inlined (selectors/pseudo/media); rerun with --css-mode hybrid or style-tag`
    )
  }

  // Verify no dangling references to CSS Module bindings; if so, drop the binding gracefully
  if (classDeclMap.size && (cssMode === 'hybrid' || cssMode === 'inline')) {
    // Any remaining `styles.foo` references we didn't inline become raw className strings to
    // keep code parseable — selectors without matching rules get a stable hashed class.
    traverse(ast, {
      MemberExpression(p) {
        const { object, property } = p.node
        if (!t.isIdentifier(object)) return
        const binding = cssImports.find(
          (c) => c.isModule && c.localBinding === object.name
        )
        if (!binding) return
        const key = t.isIdentifier(property) ? property.name : t.isStringLiteral(property) ? property.value : null
        if (!key) return
        p.replaceWith(t.stringLiteral(`cms-${hashString(binding.specifier + ':' + key)}`))
      },
    })
  }

  logger(report)
  return report
}

function injectStyleTag({ ast, traverse, css }) {
  let inserted = false
  traverse(ast, {
    JSXElement(p) {
      if (inserted) return
      // Prefer the root element of a return statement.
      const parent = p.findParent(
        (pp) => pp.isReturnStatement() || pp.isArrowFunctionExpression()
      )
      if (!parent) return
      const styleEl = t.jsxElement(
        t.jsxOpeningElement(t.jsxIdentifier('style'), [], false),
        t.jsxClosingElement(t.jsxIdentifier('style')),
        [t.jsxExpressionContainer(t.templateLiteral([t.templateElement({ raw: css, cooked: css }, true)], []))],
        false
      )
      p.node.children.unshift(styleEl)
      inserted = true
    },
  })
}

module.exports = {
  resolveCssImports,
  appendClassName,
}
