package main

import (
	"bytes"
	"fmt"
	"html/template"
	"log"
	"net/http"
	"net/url"
	"os"
	"path"
	"path/filepath"
	"regexp"
	"sort"
	"strconv"
	"strings"
	"time"
)

func init() {
	pageTypeHandlers = append(pageTypeHandlers, &diaryHandler{})
}

type diaryHandler struct{}

// redirect handles diary-specific redirect cases. The year page is the only
// real diary page; month and day URLs are aliases that collapse to a year
// page anchor (or to the year-file editor when ?edit is set).
//
//  1. `today` / `this-month` / `this-year` shortcuts resolve directly to a
//     year+anchor target (or insert flow when today's section is missing).
//  2. A virtual month URL (/diary/<root>/YYYY/MM/) redirects to
//     /diary/<root>/YYYY/#YYYY-MM (or to ?edit&section=N when ?edit is set).
//  3. A virtual day URL (/diary/<root>/YYYY/MM/DD/) redirects to
//     /diary/<root>/YYYY/#YYYY-MM-DD (or to the section / insert_before
//     editor flow when ?edit is set).
//
// Returns ok=false when the request is not a diary-handled redirect.
func (d *diaryHandler) redirect(root, fsPath, urlPath string, r *http.Request) (string, bool) {
	if target, ok := d.dateShortcutRedirect(root, fsPath, urlPath); ok {
		return target, true
	}
	if r.Method != http.MethodGet {
		return "", false
	}
	_, edit := r.URL.Query()["edit"]
	return d.virtualURLRedirect(root, fsPath, urlPath, edit)
}

func (d *diaryHandler) dateShortcutRedirect(root, fsPath, urlPath string) (string, bool) {
	base := path.Base(strings.TrimSuffix(urlPath, "/"))
	switch base {
	case "today", "this-month", "this-year":
	default:
		return "", false
	}

	parentFS := filepath.Dir(fsPath)
	parentURLPath := parentURL(urlPath)
	_, diaryRootFS, diaryRootURL, ok := findDiaryContext(root, parentFS, parentURLPath)
	if !ok {
		return "", false
	}

	now := time.Now()
	year := fmt.Sprintf("%d", now.Year())
	month := fmt.Sprintf("%02d", int(now.Month()))
	day := fmt.Sprintf("%02d", now.Day())
	yearURL := path.Join(diaryRootURL, year) + "/"

	switch base {
	case "today":
		dayHeading := fmt.Sprintf("%s-%s-%s", year, month, day)
		raw, _ := os.ReadFile(filepath.Join(diaryRootFS, year, "index.md"))
		sections := splitSections(raw)
		if _, found := findSectionIndex(sections, dayHeading); found {
			return yearURL + "#" + dayHeading, true
		}
		// Missing day: route through the insert flow so today's section
		// is spliced in at the right chronological position.
		insertIdx := computeInsertIndex(sections, dayHeading)
		return fmt.Sprintf("%s?edit&insert_before=%d&heading=%s",
			yearURL, insertIdx, url.QueryEscape(dayHeading)), true
	case "this-month":
		return yearURL + "#" + fmt.Sprintf("%s-%s", year, month), true
	case "this-year":
		return yearURL, true
	}
	return "", false
}

