import { randomUUID } from 'crypto'
import { access, copyFile, mkdir, readFile, unlink, writeFile } from 'fs/promises'
import { basename, isAbsolute, join } from 'path'
import 'reflect-metadata'

import { app, BrowserWindow, dialog, shell } from 'electron'
import JSZip from 'jszip'
import { PPTXTemplater } from 'node-pptx-templater'
import type { Repository } from 'typeorm'

import { ClassEntitySchema, type ClassEntity } from '../entities/ClassEntity'
import { ReportEntitySchema, type ReportEntity } from '../entities/ReportEntity'
import {
  StudentProfileEntitySchema,
  type StudentProfileEntity
} from '../entities/StudentProfileEntity'
import { TemplateEntitySchema, type TemplateEntity } from '../entities/TemplateEntity'
import type {
  DownloadClassReportsInput,
  GenerateReportsInput,
  ReportFormat,
  ReportGenerationProgress,
  ReportItem
} from '../types/report'
import { getAppDataSource } from './databaseService'
import { readImageBuffer } from './imageStorageService'
import { getLogger } from './loggerService'

const REPORT_FOLDER_NAME = 'reports'
const TEMPLATE_FOLDER_NAME = 'templates'
const logger = getLogger('report')

type ReportStudentData = {
  id: string
  classId: string
  className: string
  name: string
  englishName: string
  sex: string
  gender: string
  birthday: string
  zodiac: string
  friends: string
  friend: string
  hobbies: string
  hobby: string
  favoriteGames: string
  game: string
  favoriteFoods: string
  food: string
  meImage: string
  workImage1: string
  workImage2: string
  traits: string
  comment: string
  comments: string
  teacherName: string
}

function getReportStoragePath(): string {
  return join(app.getPath('userData'), REPORT_FOLDER_NAME)
}

function resolveTemplateFilePath(filePath: string): string {
  if (!isAbsolute(filePath)) {
    return join(app.getPath('userData'), filePath)
  }

  return join(app.getPath('userData'), TEMPLATE_FOLDER_NAME, basename(filePath))
}

async function getReportRepository(): Promise<Repository<ReportEntity>> {
  const source = await getAppDataSource()
  return source.getRepository<ReportEntity>(ReportEntitySchema)
}

function normalizeReportFormat(format: string): ReportFormat {
  if (format === 'word' || format === 'pdf' || format === 'excel') {
    return format
  }

  return 'ppt'
}

function mapReportEntity(entity: ReportEntity): ReportItem {
  return {
    id: entity.id,
    title: entity.title,
    classId: entity.classId,
    className: entity.className,
    studentId: entity.studentId,
    studentName: entity.studentName,
    format: normalizeReportFormat(entity.format),
    originalFileName: entity.originalFileName,
    filePath: entity.filePath,
    fileExtension: entity.fileExtension,
    templateId: entity.templateId,
    createdAt: entity.createdAt,
    updatedAt: entity.updatedAt
  }
}

function sanitizeFileName(value: string): string {
  return (
    [...value]
      .filter((character) => !'<>:"/\\|?*'.includes(character) && character.charCodeAt(0) >= 32)
      .join('')
      .trim() || '未命名'
  )
}

function getReportDownloadFileName(report: ReportEntity | ReportItem): string {
  return `${sanitizeFileName(`${report.className}${report.studentName}`)}${report.fileExtension}`
}

function sendReportProgress(progress: ReportGenerationProgress): void {
  for (const window of BrowserWindow.getAllWindows()) {
    window.webContents.send('reports:generation-progress', progress)
  }
}

function getFormatFromExtension(extension: string): ReportFormat {
  if (['.doc', '.docx'].includes(extension)) {
    return 'word'
  }

  if (['.xls', '.xlsx'].includes(extension)) {
    return 'excel'
  }

  if (extension === '.pdf') {
    return 'pdf'
  }

  return 'ppt'
}

function escapeXml(value: string): string {
  return value
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&apos;')
}

function decodeXml(value: string): string {
  return value
    .replace(/&lt;/g, '<')
    .replace(/&gt;/g, '>')
    .replace(/&quot;/g, '"')
    .replace(/&apos;/g, "'")
    .replace(/&amp;/g, '&')
}

function escapeRegExp(value: string): string {
  return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}

