/*
  Extract all user-facing copy from the codebase into an Excel file.
  Scans TS/TSX under src/, groups by Next.js App Router route, and writes docs/copy-audit.xlsx.
*/

import path from 'node:path'
import fs from 'node:fs'
import { globby } from 'globby'
import {
  Project,
  SyntaxKind,
  Node,
  JsxAttribute,
  StringLiteral,
  NoSubstitutionTemplateLiteral,
} from 'ts-morph'
import * as XLSX from 'xlsx'

type CopyKind =
  | 'text'
  | 'placeholder'
  | 'title'
  | 'alt'
  | 'aria'
  | 'label'
  | 'toast'
  | 'dialog'
  | 'error'
  | 'validation'

interface CopyItem {
  route: string
  file: string
  componentOrFn: string
  kind: CopyKind
  keyOrLocator: string
  text: string
  line: number
  notes?: string
}

const WORKDIR = process.cwd()
const SRC_DIR = path.join(WORKDIR, 'src')
const APP_DIR = path.join(SRC_DIR, 'app')

function ensureExcelDir() {
  const docsDir = path.join(WORKDIR, 'docs')
  if (!fs.existsSync(docsDir)) fs.mkdirSync(docsDir, { recursive: true })
}

function isMeaningfulText(value: string | undefined | null): value is string {
  if (!value) return false
  const trimmed = value.replace(/\s+/g, ' ').trim()
  if (!trimmed) return false
  // Filter obvious code-like tokens
  if (/^[A-Za-z0-9_.$-]+$/.test(trimmed) && trimmed.length > 24) return false
  return true
}

function getRouteForFile(absFilePath: string): string {
  if (!absFilePath.startsWith(APP_DIR)) return 'shared'
  let dir = path.dirname(absFilePath)
  // Walk up to find nearest folder that contains a page.tsx (or page.ts)
  while (dir.startsWith(APP_DIR)) {
    const pageTsx = path.join(dir, 'page.tsx')
    const pageTs = path.join(dir, 'page.ts')
    if (fs.existsSync(pageTsx) || fs.existsSync(pageTs)) {
      const rel = path.relative(APP_DIR, dir)
      return rel || '/'
    }
    const parent = path.dirname(dir)
    if (parent === dir) break
    dir = parent
  }
  // Fallback: route is the first app subfolder segment
  const relToApp = path.relative(APP_DIR, absFilePath)
  const parts = relToApp.split(path.sep)
  return parts.length > 0 ? parts[0] : 'shared'
}

function getComponentOrFnName(node: Node): string {
  const fn = node.getFirstAncestorByKind(SyntaxKind.FunctionDeclaration)
  if (fn?.getName()) return fn.getName()!
  const varDecl = node.getFirstAncestorByKind(SyntaxKind.VariableDeclaration)
  if (varDecl?.getName()) return varDecl.getName()
  const cls = node.getFirstAncestorByKind(SyntaxKind.ClassDeclaration)
  if (cls?.getName()) return cls.getName()!
  const sf = node.getSourceFile()
  return path.basename(sf.getFilePath())
}

function getNodeLine(node: Node): number {
  const pos = node.getStartLineNumber()
  return pos ?? 1
}

function getAttrName(attr: JsxAttribute): string {
  return attr.getNameNode().getText()
}

function getStringFromInitializer(attr: JsxAttribute): string | undefined {
  const init = attr.getInitializer()
  if (!init) return undefined
  if (Node.isStringLiteral(init)) return init.getLiteralText()
  if (Node.isNoSubstitutionTemplateLiteral(init)) return init.getLiteralText()
  if (Node.isJsxExpression(init)) {
    const expr = init.getExpression()
    if (!expr) return undefined
    if (Node.isStringLiteral(expr)) return expr.getLiteralText()
    if (Node.isNoSubstitutionTemplateLiteral(expr)) return expr.getLiteralText()
  }
  return undefined
}

function pushItem(items: CopyItem[], item: CopyItem) {
  if (!isMeaningfulText(item.text)) return
  items.push(item)
}