// virtualURLRedirect collapses month/day URLs onto the year page. For
// non-edit GETs the target is `/YYYY/#YYYY-MM[-DD]`. For ?edit GETs the
// target is the year-file editor URL (section edit when the section exists,
// otherwise insert_before+heading for new days, whole-year edit for new
// months). Returns ok=false when fsPath is a real folder (preferring the
// real folder over the virtual redirect lets users recover from an
// unfinished migration).
func (d *diaryHandler) virtualURLRedirect(root, fsPath, urlPath string, edit bool) (string, bool) {
	depth, diaryRootFS, diaryRootURL, ok := findDiaryContext(root, fsPath, urlPath)
	if !ok || (depth != 2 && depth != 3) {
		return "", false
	}
	if info, err := os.Stat(fsPath); err == nil && info.IsDir() {
		return "", false
	}

	year, month, day, ok := parseDiaryURLParts(fsPath, depth)
	if !ok {
		return "", false
	}
	yearFS := filepath.Join(diaryRootFS, year)
	yearURL := path.Join(diaryRootURL, year) + "/"

	if !edit {
		anchor := fmt.Sprintf("%s-%s", year, month)
		if depth == 3 {
			anchor = fmt.Sprintf("%s-%s-%s", year, month, day)
		}
		return yearURL + "#" + anchor, true
	}

	raw, _ := os.ReadFile(filepath.Join(yearFS, "index.md"))
	sections := splitSections(raw)

	if depth == 2 {
		target := fmt.Sprintf("%s-%s", year, month)
		if idx, found := findSectionIndex(sections, target); found {
			return fmt.Sprintf("%s?edit&section=%d", yearURL, idx), true
		}
		return yearURL + "?edit", true
	}
	target := fmt.Sprintf("%s-%s-%s", year, month, day)
	if idx, found := findSectionIndex(sections, target); found {
		return fmt.Sprintf("%s?edit&section=%d", yearURL, idx), true
	}
	insertIdx := computeInsertIndex(sections, target)
	return fmt.Sprintf("%s?edit&insert_before=%d&heading=%s",
		yearURL, insertIdx, url.QueryEscape(target)), true
}

// parseDiaryURLParts extracts year/month/day from fsPath based on depth.
// depth=1 returns year only; depth=2 returns year+month; depth=3 returns all.
func parseDiaryURLParts(fsPath string, depth int) (year, month, day string, ok bool) {
	parts := []string{}
	cur := fsPath
	for i := 0; i < depth; i++ {
		parts = append([]string{filepath.Base(cur)}, parts...)
		cur = filepath.Dir(cur)
	}
	switch depth {
	case 1:
		return parts[0], "", "", true
	case 2:
		return parts[0], parts[1], "", true
	case 3:
		return parts[0], parts[1], parts[2], true
	}
	return "", "", "", false
}

func (d *diaryHandler) handle(root, fsPath, urlPath string, _ *http.Request) *specialPage {
	depth, diaryRootFS, diaryRootURL, ok := findDiaryContext(root, fsPath, urlPath)
	if !ok {
		return nil
	}
	widget := computeCalendarWidget(diaryRootFS, diaryRootURL, fsPath, depth)
	if depth == 0 {
		return &specialPage{Widget: widget, SuppressTOC: true}
	}
	year, _, _, ok := parseDiaryURLParts(fsPath, depth)
	if !ok {
		return &specialPage{Widget: widget, SuppressTOC: true}
	}
	if depth == 1 {
		yearFS := filepath.Join(diaryRootFS, year)
		yearURL := path.Join(diaryRootURL, year) + "/"
		content := renderDiaryYear(yearFS, yearURL)
		return &specialPage{
			Content:         content,
			SuppressContent: true,
			SuppressListing: true,
			SuppressTOC:     true,
			Widget:          widget,
		}
	}
	// depth 2/3 only reach here when a real folder exists at the path
	// (unfinished migration). The virtual URL would have been redirected
	// in `redirect()` otherwise. Render the folder normally; just add the
	// calendar widget.
	return &specialPage{Widget: widget, SuppressTOC: true}
}

// findDiaryContext walks up from fsPath toward root looking for a
// .page-settings file with type=diary. Returns the depth of fsPath
// relative to the diary root, the diary root fs path, its URL, and
// whether a diary root was found. depth=0 means fsPath itself is the root.
func findDiaryContext(root, fsPath, urlPath string) (depth int, diaryRootFS, diaryRootURL string, ok bool) {
	currentFS := fsPath
	currentURL := urlPath
	for d := 0; ; d++ {
		s := readPageSettings(currentFS)
		if s != nil && s.Type == "diary" {
			return d, currentFS, currentURL, true
		}
		if currentFS == root {
			break
		}
		parent := filepath.Dir(currentFS)
		if parent == currentFS {
			break
		}
		currentFS = parent
		currentURL = parentURL(currentURL)
	}
	return 0, "", "", false
}