function getStudentData(student: StudentProfileEntity, classItem: ClassEntity): ReportStudentData {
  const friends = parseJsonList(student.friends).join('、') || ' '
  const hobbies = parseJsonList(student.hobbies).join('、') || ' '
  const favoriteGames = parseJsonList(student.favoriteGames).join('、') || ' '
  const favoriteFoods = parseJsonList(student.favoriteFoods).join('、') || ' '
  const teacherName =
    parseTeacherNames(classItem.teacherNames || classItem.teacherName || '').join(' ') || ' '

  return {
    id: student.id,
    classId: student.classId,
    className: student.className,
    name: student.name || '未填写',
    englishName: student.englishName || ' ',
    sex: student.gender || '男',
    gender: student.gender || '男',
    birthday: student.birthday || ' ',
    zodiac: student.zodiac || ' ',
    friends,
    friend: friends,
    hobbies,
    hobby: hobbies,
    favoriteGames,
    game: favoriteGames,
    favoriteFoods,
    food: favoriteFoods,
    meImage: student.meImage || ' ',
    workImage1: student.workImage1 || ' ',
    workImage2: student.workImage2 || ' ',
    traits: student.traits || ' ',
    comment: student.comment || '暂无评语',
    comments: student.comment || '暂无评语',
    teacherName
  }
}

function parseTeacherNames(value: string): string[] {
  try {
    const parsed = JSON.parse(value)

    if (Array.isArray(parsed)) {
      return parsed
        .filter((item): item is string => typeof item === 'string')
        .map((item) => item.trim())
        .filter(Boolean)
    }
  } catch {
    // Older data may contain a single teacher name instead of a JSON array.
  }

  return value.trim() ? [value.trim()] : []
}

function buildTextReplacements(studentData: ReportStudentData, classItem: ClassEntity): Record<string, string> {
  return {
    name: studentData.name,
    class: classItem.name,
    className: classItem.name,
    comments: studentData.comments,
    comment: studentData.comment,
    teacherName: studentData.teacherName,
    teacher_name: studentData.teacherName,
    englishName: studentData.englishName,
    english_name: studentData.englishName,
    sex: studentData.sex,
    gender: studentData.gender,
    birthday: studentData.birthday,
    zodiac: studentData.zodiac,
    friends: studentData.friends,
    friend: studentData.friend,
    hobbies: studentData.hobbies,
    hobby: studentData.hobby,
    favoriteGames: studentData.favoriteGames,
    game: studentData.game,
    favoriteFoods: studentData.favoriteFoods,
    food: studentData.food,
    me_image: studentData.meImage,
    work_image_1: studentData.workImage1,
    work_image_2: studentData.workImage2
  }
}

function buildImageReplacements(): Record<string, string> {
  return {
    class_image: 'familyPhoto',
    classImage: 'familyPhoto',
    familyPhoto: 'familyPhoto',
    me_image: 'meImage',
    meImage: 'meImage',
    work_image_1: 'workImage1',
    workImage1: 'workImage1',
    work_image_2: 'workImage2',
    workImage2: 'workImage2'
  }
}

function parseJsonList(value: string): string[] {
  try {
    const parsed = JSON.parse(value)
    return Array.isArray(parsed) ? parsed.filter((item) => typeof item === 'string') : []
  } catch {
    return []
  }
}

function normalizePptObjectName(value?: string | number | null): string {
  return String(value ?? '')
    .trim()
    .replace(/^field[:_-]/i, '')
    .replace(/[_\s-]+(.)?/g, (_, next: string | undefined) => (next ? next.toUpperCase() : ''))
    .toLowerCase()
}

type PptImageInfo = {
  id?: string | number
  name?: string
  relationshipId?: string
  targetPath?: string
  position?: PptPosition | null
}

type PptShapeInfo = {
  id?: string | number
  name?: string
  position?: PptPosition | null
}

type PptPosition = {
  x: number
  y: number
  cx: number
  cy: number
}

type PptImageReplacementTarget = {
  slideNumber: number
  imageIdentifier: string
  imageKey: string
  overlapArea: number
  centerDistance: number
}

type PptImageReplacementPlan = {
  placeholderName: string
  fieldKey: string
  imageValue?: string | null
  target: PptImageReplacementTarget
}

function getSlideImages(ppt: PPTXTemplater, slideNumber: number): PptImageInfo[] {
  // node-pptx-templater 的 useSlide(n) 会把后续操作限定到第 n 页。
  // getImages() 返回这一页所有真正的图片对象（PowerPoint XML 里的 p:pic），
  // 其中 id/name 用来调用 replaceImage，relationshipId/targetPath 用来识别图片资源。
  const slideScopedPpt = ppt.useSlide(slideNumber) as unknown as {
    getImages?: () => PptImageInfo[]
  }

  return slideScopedPpt.getImages?.() ?? []
}

function getSlideShapes(ppt: PPTXTemplater, slideNumber: number): PptShapeInfo[] {
  // getShapes() 返回普通形状对象（PowerPoint XML 里的 p:sp）。
  // 很多模板会把占位符命名在“矩形/占位框”上，而真正的图片对象名称仍然是“图片 90”。
  // 所以后面会用这些形状的位置去匹配与它重叠的图片。
  const slideScopedPpt = ppt.useSlide(slideNumber) as unknown as {
    getShapes?: () => PptShapeInfo[]
  }

  return slideScopedPpt.getShapes?.() ?? []
}

