// Composes a full schedule deck from content markers and topic files, then runs Marp.
import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync, statSync, cpSync } from 'node:fs'
import { spawnSync } from 'node:child_process'
import { join, resolve, relative, dirname, extname } from 'node:path'
import { fileURLToPath } from 'node:url'

const _pkgDir = dirname(dirname(fileURLToPath(import.meta.url)))

import { buildConfig } from './config.js'
import { splitFrontmatter, parseFrontmatter } from './frontmatter.js'
import { findMarkers } from './marker.js'
import { resolveSlug } from './resolve.js'
import { collapseBreaks, maskCode } from './slides.js'
import { firstHeading } from './heading.js'
import { composeItem, finalizeHtml } from './item.js'
import * as sort from './sort.js'
import { parseTimings, parseStartTime, validateTimings } from './timings.js'
import { buildDays, slideRangesFromSentinels } from './schedule-days.js'

function expandScheduleIncludes(body, root, seen = new Set()) {
  let out = ''
  let cursor = 0
  const childTimingsList = []

  for (const marker of findMarkers(body)) {
    if (marker.parsed.kind !== 'schedule') continue

    out += body.slice(cursor, marker.start)
    cursor = marker.end
    if (body[cursor] === '\n') cursor++

    const { slug } = marker.parsed
    if (seen.has(slug)) throw new Error(`circular schedule include: '${slug}'`)

    const childPath = resolve(root, `content/schedules/${slug}/index.md`)
    if (!existsSync(childPath)) throw new Error(`schedule not found: ${slug}`)

    const childSrc = readFileSync(childPath, 'utf8')
    const { frontmatter: childFm, body: childBody } = splitFrontmatter(childSrc)
    const childFmParsed = parseFrontmatter(childFm)

    const nextSeen = new Set([...seen, slug])
    const { expandedBody: childExpanded, effectiveTimings: childEffectiveTimings } = expandScheduleIncludes(childBody, root, nextSeen)

    let effectiveChildTimings
    if (childFmParsed.timings != null) {
      effectiveChildTimings = String(childFmParsed.timings)
    } else if (childEffectiveTimings != null) {
      effectiveChildTimings = childEffectiveTimings
    } else {
      throw new Error(`timings required: schedule '${slug}' has no timings and no child schedules`)
    }

    childTimingsList.push(effectiveChildTimings)
    out += childExpanded.replace(/(\n\*\*\*)+\s*$/, '\n')
  }

  out += body.slice(cursor)

  const effectiveTimings = childTimingsList.length > 0 ? childTimingsList.join(';') : null
  return { expandedBody: out, effectiveTimings }
}

function resolveEffectiveStartTimes(timingStartTimes, fmStartTimes, sessionDurationsByDay) {
  if (fmStartTimes == null) return [...timingStartTimes]
  const conflictDay = timingStartTimes.findIndex(t => t != null)
  if (conflictDay >= 0) throw new Error(`start_time is redundant when timings use time ranges — remove one`)
  if (fmStartTimes.length === 1 && sessionDurationsByDay.length > 1) {
    process.stdout.write(`  Using ${fmStartTimes[0]} start time for ${sessionDurationsByDay.length} days\n`)
    return sessionDurationsByDay.map(() => fmStartTimes[0])
  }
  return timingStartTimes.map((t, i) => t ?? fmStartTimes[i] ?? null)
}

function expandBreakMarker(parsed, root, itemIdx) {
  const breakContentPath = resolve(root, `content/breaks/${parsed.type}/index.md`)
  const hasContent = parsed.type !== 'end' && existsSync(breakContentPath)
  process.stdout.write(`  [${parsed.type} break]\n`)
  if (hasContent) {
    const { body: breakBody } = splitFrontmatter(readFileSync(breakContentPath, 'utf8'))
    return {
      fragment: `<!-- __item_${itemIdx}_start -->\n${breakBody.trimEnd()}\n<!-- __item_${itemIdx}_end -->\n`,
      event: { kind: 'break', type: parsed.type, duration: parsed.duration, itemIdx },
      nextItemIdx: itemIdx + 1,
    }
  }
  return {
    fragment: '',
    event: { kind: 'break', type: parsed.type, duration: parsed.duration, itemIdx: null },
    nextItemIdx: itemIdx,
  }
}