// headingTextRe matches an ATX heading at the start of a section. The
// heading text is everything after the `#`s and the required space, on the
// first line.
var headingTextRe = regexp.MustCompile(`^(#{1,6})\s+([^\n]*)`)

// sectionHeading returns the heading level (1..6) and trimmed text of a
// section produced by splitSections. Returns level=0 for the pre-heading
// section (index 0).
func sectionHeading(section []byte) (level int, text string) {
	m := headingTextRe.FindSubmatch(section)
	if m == nil {
		return 0, ""
	}
	return len(m[1]), strings.TrimSpace(string(m[2]))
}

// findSectionIndex returns the absolute section index whose heading text
// matches target (e.g. "2026-05" or "2026-05-28"). Returns the first match.
func findSectionIndex(sections [][]byte, target string) (int, bool) {
	for i := 1; i < len(sections); i++ {
		_, text := sectionHeading(sections[i])
		if text == target {
			return i, true
		}
	}
	return 0, false
}

// computeInsertIndex returns the section index at which a new date heading
// (target = `YYYY-MM` or `YYYY-MM-DD`) should be spliced in to keep date
// sections chronologically ordered. Only date-format headings participate in
// the comparison; non-date headings (e.g. `## Events` in a year intro) are
// skipped so the new section is placed relative to the surrounding date
// sections, not the intro. Falls back to len(sections) when target is
// greater than every date heading. ISO formatting means string comparison
// is equivalent to chronological order.
func computeInsertIndex(sections [][]byte, target string) int {
	for i := 1; i < len(sections); i++ {
		_, text := sectionHeading(sections[i])
		if !isDateHeading(text) {
			continue
		}
		if text > target {
			return i
		}
	}
	return len(sections)
}

// isDateHeading reports whether text is exactly a `YYYY`, `YYYY-MM`, or
// `YYYY-MM-DD` token. Used by the insert-index search to ignore non-date
// section headings.
func isDateHeading(text string) bool {
	switch len(text) {
	case 4, 7, 10:
	default:
		return false
	}
	if _, err := time.Parse("2006", text[:4]); err != nil {
		return false
	}
	if len(text) >= 7 {
		if text[4] != '-' {
			return false
		}
		if _, err := time.Parse("2006-01", text[:7]); err != nil {
			return false
		}
	}
	if len(text) == 10 {
		if text[7] != '-' {
			return false
		}
		if _, err := time.Parse("2006-01-02", text); err != nil {
			return false
		}
	}
	return true
}

// daysWithEntriesByMonth returns a `month → set[day]` map of `### YYYY-MM-DD`
// sections in the year's index.md. Used by the calendar widget to populate
// all 12 month grids in a single file read.
func daysWithEntriesByMonth(yearFS string, year int) map[int]map[int]bool {
	out := map[int]map[int]bool{}
	raw, err := os.ReadFile(filepath.Join(yearFS, "index.md"))
	if err != nil {
		return out
	}
	yearPrefix := fmt.Sprintf("%d-", year)
	sections := splitSections(raw)
	for i := 1; i < len(sections); i++ {
		level, text := sectionHeading(sections[i])
		if level != 3 || !strings.HasPrefix(text, yearPrefix) || len(text) < 10 {
			continue
		}
		m, err := strconv.Atoi(text[5:7])
		if err != nil || m < 1 || m > 12 {
			continue
		}
		d, err := strconv.Atoi(text[8:10])
		if err != nil || d < 1 || d > 31 {
			continue
		}
		if out[m] == nil {
			out[m] = map[int]bool{}
		}
		out[m][d] = true
	}
	return out
}

type calDay struct {
	Num       int
	URL       string
	HasEntry  bool
	IsToday   bool
	IsCurrent bool
}