function getOverlapArea(first?: PptPosition | null, second?: PptPosition | null): number {
  if (!first || !second) {
    return 0
  }

  const left = Math.max(first.x, second.x)
  const top = Math.max(first.y, second.y)
  const right = Math.min(first.x + first.cx, second.x + second.cx)
  const bottom = Math.min(first.y + first.cy, second.y + second.cy)
  const width = Math.max(right - left, 0)
  const height = Math.max(bottom - top, 0)

  return width * height
}

function getCenterDistance(first?: PptPosition | null, second?: PptPosition | null): number {
  if (!first || !second) {
    return Number.MAX_SAFE_INTEGER
  }

  const firstCenterX = first.x + first.cx / 2
  const firstCenterY = first.y + first.cy / 2
  const secondCenterX = second.x + second.cx / 2
  const secondCenterY = second.y + second.cy / 2

  return Math.hypot(firstCenterX - secondCenterX, firstCenterY - secondCenterY)
}

function getImageReplacementKey(slideNumber: number, image: PptImageInfo): string {
  return [
    slideNumber,
    image.id ?? '',
    image.name ?? '',
    image.relationshipId ?? '',
    image.targetPath ?? ''
  ].join('|')
}

function getImageIdentifier(image: PptImageInfo, fallback: string): string {
  return String(image.id ?? image.name ?? image.relationshipId ?? fallback)
}

function describeImageValue(value?: string | null): Record<string, unknown> {
  if (!value) {
    return { exists: false }
  }

  return {
    exists: true,
    source: value.startsWith('data:image/') ? 'data-url' : 'file-path',
    length: value.length,
    preview: value.startsWith('data:image/') ? value.slice(0, 40) : value
  }
}

function describePosition(position?: PptPosition | null): Record<string, number> | null {
  if (!position) {
    return null
  }

  return {
    x: position.x,
    y: position.y,
    cx: position.cx,
    cy: position.cy
  }
}

function logSlideObjects(ppt: PPTXTemplater, traceId: string): void {
  for (let slideNumber = 1; slideNumber <= ppt.slideCount; slideNumber += 1) {
    const images = getSlideImages(ppt, slideNumber).map((image) => ({
      id: image.id,
      name: image.name,
      relationshipId: image.relationshipId,
      targetPath: image.targetPath,
      position: describePosition(image.position)
    }))
    const shapes = getSlideShapes(ppt, slideNumber).map((shape) => ({
      id: shape.id,
      name: shape.name,
      position: describePosition(shape.position)
    }))

    logger.debug(`[${traceId}] PPTX 第 ${slideNumber} 页对象扫描`, {
      imageCount: images.length,
      images,
      shapeCount: shapes.length,
      shapes
    })
  }
}

function getSlidePaths(zip: JSZip): string[] {
  return Object.keys(zip.files)
    .filter((path) => /^ppt\/slides\/slide\d+\.xml$/.test(path))
    .sort((first, second) => {
      const firstNumber = Number(first.match(/slide(\d+)\.xml$/)?.[1] ?? 0)
      const secondNumber = Number(second.match(/slide(\d+)\.xml$/)?.[1] ?? 0)

      return firstNumber - secondNumber
    })
}

function hasShapeName(shapeXml: string, shapeName: string): boolean {
  const normalizedShapeName = normalizePptObjectName(shapeName)
  const shapeProperties = shapeXml.match(/<p:cNvPr[^>]*>/)?.[0] ?? ''
  const candidateNames = ['name', 'descr', 'title']
    .map((attributeName) => getXmlAttributeValue(shapeProperties, attributeName))
    .filter((value): value is string => Boolean(value))

  return candidateNames.some(
    (candidateName) => normalizePptObjectName(decodeXml(candidateName)) === normalizedShapeName
  )
}

function getXmlAttributeValue(xml: string, attributeName: string): string | null {
  const match = xml.match(new RegExp(`\\s${attributeName}="([^"]*)"`))

  return match?.[1] ?? null
}

function replaceTextNodeValue(
  textNode: string,
  replacer: (value: string) => string | null
): string {
  const match = textNode.match(/^(<a:t(?:\s[^>]*)?>)([\s\S]*?)(<\/a:t>)$/)

  if (!match) {
    return textNode
  }

  const nextValue = replacer(match[2])

  if (nextValue === null) {
    return textNode
  }

  return `${match[1]}${nextValue}${match[3]}`
}