function expandContentMarker(parsed, root, isDraft, itemIdx) {
  let resolvedType = 'topic'
  let title = `[draft: ${parsed.slug}]`
  let contentBody = `# ${title}\n\n*Topic not yet authored.*\n`
  let skipped = false
  let topicDir = null

  try {
    const resolved = resolveSlug(parsed.slug, { root })
    resolvedType = resolved.type
    const srcDir = dirname(resolved.path)
    topicDir = srcDir
    let srcPath = resolved.path
    const rel = relative(resolve(root, 'content'), srcDir)
    const builtPath = resolve(root, 'decks', rel, 'index-built.md')
    if (existsSync(builtPath)) srcPath = builtPath
    const split = splitFrontmatter(readFileSync(srcPath, 'utf8'))
    contentBody = split.body
    const itemFm = parseFrontmatter(split.frontmatter)
    title = itemFm.title ?? firstHeading(contentBody) ?? parsed.slug
  } catch (err) {
    if (!isDraft) throw err
    skipped = true
  }

  if (!skipped && parsed.duration == null && !isDraft) {
    throw new Error(`duration required: '${parsed.slug}' has no duration in schedule marker`)
  }

  if (skipped) process.stdout.write(`  ${parsed.slug} [skipped]\n`)
  else if (parsed.duration == null) process.stdout.write(`  ${parsed.slug} — ${title} [no duration]\n`)
  else process.stdout.write(`  ${parsed.slug} — ${title}\n`)

  const trimmed = contentBody.trimEnd()
  const stripped = trimmed.replace(/(\s*\n---)+$/, '')
  if (!skipped && stripped !== trimmed) {
    process.stdout.write(`  [warning] ${parsed.slug} has a trailing --- which is unnecessary in a schedule — remove it from the topic file\n`)
  }

  const prefixed = stripped.replace(
    /!\[([^\]]*)\]\((?!https?:\/\/|\/|data:)([^)]+)\)/g,
    (_, alt, src) => `![${alt}](${parsed.slug}/${src})`
  )

  return {
    fragment: `<!-- __item_${itemIdx}_start -->\n${prefixed}\n<!-- __item_${itemIdx}_end -->\n`,
    event: { kind: 'content', itemIdx, slug: parsed.slug, type: resolvedType, title, duration: parsed.duration ?? null, kv: parsed.kv ?? {} },
    topicDir,
    nextItemIdx: itemIdx + 1,
  }
}

function expandMarkers(expandedBody, root, isDraft) {
  const topicDirs = new Set()
  const allEvents = []
  let out = ''
  let cursor = 0
  let itemIdx = 0

  process.stdout.write('Content:\n')

  for (const marker of findMarkers(expandedBody)) {
    out += expandedBody.slice(cursor, marker.start)
    cursor = marker.end
    if (expandedBody[cursor] === '\n') cursor++

    const { parsed } = marker
    if (parsed.kind === 'deprecated-end') {
      process.stdout.write('  [warning] {{ break end }} is no longer needed — remove it, or use *** to separate days in a multi-day schedule\n')
      continue
    }
    const result = parsed.kind === 'break'
      ? expandBreakMarker(parsed, root, itemIdx)
      : expandContentMarker(parsed, root, isDraft, itemIdx)

    if (result.topicDir) topicDirs.add(result.topicDir)
    out += result.fragment
    allEvents.push(result.event)
    itemIdx = result.nextItemIdx
  }

  out += expandedBody.slice(cursor)
  return { markdown: out, allEvents, topicDirs, itemIdx }
}

export function buildSchedule(slug, { root = process.cwd(), draft: isDraft = false, deckMeta = {} } = {}) {
  // --- parse source ---
  const srcPath = resolve(root, `content/schedules/${slug}/index.md`)
  if (!existsSync(srcPath)) throw new Error(`schedule not found: ${slug}`)

  const src = readFileSync(srcPath, 'utf8')
  const { frontmatter, body } = splitFrontmatter(src)
  const fm = parseFrontmatter(frontmatter)

  // --- resolve timings ---
  const { expandedBody, effectiveTimings } = expandScheduleIncludes(body, root, new Set([slug]))

  let timingsStr
  if (fm.timings != null) {
    timingsStr = String(fm.timings)
  } else if (effectiveTimings != null) {
    timingsStr = effectiveTimings
  } else {
    throw new Error(`timings required: specify 'timings' in frontmatter or include child schedules with their own timings`)
  }

  const { sessions: sessionDurationsByDay, startTimes: timingStartTimes, impliedBreaks } = parseTimings(timingsStr)
  const effectiveStartTimes = resolveEffectiveStartTimes(timingStartTimes, parseStartTime(fm.start_time ?? null), sessionDurationsByDay)

  // --- expand markers and compute slide ranges ---
  const { markdown: rawMarkdown, allEvents, topicDirs, itemIdx } = expandMarkers(expandedBody, root, isDraft)

  const collapsed = collapseBreaks(rawMarkdown)
  const slideRanges = slideRangesFromSentinels(collapsed, itemIdx)
  const markdown = collapsed.replace(/^<!-- __item_\d+_(start|end) -->\n/gm, '')

  if ([...maskCode(markdown).matchAll(/^\{\{([\s\S]*?)\}\}/gm)].some(m => m[1].trim().startsWith('build'))) {
    throw new Error('{{ build }} directives are not allowed in schedule markdown — use them only in individual topic files')
  }

  // --- build day schedule and validate ---
  const { days, sessionCountsPerDay } = buildDays(allEvents, slideRanges, sessionDurationsByDay, effectiveStartTimes, impliedBreaks)
  validateTimings(sessionDurationsByDay, sessionCountsPerDay)

  // --- assemble outline (alphabetically sorted for stable diffs) ---
  const fmDeck = fm.deck ?? {}
  const deck = { ...deckMeta, ...fmDeck }
  const outlineInput = { days, description: fm.description ?? '', slug, title: fm.title ?? firstHeading(body) ?? slug }
  if (Object.keys(deck).length > 0) outlineInput.deck = sort.byKeys(deck)
  const outline = sort.byKeys({
    ...outlineInput,
    days: days.map(day => sort.byKeys({
      ...(day.start_time != null ? { start_time: day.start_time } : {}),
      schedule: day.schedule.map(entry => sort.byKeys(entry)),
    })),
  })

  return { frontmatter, markdown, outline, topicDirs }
}