type calYear struct {
	Num       int
	URL       string
	IsCurrent bool
}

// calMonthGrid carries everything the template needs to render one month's
// grid plus the caption that links to it. The calendar widget renders all 12
// stacked vertically; JS centers the current month in the rail on load.
type calMonthGrid struct {
	Num       int
	Name      string
	AnchorURL string // "#YYYY-MM" on a year page, full URL otherwise
	Weeks     [][]calDay
}

type calendarData struct {
	DisplayYear  int
	DisplayMonth int
	DiaryURL     string
	Months       []calMonthGrid
	Years        []calYear
}

var diaryCalTmpl = template.Must(template.ParseFS(assets, "assets/diary/calendar.html"))

func computeCalendarWidget(diaryRootFS, diaryRootURL, fsPath string, depth int) template.HTML {
	today := time.Now()
	var displayYear, displayMonth, currentDay, currentMonth int

	switch depth {
	case 0:
		displayYear = today.Year()
		displayMonth = int(today.Month())
	case 1:
		y, err := strconv.Atoi(filepath.Base(fsPath))
		if err != nil {
			return ""
		}
		displayYear = y
		if y == today.Year() {
			displayMonth = int(today.Month())
		} else {
			displayMonth = 1
		}
	case 2:
		m, err := strconv.Atoi(filepath.Base(fsPath))
		if err != nil || m < 1 || m > 12 {
			return ""
		}
		y, err := strconv.Atoi(filepath.Base(filepath.Dir(fsPath)))
		if err != nil {
			return ""
		}
		displayYear = y
		displayMonth = m
	case 3:
		d, err := strconv.Atoi(filepath.Base(fsPath))
		if err != nil || d < 1 || d > 31 {
			return ""
		}
		monthFS := filepath.Dir(fsPath)
		m, err := strconv.Atoi(filepath.Base(monthFS))
		if err != nil || m < 1 || m > 12 {
			return ""
		}
		y, err := strconv.Atoi(filepath.Base(filepath.Dir(monthFS)))
		if err != nil {
			return ""
		}
		displayYear = y
		displayMonth = m
		currentDay = d
		currentMonth = m
	default:
		return ""
	}

	yearFS := filepath.Join(diaryRootFS, fmt.Sprintf("%d", displayYear))
	hasDayEntryByMonth := daysWithEntriesByMonth(yearFS, displayYear)
	yearURL := path.Join(diaryRootURL, fmt.Sprintf("%d", displayYear)) + "/"

	// On a year page, in-year month/day links collapse to anchors so the
	// browser scrolls within the current page instead of navigating away.
	// On the diary root (depth=0), all links remain full URLs.
	pageYear := 0
	if depth >= 1 {
		pageYear = displayYear
	}

	monthAnchor := func(year, month int) string {
		if pageYear == year {
			return fmt.Sprintf("#%d-%02d", year, month)
		}
		return path.Join(diaryRootURL,
			fmt.Sprintf("%d", year),
			fmt.Sprintf("%02d", month)) + "/"
	}
	dayAnchor := func(year, month, day int) string {
		if pageYear == year {
			return fmt.Sprintf("#%d-%02d-%02d", year, month, day)
		}
		return path.Join(diaryRootURL,
			fmt.Sprintf("%d", year),
			fmt.Sprintf("%02d", month),
			fmt.Sprintf("%02d", day)) + "/"
	}

	months := make([]calMonthGrid, 12)
	for m := 1; m <= 12; m++ {
		var cd int
		if m == currentMonth {
			cd = currentDay
		}
		months[m-1] = calMonthGrid{
			Num:       m,
			Name:      germanMonths[time.Month(m)],
			AnchorURL: monthAnchor(displayYear, m),
			Weeks:     buildMonthGrid(displayYear, m, today, cd, hasDayEntryByMonth[m], dayAnchor),
		}
	}

	// Collect all year subdirectories in diary root (descending).
	yearEntries, _ := os.ReadDir(diaryRootFS)
	var years []calYear
	yearSet := map[int]bool{}
	for _, e := range yearEntries {
		if !e.IsDir() {
			continue
		}
		y, err := strconv.Atoi(e.Name())
		if err != nil {
			continue
		}
		yearSet[y] = true
		years = append(years, calYear{
			Num:       y,
			URL:       path.Join(diaryRootURL, e.Name()) + "/",
			IsCurrent: y == displayYear,
		})
	}
	if !yearSet[displayYear] {
		years = append(years, calYear{
			Num:       displayYear,
			URL:       yearURL,
			IsCurrent: true,
		})
	}
	sort.Slice(years, func(i, j int) bool { return years[i].Num > years[j].Num })

	data := calendarData{
		DisplayYear:  displayYear,
		DisplayMonth: displayMonth,
		DiaryURL:     diaryRootURL,
		Months:       months,
		Years:        years,
	}

	var buf bytes.Buffer
	if err := diaryCalTmpl.Execute(&buf, data); err != nil {
		log.Printf("diary calendar template: %v", err)
		return ""
	}
	return template.HTML(buf.String())
}