function replaceShapeTextPreservingStyle(slideXml: string, shapeName: string, text: string): string {
  return slideXml.replace(/<p:sp[\s\S]*?<\/p:sp>/g, (shapeXml: string) => {
    if (!hasShapeName(shapeXml, shapeName)) {
      return shapeXml
    }

    const escapedText = escapeXml(text)
    const textNodes = [...shapeXml.matchAll(/<a:t(?:\s[^>]*)?>[\s\S]*?<\/a:t>/g)]

    if (textNodes.length === 0) {
      return shapeXml
    }

    let replacedFirstText = false

    return shapeXml.replace(/<a:t(?:\s[^>]*)?>[\s\S]*?<\/a:t>/g, (textNode: string) => {
      if (replacedFirstText) {
        return replaceTextNodeValue(textNode, () => '')
      }

      replacedFirstText = true
      return replaceTextNodeValue(textNode, () => escapedText)
    })
  })
}

function replaceInlineTextPlaceholderPreservingStyle(
  slideXml: string,
  placeholder: string,
  text: string
): string {
  const placeholderPattern = new RegExp(`\\{\\{\\s*${escapeRegExp(placeholder)}\\s*\\}\\}`, 'g')

  return slideXml.replace(/<a:t(?:\s[^>]*)?>[\s\S]*?<\/a:t>/g, (textNode: string) =>
    replaceTextNodeValue(textNode, (value) => {
      placeholderPattern.lastIndex = 0

      if (!placeholderPattern.test(value)) {
        return null
      }

      placeholderPattern.lastIndex = 0
      return value.replace(placeholderPattern, escapeXml(text))
    })
  )
}

function getTextNodeValue(textNode: string): string {
  return textNode.match(/^<a:t(?:\s[^>]*)?>([\s\S]*?)<\/a:t>$/)?.[1] ?? ''
}

function replaceShapeInlinePlaceholdersPreservingStyle(
  slideXml: string,
  replacements: Record<string, string>
): string {
  return slideXml.replace(/<p:sp[\s\S]*?<\/p:sp>/g, (shapeXml: string) => {
    const textNodes = [...shapeXml.matchAll(/<a:t(?:\s[^>]*)?>[\s\S]*?<\/a:t>/g)]

    if (textNodes.length === 0) {
      return shapeXml
    }

    const combinedText = textNodes.map((match) => getTextNodeValue(match[0])).join('')
    let nextCombinedText = combinedText

    for (const [placeholder, text] of Object.entries(replacements)) {
      const placeholderPattern = new RegExp(
        `\\{\\{\\s*${escapeRegExp(placeholder)}\\s*\\}\\}`,
        'g'
      )
      nextCombinedText = nextCombinedText.replace(placeholderPattern, escapeXml(text))
    }

    if (nextCombinedText === combinedText) {
      return shapeXml
    }

    let replacedFirstText = false

    return shapeXml.replace(/<a:t(?:\s[^>]*)?>[\s\S]*?<\/a:t>/g, (textNode: string) => {
      if (replacedFirstText) {
        return replaceTextNodeValue(textNode, () => '')
      }

      replacedFirstText = true
      return replaceTextNodeValue(textNode, () => nextCombinedText)
    })
  })
}

async function replacePptxTextPreservingStyle(
  filePath: string,
  replacements: Record<string, string>,
  traceId: string
): Promise<void> {
  const fileBuffer = await readFile(filePath)
  const zip = await JSZip.loadAsync(fileBuffer)
  let replacementCount = 0

  for (const slidePath of getSlidePaths(zip)) {
    const slideFile = zip.file(slidePath)

    if (!slideFile) {
      continue
    }

    const originalSlideXml = await slideFile.async('text')
    let slideXml = originalSlideXml

    for (const [placeholder, text] of Object.entries(replacements)) {
      slideXml = replaceInlineTextPlaceholderPreservingStyle(slideXml, placeholder, text)
      slideXml = replaceShapeTextPreservingStyle(slideXml, placeholder, text)
    }
    slideXml = replaceShapeInlinePlaceholdersPreservingStyle(slideXml, replacements)

    if (slideXml !== originalSlideXml) {
      replacementCount += 1
      zip.file(slidePath, slideXml)
    }
  }

  const outputBuffer = await zip.generateAsync({
    type: 'nodebuffer',
    compression: 'DEFLATE'
  })
  await writeFile(filePath, outputBuffer)

  logger.debug(`[${traceId}] 保留样式的 PPTX 文本替换完成`, {
    touchedSlideCount: replacementCount,
    replacementKeys: Object.keys(replacements)
  })
}