async function collectFiles(): Promise<string[]> {
  const patterns = ['src/**/*.{ts,tsx}']
  const ignore = [
    '**/node_modules/**',
    '**/.next/**',
    '**/__tests__/**',
    '**/mocks/**',
    '**/mock/**',
    '**/*.d.ts',
  ]
  return await globby(patterns, { gitignore: true, ignore })
}

function extractFromSourceFile(abs: string, items: CopyItem[], project: Project) {
  const sf = project.addSourceFileAtPath(abs)
  // JSX text nodes
  sf.forEachDescendant((node) => {
    if (Node.isJsxElement(node)) {
      const opening = node.getOpeningElement()
      const componentOrFn = getComponentOrFnName(node)
      const route = getRouteForFile(abs)
      // 递归提取所有 JsxText 与 {'...'} 字面量
      const tagName = opening.getTagNameNode().getText()
      const textNodes = node.getDescendantsOfKind(SyntaxKind.JsxText)
      textNodes.forEach((t) => {
        const text = t.getText()
        const cleaned = text.replace(/\s+/g, ' ').trim()
        if (isMeaningfulText(cleaned)) {
          pushItem(items, {
            route,
            file: path.relative(WORKDIR, abs),
            componentOrFn,
            kind: 'text',
            keyOrLocator: tagName,
            text: cleaned,
            line: getNodeLine(t),
          })
        }
      })
      const exprs = node.getDescendantsOfKind(SyntaxKind.JsxExpression)
      exprs.forEach((expr) => {
        const inner = expr.getExpression()
        if (inner && (Node.isStringLiteral(inner) || Node.isNoSubstitutionTemplateLiteral(inner))) {
          const cleaned = inner.getLiteralText().replace(/\s+/g, ' ').trim()
          if (isMeaningfulText(cleaned)) {
            pushItem(items, {
              route,
              file: path.relative(WORKDIR, abs),
              componentOrFn,
              kind: 'text',
              keyOrLocator: tagName,
              text: cleaned,
              line: getNodeLine(expr),
            })
          }
        }
      })
    }

    // JSX attributes
    if (Node.isJsxOpeningElement(node) || Node.isJsxSelfClosingElement(node)) {
      const route = getRouteForFile(abs)
      const componentOrFn = getComponentOrFnName(node)
      const tag = Node.isJsxOpeningElement(node)
        ? node.getTagNameNode().getText()
        : node.getTagNameNode().getText()
      const attrs = node.getAttributes().filter(Node.isJsxAttribute)
      attrs.forEach((attr) => {
        const name = getAttrName(attr)
        const lower = name.toLowerCase()
        const value = getStringFromInitializer(attr)
        if (!value) return
        let kind: CopyKind | null = null
        if (lower === 'placeholder') kind = 'placeholder'
        else if (lower === 'title') kind = 'title'
        else if (lower === 'alt') kind = 'alt'
        else if (lower.startsWith('aria-')) kind = 'aria'
        else if (lower === 'label') kind = 'label'
        if (kind) {
          pushItem(items, {
            route,
            file: path.relative(WORKDIR, abs),
            componentOrFn,
            kind,
            keyOrLocator: `${tag}.${name}`,
            text: value,
            line: getNodeLine(attr),
          })
        }
      })
    }

    // Interaction messages: toast.*, alert, confirm, message.*
    if (Node.isCallExpression(node)) {
      const route = getRouteForFile(abs)
      const componentOrFn = getComponentOrFnName(node)
      const expr = node.getExpression()
      let kind: CopyKind | null = null
      let keyOrLocator = ''
      if (Node.isPropertyAccessExpression(expr)) {
        const left = expr.getExpression().getText()
        const name = expr.getName()
        if (left === 'toast' || left === 'message') {
          kind = 'toast'
          keyOrLocator = `${left}.${name}`
        }
        if (left.toLowerCase().includes('dialog')) {
          kind = 'dialog'
          keyOrLocator = `${left}.${name}`
        }
      } else if (Node.isIdentifier(expr)) {
        const id = expr.getText()
        if (id === 'alert' || id === 'confirm') {
          kind = 'dialog'
          keyOrLocator = id
        }
      }
      if (kind) {
        const arg0 = node.getArguments()[0]
        if (arg0 && (Node.isStringLiteral(arg0) || Node.isNoSubstitutionTemplateLiteral(arg0))) {
          const text = (arg0 as StringLiteral | NoSubstitutionTemplateLiteral).getLiteralText()
          pushItem(items, {
            route,
            file: path.relative(WORKDIR, abs),
            componentOrFn,
            kind,
            keyOrLocator,
            text,
            line: getNodeLine(node),
          })
        }
      }

      // form.setError("field", { message: "..." })
      if (Node.isPropertyAccessExpression(expr) && expr.getName() === 'setError') {
        const args = node.getArguments()
        if (args.length >= 2) {
          const second = args[1]
          if (Node.isObjectLiteralExpression(second)) {
            const msgProp = second.getProperty('message')
            if (msgProp && Node.isPropertyAssignment(msgProp)) {
              const init = msgProp.getInitializer()
              if (
                init &&
                (Node.isStringLiteral(init) || Node.isNoSubstitutionTemplateLiteral(init))
              ) {
                const text = init.getLiteralText()
                pushItem(items, {
                  route,
                  file: path.relative(WORKDIR, abs),
                  componentOrFn,
                  kind: 'error',
                  keyOrLocator: 'form.setError',
                  text,
                  line: getNodeLine(msgProp),
                })
              }
            }
          }
        }
      }

      // Generic validation: any object literal { message: "..." } inside chained calls
      const args = node.getArguments()
      for (const a of args) {
        if (Node.isObjectLiteralExpression(a)) {
          const prop = a.getProperty('message')
          if (prop && Node.isPropertyAssignment(prop)) {
            const init = prop.getInitializer()
            if (
              init &&
              (Node.isStringLiteral(init) || Node.isNoSubstitutionTemplateLiteral(init))
            ) {
              const text = init.getLiteralText()
              pushItem(items, {
                route,
                file: path.relative(WORKDIR, abs),
                componentOrFn,
                kind: 'validation',
                keyOrLocator: 'message',
                text,
                line: getNodeLine(prop),
              })
            }
          }
        }
      }
    }
  })
}