// buildMonthGrid renders one month's day cells as a Monday-first week grid.
// hasDayEntry maps day-of-month → has a diary entry. dayAnchor produces the
// in-page anchor (or full URL when crossing pages); empty days link to the
// same anchor — every day exists on the year page as either a real or
// virtual section, so navigation is enough. Page creation happens via the
// [edit] button on the heading itself.
func buildMonthGrid(year, month int, today time.Time, currentDay int, hasDayEntry map[int]bool, dayAnchor func(int, int, int) string) [][]calDay {
	firstDay := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC)
	startOffset := int(firstDay.Weekday()+6) % 7
	daysInMonth := time.Date(year, time.Month(month)+1, 0, 0, 0, 0, 0, time.UTC).Day()

	var weeks [][]calDay
	week := make([]calDay, 7)
	col := startOffset
	for d := 1; d <= daysInMonth; d++ {
		cell := calDay{
			Num:      d,
			HasEntry: hasDayEntry[d],
			URL:      dayAnchor(year, month, d),
		}
		cell.IsCurrent = currentDay > 0 && d == currentDay
		cell.IsToday = d == today.Day() &&
			time.Month(month) == today.Month() &&
			year == today.Year()
		week[col] = cell
		col++
		if col == 7 {
			weeks = append(weeks, week)
			week = make([]calDay, 7)
			col = 0
		}
	}
	if col > 0 {
		weeks = append(weeks, week)
	}
	return weeks
}

// diaryPhoto is a photo file whose name starts with a YYYY-MM-DD date prefix.
type diaryPhoto struct {
	Date     time.Time
	Name     string
	URL      string
	ThumbURL string
}

// diarySection is one rendered section of the diary content (year, month, or
// day). Edit URLs point back into the year file's section editor so per-day
// editing works from any slice page.
type diarySection struct {
	Level   int           // 1, 2, or 3
	ID      string        // anchor id (e.g. "2026-05-28")
	Heading string        // displayed heading text
	EditURL string        // year-file section edit URL ("" = no edit button)
	Body    template.HTML // rendered markdown body (excludes the heading line)
	Photos  []diaryPhoto
}

type diaryContentData struct {
	Sections []diarySection
}

var germanMonths = map[time.Month]string{
	time.January:   "Januar",
	time.February:  "Februar",
	time.March:     "März",
	time.April:     "April",
	time.May:       "Mai",
	time.June:      "Juni",
	time.July:      "Juli",
	time.August:    "August",
	time.September: "September",
	time.October:   "Oktober",
	time.November:  "November",
	time.December:  "Dezember",
}

var photoExts = map[string]bool{
	".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true,
}