function findImageReplacementTargets(
  ppt: PPTXTemplater,
  slideNumber: number,
  placeholderName: string,
  usedImageKeys: Set<string>,
  traceId: string
): PptImageReplacementTarget[] {
  const normalizedPlaceholderName = normalizePptObjectName(placeholderName)
  const slideImages = getSlideImages(ppt, slideNumber)
  const directMatchingTargets = slideImages
    .filter((image) =>
      [image.name, image.id].some(
        (identifier) => normalizePptObjectName(identifier) === normalizedPlaceholderName
      )
    )
    .map((image) => ({
      slideNumber,
      imageIdentifier: getImageIdentifier(image, placeholderName),
      imageKey: getImageReplacementKey(slideNumber, image),
      overlapArea: Number.MAX_SAFE_INTEGER,
      centerDistance: 0
    }))
    .filter((target) => !usedImageKeys.has(target.imageKey))

  if (directMatchingTargets.length > 0) {
    logger.debug(`[${traceId}] 图片占位符直接命中图片对象`, {
      slideNumber,
      placeholderName,
      targets: directMatchingTargets
    })
    return directMatchingTargets
  }

  const placeholderShapes = getSlideShapes(ppt, slideNumber).filter((shape) =>
    [shape.name, shape.id].some(
      (identifier) => normalizePptObjectName(identifier) === normalizedPlaceholderName
    )
  )

  // 如果模板里命名的是普通形状，而不是图片对象，就使用几何位置做兜底：
  // 1. 先找与占位形状有重叠面积的图片；
  // 2. 重叠面积越大越可信；
  // 3. 面积相近时，中心点越近越可信；
  // 4. usedImageKeys 确保 work_image_1 和 work_image_2 不会抢到同一张图片。
  const fallbackTargets = placeholderShapes
    .flatMap((placeholderShape) =>
      slideImages.map((image) => ({
        slideNumber,
        imageIdentifier: getImageIdentifier(image, placeholderName),
        imageKey: getImageReplacementKey(slideNumber, image),
        overlapArea: getOverlapArea(placeholderShape.position, image.position),
        centerDistance: getCenterDistance(placeholderShape.position, image.position)
      }))
    )
    .filter((candidate) => candidate.overlapArea > 0 && !usedImageKeys.has(candidate.imageKey))
    .sort((first, second) => {
      if (second.overlapArea !== first.overlapArea) {
        return second.overlapArea - first.overlapArea
      }

      return first.centerDistance - second.centerDistance
    })

  logger.debug(`[${traceId}] 图片占位符按位置匹配图片对象`, {
    slideNumber,
    placeholderName,
    placeholderShapes: placeholderShapes.map((shape) => ({
      id: shape.id,
      name: shape.name,
      position: describePosition(shape.position)
    })),
    candidateCount: fallbackTargets.length,
    candidates: fallbackTargets.slice(0, 8)
  })

  return fallbackTargets
}

async function replaceImageIfExists(
  ppt: PPTXTemplater,
  replacementPlan: PptImageReplacementPlan,
  usedImageKeys: Set<string>,
  traceId: string
): Promise<void> {
  const imageBuffer = await readImageBuffer(replacementPlan.imageValue)

  if (!imageBuffer) {
    logger.warn(`[${traceId}] 图片替换跳过：没有可读取的图片数据`, {
      placeholderName: replacementPlan.placeholderName,
      fieldKey: replacementPlan.fieldKey,
      imageValue: describeImageValue(replacementPlan.imageValue)
    })
    return
  }

  if (usedImageKeys.has(replacementPlan.target.imageKey)) {
    logger.warn(`[${traceId}] 图片替换跳过：目标图片对象已经替换过`, {
      placeholderName: replacementPlan.placeholderName,
      fieldKey: replacementPlan.fieldKey,
      target: replacementPlan.target
    })
    return
  }

  try {
    logger.debug(`[${traceId}] 开始使用 node-pptx-templater.replaceImage 替换图片`, {
      placeholderName: replacementPlan.placeholderName,
      fieldKey: replacementPlan.fieldKey,
      imageValue: describeImageValue(replacementPlan.imageValue),
      bufferBytes: imageBuffer.length,
      target: replacementPlan.target
    })

    await ppt
      .useSlide(replacementPlan.target.slideNumber)
      .replaceImage(replacementPlan.target.imageIdentifier, imageBuffer)
    usedImageKeys.add(replacementPlan.target.imageKey)
    logger.info(`[${traceId}] node-pptx-templater.replaceImage 图片替换成功`, {
      placeholderName: replacementPlan.placeholderName,
      fieldKey: replacementPlan.fieldKey,
      target: replacementPlan.target
    })
  } catch (error) {
    const message = error instanceof Error ? error.message : ''

    if (!message.includes('not found')) {
      logger.error(`[${traceId}] node-pptx-templater.replaceImage 图片替换失败`, {
        placeholderName: replacementPlan.placeholderName,
        fieldKey: replacementPlan.fieldKey,
        target: replacementPlan.target,
        error
      })
      throw error
    }

    logger.warn(`[${traceId}] 图片替换跳过：目标在当前页未找到`, {
      placeholderName: replacementPlan.placeholderName,
      fieldKey: replacementPlan.fieldKey,
      target: replacementPlan.target,
      message
    })
  }
}