function aggregate(items: CopyItem[]): CopyItem[] {
  // Deduplicate by route+kind+text+keyOrLocator to keep first occurrence, count separately
  const map = new Map<string, { item: CopyItem; count: number }>()
  for (const it of items) {
    const key = `${it.route}__${it.kind}__${it.keyOrLocator}__${it.text}`
    if (!map.has(key)) {
      map.set(key, { item: it, count: 1 })
    } else {
      map.get(key)!.count += 1
    }
  }
  const result: CopyItem[] = []
  for (const { item, count } of map.values()) {
    ;(item as any).count = count
    result.push(item)
  }
  return result
}

function toWorkbook(items: CopyItem[]): XLSX.WorkBook {
  const rows = items.map((it) => ({
    route: it.route,
    file: it.file,
    componentOrFn: it.componentOrFn,
    kind: it.kind,
    keyOrLocator: it.keyOrLocator,
    text: it.text,
    line: it.line,
    count: (it as any).count ?? 1,
    notes: it.notes ?? '',
  }))
  const ws = XLSX.utils.json_to_sheet(rows, { skipHeader: false })
  const wb = XLSX.utils.book_new()
  XLSX.utils.book_append_sheet(wb, ws, 'copy')
  return wb
}

async function main() {
  ensureExcelDir()
  const files = await collectFiles()
  const project = new Project({
    tsConfigFilePath: path.join(WORKDIR, 'tsconfig.json'),
    skipAddingFilesFromTsConfig: true,
  })
  const items: CopyItem[] = []
  for (const rel of files) {
    const abs = path.join(WORKDIR, rel)
    try {
      extractFromSourceFile(abs, items, project)
    } catch (e) {
      // swallow parse errors but continue
    }
  }
  const aggregated = aggregate(items)
  const wb = toWorkbook(aggregated)
  const out = path.join(WORKDIR, 'docs', 'copy-audit.xlsx')
  XLSX.writeFile(wb, out)
  // eslint-disable-next-line no-console
  console.log(`Wrote ${aggregated.length} rows to ${out}`)
}

main().catch((err) => {
  // eslint-disable-next-line no-console
  console.error(err)
  process.exitCode = 1
})