// yearPhotos returns all photos in yearFsPath whose filename starts with
// a YYYY-MM-DD date prefix.
func yearPhotos(yearFsPath, yearURLPath string) []diaryPhoto {
	entries, err := os.ReadDir(yearFsPath)
	if err != nil {
		return nil
	}
	var photos []diaryPhoto
	for _, e := range entries {
		if e.IsDir() {
			continue
		}
		name := e.Name()
		if !photoExts[strings.ToLower(filepath.Ext(name))] {
			continue
		}
		if len(name) < 10 {
			continue
		}
		t, err := time.Parse("2006-01-02", name[:10])
		if err != nil {
			continue
		}
		photoURL := path.Join(yearURLPath, url.PathEscape(name))
		thumb := photoURL
		if hasThumbnail(name) {
			thumb = thumbURL(photoURL, 300)
		}
		photos = append(photos, diaryPhoto{
			Date:     t,
			Name:     name,
			URL:      photoURL,
			ThumbURL: thumb,
		})
	}
	return photos
}

var diaryContentTmpl = template.Must(template.ParseFS(assets, "assets/diary/content.html"))

// sectionBody strips the first heading line and returns the rendered body.
// Used so the diary template can emit the heading explicitly (with edit URL)
// while still rendering the section body via goldmark.
func sectionBody(section []byte) template.HTML {
	body := stripFirstHeading(section)
	if len(bytes.TrimSpace(body)) == 0 {
		return ""
	}
	return renderMarkdown(body)
}

// renderDiaryYear renders the year page: every section from the year file
// (with photos attached to `### YYYY-MM-DD` headings) plus virtual entries
// for every month/day slot the file doesn't yet contain.
func renderDiaryYear(yearFS, yearURL string) template.HTML {
	year, err := strconv.Atoi(filepath.Base(yearFS))
	if err != nil {
		return ""
	}
	raw, _ := os.ReadFile(filepath.Join(yearFS, "index.md"))
	sections := splitSections(raw)
	photos := yearPhotos(yearFS, yearURL)

	out := buildFileSections(sections, photos, yearURL)
	out = appendVirtualEntries(out, sections, photos, year, yearURL)
	return renderDiaryContent(out)
}

// buildFileSections converts the year file's sections (skipping the
// pre-heading section 0) into rendered diarySection entries. Photos are
// attached to level-3 headings whose text parses as `YYYY-MM-DD`.
func buildFileSections(sections [][]byte, photos []diaryPhoto, yearURL string) []diarySection {
	var out []diarySection
	for i := 1; i < len(sections); i++ {
		level, text := sectionHeading(sections[i])
		if level == 0 {
			continue
		}
		sec := diarySection{
			Level:   level,
			ID:      text,
			Heading: text,
			EditURL: fmt.Sprintf("%s?edit&section=%d", yearURL, i),
			Body:    sectionBody(sections[i]),
		}
		if level == 3 {
			if y, m, d, ok := parseISODate(text); ok {
				sec.Photos = filterPhotos(photos, y, m, d)
			}
		}
		out = append(out, sec)
	}
	return out
}