function buildImageReplacementPlans(
  ppt: PPTXTemplater,
  replacements: Record<string, string>,
  getImageValue: (fieldKey: string) => string | null | undefined,
  traceId: string
): PptImageReplacementPlan[] {
  const plans: PptImageReplacementPlan[] = []
  const usedImageKeys = new Set<string>()
  const usedFieldSlideKeys = new Set<string>()

  for (const [placeholderName, fieldKey] of Object.entries(replacements)) {
    const imageValue = getImageValue(fieldKey)

    logger.debug(`[${traceId}] 开始规划图片占位符`, {
      placeholderName,
      fieldKey,
      imageValue: describeImageValue(imageValue)
    })

    if (!imageValue) {
      logger.warn(`[${traceId}] 图片占位符跳过：字段没有图片`, {
        placeholderName,
        fieldKey
      })
      continue
    }

    for (let slideNumber = 1; slideNumber <= ppt.slideCount; slideNumber += 1) {
      const fieldSlideKey = `${fieldKey}|${slideNumber}`

      if (usedFieldSlideKeys.has(fieldSlideKey)) {
        continue
      }

      const target = findImageReplacementTargets(
        ppt,
        slideNumber,
        placeholderName,
        usedImageKeys,
        traceId
      )[0]

      if (!target) {
        logger.debug(`[${traceId}] 当前页没有找到可替换图片目标`, {
          slideNumber,
          placeholderName,
          fieldKey
        })
        continue
      }

      // 这里先登记，再执行真正替换。
      // 原因是 node-pptx-templater 的 replaceImage 会修改 PPTX 内部 relationship，
      // 如果边扫描边替换，后续 getImages() 看到的 targetPath 可能已经变了，
      // 容易让第二个占位符匹配到刚被第一个占位符替换过的图片。
      plans.push({
        placeholderName,
        fieldKey,
        imageValue,
        target
      })
      usedImageKeys.add(target.imageKey)
      usedFieldSlideKeys.add(fieldSlideKey)
      logger.info(`[${traceId}] 图片替换计划已生成`, {
        placeholderName,
        fieldKey,
        slideNumber,
        target
      })
    }
  }

  logger.info(`[${traceId}] 图片替换计划汇总`, {
    planCount: plans.length,
    plans: plans.map((plan) => ({
      placeholderName: plan.placeholderName,
      fieldKey: plan.fieldKey,
      imageValue: describeImageValue(plan.imageValue),
      target: plan.target
    }))
  })

  const relationshipKeyCounts = new Map<string, number>()

  for (const plan of plans) {
    const relationshipKey = plan.target.imageKey.split('|').slice(3).join('|')
    relationshipKeyCounts.set(relationshipKey, (relationshipKeyCounts.get(relationshipKey) ?? 0) + 1)
  }

  const sharedRelationshipKeys = Array.from(relationshipKeyCounts.entries()).filter(
    ([, count]) => count > 1
  )

  if (sharedRelationshipKeys.length > 0) {
    logger.warn(
      `[${traceId}] 图片替换计划发现共享 relationship，请确认模板中的占位图片不是复制同一张图片资源`,
      { sharedRelationshipKeys }
    )
  }

  return plans
}

async function buildPptxReport(
  template: TemplateEntity,
  outputPath: string,
  student: StudentProfileEntity,
  classItem: ClassEntity,
  traceId: string
): Promise<void> {
  const templateFilePath = resolveTemplateFilePath(template.filePath)
  logger.info(`[${traceId}] 开始加载 PPTX 模板`, {
    templateId: template.id,
    templateName: template.name,
    templateFilePath,
    outputPath,
    studentId: student.id,
    studentName: student.name,
    classId: classItem.id,
    className: classItem.name
  })

  const ppt = await PPTXTemplater.load(templateFilePath, {
    logLevel: 'silent'
  })
  const studentData = getStudentData(student, classItem)
  const textReplacements = buildTextReplacements(studentData, classItem)
  const replacedImageKeys = new Set<string>()

  logger.info(`[${traceId}] PPTX 模板加载完成`, {
    slideCount: ppt.slideCount,
    textReplacementKeys: Object.keys(textReplacements),
    imageReplacementAliases: buildImageReplacements()
  })
  logSlideObjects(ppt, traceId)

  const imageReplacementPlans = buildImageReplacementPlans(
    ppt,
    buildImageReplacements(),
    (fieldKey) =>
      fieldKey === 'familyPhoto'
        ? classItem.familyPhoto
        : student[fieldKey as 'meImage' | 'workImage1' | 'workImage2'],
    traceId
  )

  for (const imageReplacementPlan of imageReplacementPlans) {
    await replaceImageIfExists(ppt, imageReplacementPlan, replacedImageKeys, traceId)
  }

  await ppt.saveToFile(outputPath)
  await replacePptxTextPreservingStyle(outputPath, textReplacements, traceId)
  logger.info(`[${traceId}] PPTX 报告保存完成`, {
    outputPath,
    replacedImageCount: replacedImageKeys.size
  })
}