export async function composeSchedule(slug, _rest = [], { root = process.cwd(), draft: isDraft = false, deckMeta = {} } = {}) {
  const schedSrc = resolve(root, `content/schedules/${slug}/index.md`)
  if (!existsSync(schedSrc)) throw new Error(`schedule not found: ${slug}`)
  const { body: rawBody } = splitFrontmatter(readFileSync(schedSrc, 'utf8'))
  const { expandedBody } = expandScheduleIncludes(rawBody, root, new Set([slug]))

  const topicDirs = new Set()
  const itemDecks = new Map() // slug → built deck dir
  for (const marker of findMarkers(expandedBody)) {
    if (marker.parsed.kind !== 'content') continue
    let resolved
    try {
      resolved = resolveSlug(marker.parsed.slug, { root })
    } catch (err) {
      if (!isDraft) throw err
      continue
    }
    if (resolved.type === 'activity') continue
    const srcDir = dirname(resolved.path)
    topicDirs.add(srcDir)
    const rel = relative(resolve(root, 'content'), srcDir)
    itemDecks.set(marker.parsed.slug, resolve(root, 'decks', rel))
    const code = await composeItem(marker.parsed.slug, [], { root })
    if (code !== 0) throw new Error(`item build failed: ${marker.parsed.slug} (exit ${code})`)
  }

  const schedOut = resolve(root, `decks/schedules/${slug}/index.html`)
  if (existsSync(schedOut)) {
    const schedOutMtime = statSync(schedOut).mtimeMs
    if (schedOutMtime >= statSync(schedSrc).mtimeMs) {
      const allTopicsFresh = [...topicDirs].every(srcDir => {
        const rel = relative(resolve(root, 'content'), srcDir)
        const topicOut = resolve(root, 'decks', rel, 'index.html')
        return !existsSync(topicOut) || statSync(topicOut).mtimeMs <= schedOutMtime
      })
      if (allTopicsFresh) return { status: 0, topicDirs }
    }
  }

  const { frontmatter, markdown, outline } = buildSchedule(slug, { root, draft: isDraft, deckMeta })

  const destDir = resolve(root, `decks/schedules/${slug}`)
  rmSync(destDir, { recursive: true, force: true })
  mkdirSync(destDir, { recursive: true })

  for (const [itemSlug, builtDeckDir] of itemDecks) {
    const SKIP = new Set(['.html', '.md', '.excalidraw'])
    cpSync(builtDeckDir, join(destDir, itemSlug), {
      recursive: true,
      filter: src => !SKIP.has(extname(src)),
    })
  }

  const composedPath = join(destDir, 'schedule.md')
  writeFileSync(composedPath, `---\n${frontmatter}\n---\n\n${markdown}`)

  const marpBin = resolve(_pkgDir, 'node_modules/.bin/marp')
  const marpConfig = buildConfig(root)
  const marpRun = spawnSync(marpBin, ['--no-stdin', '--config', marpConfig, '--output', 'index.html', 'schedule.md'], {
    cwd: destDir,
    stdio: 'inherit',
  })
  if (marpRun.status !== 0) return { status: marpRun.status ?? 1, topicDirs }

  const outPath = join(destDir, 'index.html')
  const html = readFileSync(outPath, 'utf8')
  writeFileSync(outPath, finalizeHtml(html, { root, expanded: markdown, outline }))
  process.stdout.write(`→ ${relative(root, outPath)}\n`)

  return { status: 0, topicDirs }
}