// appendVirtualEntries inserts virtual month and day sections for every
// `## YYYY-MM` / `### YYYY-MM-DD` slot in `year` that lacks a real section.
// Virtual day sections carry photos when present. Each virtual entry's
// EditURL routes through the insert-before flow so clicking [edit] splices
// the section into the year file at the right chronological position.
//
// Scope: past years get all 12 months / 365(6) days; the current year stops
// at today; future years are returned unchanged.
//
// Interleave: real date sections (`## YYYY-MM`, `### YYYY-MM-DD`) keep their
// document position; virtual entries are spliced in lexicographic ID order
// before the next real date section. Non-date headings (e.g. `## Events` →
// `### Festival` in a year intro) are left where the user wrote them.
func appendVirtualEntries(existing []diarySection, sections [][]byte, photos []diaryPhoto, year int, yearURL string) []diarySection {
	today := time.Now()
	if year > today.Year() {
		return existing
	}

	coveredMonth := map[string]bool{}
	coveredDay := map[string]bool{}
	for _, s := range existing {
		switch {
		case s.Level == 2 && len(s.ID) == 7 && isDateHeading(s.ID):
			coveredMonth[s.ID] = true
		case s.Level == 3 && len(s.ID) == 10 && isDateHeading(s.ID):
			coveredDay[s.ID] = true
		}
	}

	photoByDay := map[string][]diaryPhoto{}
	for _, p := range photos {
		if p.Date.Year() != year {
			continue
		}
		photoByDay[p.Date.Format("2006-01-02")] = append(photoByDay[p.Date.Format("2006-01-02")], p)
	}

	lastDay := time.Date(year, time.December, 31, 0, 0, 0, 0, time.UTC)
	if year == today.Year() {
		lastDay = time.Date(year, today.Month(), today.Day(), 0, 0, 0, 0, time.UTC)
	}

	var virtual []diarySection
	for d := time.Date(year, time.January, 1, 0, 0, 0, 0, time.UTC); !d.After(lastDay); d = d.AddDate(0, 0, 1) {
		if d.Day() == 1 {
			monthID := d.Format("2006-01")
			if !coveredMonth[monthID] {
				idx := computeInsertIndex(sections, monthID)
				virtual = append(virtual, diarySection{
					Level:   2,
					ID:      monthID,
					Heading: monthID,
					EditURL: fmt.Sprintf("%s?edit&insert_before=%d&heading=%s&level=%s",
						yearURL, idx, url.QueryEscape(monthID), url.QueryEscape("##")),
				})
			}
		}
		dayID := d.Format("2006-01-02")
		if !coveredDay[dayID] {
			idx := computeInsertIndex(sections, dayID)
			virtual = append(virtual, diarySection{
				Level:   3,
				ID:      dayID,
				Heading: dayID,
				EditURL: fmt.Sprintf("%s?edit&insert_before=%d&heading=%s",
					yearURL, idx, url.QueryEscape(dayID)),
				Photos: photoByDay[dayID],
			})
		}
	}
	if len(virtual) == 0 {
		return existing
	}

	out := make([]diarySection, 0, len(existing)+len(virtual))
	vi := 0
	for _, s := range existing {
		if isRealDateSection(s) {
			for vi < len(virtual) && virtual[vi].ID < s.ID {
				out = append(out, virtual[vi])
				vi++
			}
		}
		out = append(out, s)
	}
	for vi < len(virtual) {
		out = append(out, virtual[vi])
		vi++
	}
	return out
}

// isRealDateSection reports whether a rendered diarySection is one of the
// date-headed slots (`## YYYY-MM` or `### YYYY-MM-DD`) the virtual-entry
// interleave sorts against.
func isRealDateSection(s diarySection) bool {
	switch s.Level {
	case 2:
		return len(s.ID) == 7 && isDateHeading(s.ID)
	case 3:
		return len(s.ID) == 10 && isDateHeading(s.ID)
	}
	return false
}

// parseISODate parses "YYYY-MM-DD" leading characters of s. Returns ok=false
// if the prefix does not match.
func parseISODate(s string) (year, month, day int, ok bool) {
	if len(s) < 10 {
		return 0, 0, 0, false
	}
	t, err := time.Parse("2006-01-02", s[:10])
	if err != nil {
		return 0, 0, 0, false
	}
	return t.Year(), int(t.Month()), t.Day(), true
}

func filterPhotos(photos []diaryPhoto, year, month, day int) []diaryPhoto {
	var out []diaryPhoto
	for _, p := range photos {
		if p.Date.Year() == year && int(p.Date.Month()) == month && p.Date.Day() == day {
			out = append(out, p)
		}
	}
	return out
}

func renderDiaryContent(sections []diarySection) template.HTML {
	var buf bytes.Buffer
	if err := diaryContentTmpl.Execute(&buf, diaryContentData{Sections: sections}); err != nil {
		log.Printf("diary content template: %v", err)
		return ""
	}
	return template.HTML(buf.String())
}
