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 { In, 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,
  ReportDownloadProgress,
  ReportFormat,
  ReportGenerationProgress,
  ReportItem
} from '../types/report'
import type { ClassTypeConfig } from '../types/settings'
import { getAppDataSource } from './databaseService'
import { readImageBuffer } from './imageStorageService'
import { DEFAULT_CLASS_TYPE_CONFIGS, loadSettings } from './settingsService'

const REPORT_FOLDER_NAME = 'reports'
const TEMPLATE_FOLDER_NAME = 'templates'

type ReportGenerationController = {
  batchId: string
  paused: boolean
  stopped: boolean
  lastProgress: ReportGenerationProgress
  resumeWaiters: Array<() => void>
}

const reportGenerationControllers = new Map<string, ReportGenerationController>()

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
  traits: string
  comment: string
  comments: string
  teacherName: string
  classType: string
  classTypeLabel: string
  courseContent: string
}

type StudentProfileWithAliases = StudentProfileEntity & {
  studentName?: 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 sendReportDownloadProgress(progress: ReportDownloadProgress): void {
  for (const window of BrowserWindow.getAllWindows()) {
    window.webContents.send('reports:download-class-progress', progress)
  }
}

function updateReportControllerProgress(
  controller: ReportGenerationController,
  progress: ReportGenerationProgress
): void {
  controller.lastProgress = progress
  sendReportProgress(progress)
}

async function waitForReportGenerationResume(
  controller: ReportGenerationController
): Promise<void> {
  if (!controller.paused || controller.stopped) {
    return
  }

  updateReportControllerProgress(controller, {
    ...controller.lastProgress,
    status: 'paused',
    studentId: undefined,
    studentName: undefined
  })

  await new Promise<void>((resolve) => {
    controller.resumeWaiters.push(resolve)
  })
}

export function pauseReportGeneration(batchId: string): boolean {
  const controller = reportGenerationControllers.get(batchId)

  if (!controller || controller.stopped) {
    return false
  }

  controller.paused = true
  updateReportControllerProgress(controller, {
    ...controller.lastProgress,
    status: 'paused',
    studentId: undefined,
    studentName: undefined
  })
  return true
}

export function resumeReportGeneration(batchId: string): boolean {
  const controller = reportGenerationControllers.get(batchId)

  if (!controller || controller.stopped) {
    return false
  }

  controller.paused = false
  const waiters = controller.resumeWaiters.splice(0)

  for (const resolve of waiters) {
    resolve()
  }

  updateReportControllerProgress(controller, {
    ...controller.lastProgress,
    status: 'started',
    studentId: undefined,
    studentName: undefined
  })
  return true
}

export function stopReportGeneration(batchId: string): boolean {
  const controller = reportGenerationControllers.get(batchId)

  if (!controller) {
    return false
  }

  controller.stopped = true
  controller.paused = false
  const waiters = controller.resumeWaiters.splice(0)

  for (const resolve of waiters) {
    resolve()
  }

  updateReportControllerProgress(controller, {
    ...controller.lastProgress,
    status: 'stopped',
    studentId: undefined,
    studentName: undefined
  })
  return true
}

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 getStudentName(student: StudentProfileEntity): string {
  const profile = student as StudentProfileWithAliases

  return profile.studentName || profile.name || profile.englishName || ''
}

function getClassTypeConfig(
  classType: string,
  classTypeConfigs: ClassTypeConfig[]
): ClassTypeConfig {
  return (
    classTypeConfigs.find((config) => config.id === classType) ??
    DEFAULT_CLASS_TYPE_CONFIGS.find((config) => config.id === classType) ?? {
      id: classType,
      label: classType || '便宜班',
      courseContent: ''
    }
  )
}

function getStudentData(
  student: StudentProfileEntity,
  classItem: ClassEntity,
  classTypeConfigs: ClassTypeConfig[]
): 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(' ') || ' '
  const classType = classItem.type || 'cheap'
  const classTypeConfig = getClassTypeConfig(classType, classTypeConfigs)

  return {
    id: student.id,
    classId: student.classId,
    className: student.className,
    name: getStudentName(student) || '未填写',
    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,
    traits: student.traits || ' ',
    comment: student.comment || '暂无评语',
    comments: student.comment || '暂无评语',
    teacherName,
    classType,
    classTypeLabel: classTypeConfig.label,
    courseContent: classTypeConfig.courseContent || ' '
  }
}

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,
    classType: studentData.classType,
    class_type: studentData.classType,
    classTypeLabel: studentData.classTypeLabel,
    class_type_label: studentData.classTypeLabel,
    courseContent: studentData.courseContent,
    course_content: studentData.courseContent,
    classCourse: studentData.courseContent,
    class_course: studentData.courseContent,
    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,
    traits: studentData.traits
  }
}

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 = {
  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 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\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\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>
): Promise<void> {
  const fileBuffer = await readFile(filePath)
  const zip = await JSZip.loadAsync(fileBuffer)

  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) {
      zip.file(slidePath, slideXml)
    }
  }

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