async function removeFileIfExists(filePath: string): Promise<void> {
  try {
    await unlink(filePath)
  } catch {
    // Report files may have been moved or removed by the user.
  }
}

export async function loadReports(): Promise<ReportItem[]> {
  const repository = await getReportRepository()
  const reports = await repository.find({
    order: {
      updatedAt: 'DESC'
    }
  })

  return reports.map(mapReportEntity)
}

export async function generateReports(input: GenerateReportsInput): Promise<{
  reports: ReportItem[]
  skipped: Array<{ studentName: string; reason: string }>
}> {
  const source = await getAppDataSource()
  const templateRepository = source.getRepository<TemplateEntity>(TemplateEntitySchema)
  const classRepository = source.getRepository<ClassEntity>(ClassEntitySchema)
  const studentRepository = source.getRepository<StudentProfileEntity>(StudentProfileEntitySchema)
  const reportRepository = source.getRepository<ReportEntity>(ReportEntitySchema)
  const template = await templateRepository.findOneBy({ id: input.templateId })

  if (!template) {
    throw new Error('请选择报告模板')
  }

  if (!['.ppt', '.pptx'].includes(template.fileExtension.toLowerCase())) {
    throw new Error('当前生成方式仅支持 PPT/PPTX 模板')
  }

  if (template.fileExtension.toLowerCase() !== '.pptx') {
    throw new Error('请使用 .pptx 模板生成报告，旧版 .ppt 暂不支持直接写入')
  }

  await access(resolveTemplateFilePath(template.filePath))

  const classItem = await classRepository.findOneBy({ id: input.classId })

  if (!classItem) {
    throw new Error('请选择班级')
  }

  const selectedIds = new Set(input.studentIds ?? [])
  const students = await studentRepository.find({
    where: {
      classId: input.classId
    },
    order: {
      name: 'ASC',
      id: 'ASC'
    }
  })
  const targetStudents =
    selectedIds.size > 0 ? students.filter((student) => selectedIds.has(student.id)) : students

  if (targetStudents.length === 0) {
    throw new Error('当前班级没有可生成的学生')
  }

  await mkdir(getReportStoragePath(), { recursive: true })

  const now = new Date().toISOString()
  const reports: ReportItem[] = []
  const skipped: Array<{ studentName: string; reason: string }> = []
  const batchTraceId = randomUUID()

  logger.info(`[${batchTraceId}] 开始批量生成报告`, {
    templateId: template.id,
    templateName: template.name,
    templateFilePath: resolveTemplateFilePath(template.filePath),
    classId: classItem.id,
    className: classItem.name,
    selectedStudentCount: targetStudents.length,
    selectedIds: Array.from(selectedIds)
  })
  sendReportProgress({
    batchId: batchTraceId,
    status: 'started',
    classId: classItem.id,
    className: classItem.name,
    current: 0,
    total: targetStudents.length
  })

  for (const [studentIndex, student] of targetStudents.entries()) {
    const studentName = student.name || student.englishName || '未命名学生'
    const title = `${classItem.name} ${studentName} 幼儿成长报告`
    const reportId = randomUUID()
    const fileName = `${sanitizeFileName(title)}${template.fileExtension}`
    const outputPath = join(getReportStoragePath(), `${reportId}${template.fileExtension}`)
    const traceId = `${batchTraceId}:${student.id}`

    try {
      sendReportProgress({
        batchId: batchTraceId,
        status: 'student-started',
        classId: classItem.id,
        className: classItem.name,
        studentId: student.id,
        studentName,
        current: studentIndex + 1,
        total: targetStudents.length
      })
      logger.info(`[${traceId}] 开始生成单个学生报告`, {
        studentId: student.id,
        studentName,
        reportId,
        outputPath,
        imageFields: {
          meImage: describeImageValue(student.meImage),
          workImage1: describeImageValue(student.workImage1),
          workImage2: describeImageValue(student.workImage2),
          familyPhoto: describeImageValue(classItem.familyPhoto)
        }
      })

      await buildPptxReport(template, outputPath, student, classItem, traceId)

      const entity: ReportEntity = {
        id: reportId,
        title,
        classId: classItem.id,
        className: classItem.name,
        studentId: student.id,
        studentName,
        format: getFormatFromExtension(template.fileExtension),
        originalFileName: fileName,
        filePath: outputPath,
        fileExtension: template.fileExtension,
        templateId: template.id,
        createdAt: now,
        updatedAt: now
      }

      await reportRepository.save(entity)
      await studentRepository.update({ id: student.id }, { reportGenerated: true })
      const reportItem = mapReportEntity(entity)
      reports.push(reportItem)
      sendReportProgress({
        batchId: batchTraceId,
        status: 'student-finished',
        classId: classItem.id,
        className: classItem.name,
        studentId: student.id,
        studentName,
        current: studentIndex + 1,
        total: targetStudents.length,
        report: reportItem
      })
      logger.info(`[${traceId}] 单个学生报告生成成功`, {
        reportId,
        outputPath
      })
    } catch (error) {
      const reason = error instanceof Error ? error.message : '生成失败'
      logger.error(`[${traceId}] 单个学生报告生成失败`, {
        studentId: student.id,
        studentName,
        reportId,
        outputPath,
        error
      })
      skipped.push({
        studentName,
        reason
      })
      sendReportProgress({
        batchId: batchTraceId,
        status: 'student-failed',
        classId: classItem.id,
        className: classItem.name,
        studentId: student.id,
        studentName,
        current: studentIndex + 1,
        total: targetStudents.length,
        error: reason
      })
      await removeFileIfExists(outputPath)
    }
  }

  logger.info(`[${batchTraceId}] 批量生成报告完成`, {
    successCount: reports.length,
    skippedCount: skipped.length,
    skipped
  })
  sendReportProgress({
    batchId: batchTraceId,
    status: 'finished',
    classId: classItem.id,
    className: classItem.name,
    current: targetStudents.length,
    total: targetStudents.length
  })

  return { reports, skipped }
}