function findImageReplacementTargets(
  ppt: PPTXTemplater,
  slideNumber: number,
  placeholderName: string,
  usedImageKeys: Set<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) {
    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
    })

  return fallbackTargets
}

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

  if (!imageBuffer) {
    return
  }

  if (usedImageKeys.has(replacementPlan.target.imageKey)) {
    return
  }

  try {
    await ppt
      .useSlide(replacementPlan.target.slideNumber)
      .replaceImage(replacementPlan.target.imageIdentifier, imageBuffer)
    usedImageKeys.add(replacementPlan.target.imageKey)
  } catch (error) {
    const message = error instanceof Error ? error.message : ''

    if (!message.includes('not found')) {
      throw error
    }
  }
}

function buildImageReplacementPlans(
  ppt: PPTXTemplater,
  replacements: Record<string, string>,
  getImageValue: (fieldKey: string) => string | null | undefined
): 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)

    if (!imageValue) {
      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
      )[0]

      if (!target) {
        continue
      }

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

  return plans
}

async function buildPptxReport(
  template: TemplateEntity,
  outputPath: string,
  student: StudentProfileEntity,
  classItem: ClassEntity,
  classTypeConfigs: ClassTypeConfig[]
): Promise<void> {
  const templateFilePath = resolveTemplateFilePath(template.filePath)

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

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

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

  await ppt.saveToFile(outputPath)
  await replacePptxTextPreservingStyle(outputPath, textReplacements)
}

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 }>
  stopped?: boolean
}> {
  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 reportTemplate = template
  const reportClass = classItem
  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('当前班级没有可生成的学生')
  }

  const settingsResponse = await loadSettings()
  const classTypeConfigs = settingsResponse.ok
    ? settingsResponse.classTypeConfigs
    : DEFAULT_CLASS_TYPE_CONFIGS

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

  const now = new Date().toISOString()
  const reports: ReportItem[] = []
  const skipped: Array<{ studentName: string; reason: string }> = []
  const batchTraceId = randomUUID()
  let completedCount = 0
  const controller: ReportGenerationController = {
    batchId: batchTraceId,
    paused: false,
    stopped: false,
    lastProgress: {
      batchId: batchTraceId,
      status: 'started',
      classId: reportClass.id,
      className: reportClass.name,
      current: 0,
      total: targetStudents.length
    },
    resumeWaiters: []
  }

  reportGenerationControllers.set(batchTraceId, controller)

  function publishProgress(progress: ReportGenerationProgress): void {
    updateReportControllerProgress(controller, progress)
  }

  publishProgress(controller.lastProgress)

  async function generateStudentReport(student: StudentProfileEntity): Promise<void> {
    const studentName = getStudentName(student) || '未命名学生'
    const title = `${reportClass.name} ${studentName} 幼儿成长报告`
    const reportId = randomUUID()
    const fileName = `${sanitizeFileName(title)}${reportTemplate.fileExtension}`
    const outputPath = join(getReportStoragePath(), `${reportId}${reportTemplate.fileExtension}`)

    try {
      publishProgress({
        batchId: batchTraceId,
        status: 'student-started',
        classId: reportClass.id,
        className: reportClass.name,
        studentId: student.id,
        studentName,
        current: completedCount,
        total: targetStudents.length
      })

      await buildPptxReport(reportTemplate, outputPath, student, reportClass, classTypeConfigs)

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

      await reportRepository.save(entity)
      await studentRepository.update({ id: student.id }, { reportGenerated: true })
      const reportItem = mapReportEntity(entity)
      reports.push(reportItem)
      completedCount += 1
      publishProgress({
        batchId: batchTraceId,
        status: 'student-finished',
        classId: reportClass.id,
        className: reportClass.name,
        studentId: student.id,
        studentName,
        current: completedCount,
        total: targetStudents.length,
        report: reportItem
      })
    } catch (error) {
      const reason = error instanceof Error ? error.message : '生成失败'

      skipped.push({
        studentName,
        reason
      })
      completedCount += 1
      publishProgress({
        batchId: batchTraceId,
        status: 'student-failed',
        classId: reportClass.id,
        className: reportClass.name,
        studentId: student.id,
        studentName,
        current: completedCount,
        total: targetStudents.length,
        error: reason
      })
      await removeFileIfExists(outputPath)
    }
  }

  try {
    for (const student of targetStudents) {
      await waitForReportGenerationResume(controller)

      if (controller.stopped) {
        break
      }

      await generateStudentReport(student)
    }

    publishProgress({
      batchId: batchTraceId,
      status: controller.stopped ? 'stopped' : 'finished',
      classId: reportClass.id,
      className: reportClass.name,
      current: controller.stopped ? completedCount : targetStudents.length,
      total: targetStudents.length
    })
  } finally {
    reportGenerationControllers.delete(batchTraceId)
  }

  return { reports, skipped, stopped: controller.stopped }
}

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 batchId = randomUUID()
  const zip = new JSZip()
  const usedFileNames = new Map<string, number>()
  let completedCount = 0

  function publishProgress(
    progress: Omit<ReportDownloadProgress, 'batchId' | 'classId' | 'className'>
  ): void {
    sendReportDownloadProgress({
      batchId,
      classId: input.classId,
      className,
      ...progress
    })
  }

  try {
    publishProgress({
      status: 'started',
      current: 0,
      total: reports.length,
      filePath: result.filePath
    })

    for (const report of reports) {
      publishProgress({
        status: 'file-started',
        reportId: report.id,
        reportTitle: report.title,
        current: completedCount,
        total: reports.length,
        filePath: result.filePath
      })

      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))
      completedCount += 1

      publishProgress({
        status: 'file-finished',
        reportId: report.id,
        reportTitle: report.title,
        current: completedCount,
        total: reports.length,
        filePath: result.filePath
      })
    }

    publishProgress({
      status: 'writing',
      current: reports.length,
      total: reports.length,
      filePath: result.filePath
    })

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

    publishProgress({
      status: 'finished',
      current: reports.length,
      total: reports.length,
      filePath: result.filePath
    })
  } catch (error) {
    publishProgress({
      status: 'failed',
      current: completedCount,
      total: reports.length,
      filePath: result.filePath,
      error: error instanceof Error ? error.message : '下载班级报告失败'
    })
    throw error
  }

  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 })
  }
}

export async function deleteAllReports(): Promise<number> {
  const source = await getAppDataSource()
  const repository = source.getRepository<ReportEntity>(ReportEntitySchema)
  const studentRepository = source.getRepository<StudentProfileEntity>(StudentProfileEntitySchema)
  const reports = await repository.find()

  if (reports.length === 0) {
    return 0
  }

  const studentIds = Array.from(new Set(reports.map((report) => report.studentId).filter(Boolean)))

  if (studentIds.length > 0) {
    await studentRepository.update({ id: In(studentIds) }, { reportGenerated: false })
  }

  await repository.clear()
  await Promise.all(reports.map((report) => removeFileIfExists(report.filePath)))

  return reports.length
}