export async function openReport(filePath: string): Promise<void> {
  await access(filePath)

  const errorMessage = await shell.openPath(filePath)

  if (errorMessage) {
    throw new Error(errorMessage)
  }
}

export async function downloadReport(id: string): Promise<{ filePath: string; canceled?: boolean }> {
  const repository = await getReportRepository()
  const report = await repository.findOneBy({ id })

  if (!report) {
    throw new Error('报告不存在')
  }

  await access(report.filePath)

  const result = await dialog.showSaveDialog({
    title: '下载报告',
    defaultPath: getReportDownloadFileName(report),
    filters: [
      {
        name: '报告文件',
        extensions: [report.fileExtension.replace(/^\./, '') || 'pptx']
      }
    ]
  })

  if (result.canceled || !result.filePath) {
    return { filePath: '', canceled: true }
  }

  await copyFile(report.filePath, result.filePath)
  return { filePath: result.filePath }
}

export async function downloadClassReports(
  input: DownloadClassReportsInput
): Promise<{ filePath: string; reportCount: number; canceled?: boolean }> {
  const repository = await getReportRepository()
  const reports = await repository.find({
    where: {
      classId: input.classId
    },
    order: {
      studentName: 'ASC',
      createdAt: 'DESC'
    }
  })

  if (reports.length === 0) {
    throw new Error('当前班级还没有可下载的报告')
  }

  const className = reports[0]?.className || '班级报告'
  const result = await dialog.showSaveDialog({
    title: '下载班级报告',
    defaultPath: `${sanitizeFileName(className)}成长报告.zip`,
    filters: [{ name: 'ZIP 压缩包', extensions: ['zip'] }]
  })

  if (result.canceled || !result.filePath) {
    return { filePath: '', reportCount: reports.length, canceled: true }
  }

  const zip = new JSZip()
  const usedFileNames = new Map<string, number>()

  for (const report of reports) {
    await access(report.filePath)
    const baseFileName = getReportDownloadFileName(report)
    const usedCount = usedFileNames.get(baseFileName) ?? 0
    usedFileNames.set(baseFileName, usedCount + 1)
    const fileName =
      usedCount === 0
        ? baseFileName
        : `${baseFileName.replace(report.fileExtension, '')}-${usedCount + 1}${report.fileExtension}`

    zip.file(fileName, await readFile(report.filePath))
  }

  const zipBuffer = await zip.generateAsync({
    type: 'nodebuffer',
    compression: 'DEFLATE'
  })
  await writeFile(result.filePath, zipBuffer)

  return { filePath: result.filePath, reportCount: reports.length }
}

export async function deleteReport(id: string): Promise<void> {
  const source = await getAppDataSource()
  const repository = source.getRepository<ReportEntity>(ReportEntitySchema)
  const studentRepository = source.getRepository<StudentProfileEntity>(StudentProfileEntitySchema)
  const report = await repository.findOneBy({ id })

  if (!report) {
    return
  }

  await repository.delete({ id })
  await removeFileIfExists(report.filePath)

  const remainingReportCount = await repository.countBy({ studentId: report.studentId })

  if (remainingReportCount === 0) {
    await studentRepository.update({ id: report.studentId }, { reportGenerated: false })
  }
}
