package diff

import (
	"bytes"
	"encoding/json"
	"fmt"
	"html"
	"html/template"
	"maps"
	"os"
	"path/filepath"
	"slices"
	"strings"

	"github.com/apex/log"
	mcmd "github.com/blacktop/ipsw/internal/commands/macho"
	"github.com/blacktop/ipsw/internal/utils"
	"github.com/gomarkdown/markdown"
	mdhtml "github.com/gomarkdown/markdown/html"
)

// {{ range $index, $element := .Ents }}
// 		- [{{ $element }}](#{{ $element | urlize }})
// {{ end -}}

const diffMarkdownTemplate = `
- [{{ .Title }}](#{{ .Title | slug }})
	- [Inputs](#inputs)
	- [Kernel](#kernel)
		- [Version](#version)
{{- if .Kexts }}
		- [Kexts](#kexts)
{{- if .Kexts.New }}
			- [🆕 NEW ({{ len .Kexts.New }})](#-new)
{{- end }}
{{- if .Kexts.Removed }}
			- [❌ Removed ({{ len .Kexts.Removed }})](#-removed)
{{- end }}
{{- if .Kexts.Updated }}
			- [⬆️ Updated ({{ len .Kexts.Updated }})](#️-updated)
{{- end }}
{{- end }}
{{- if .Machos }}
	- [Machos](#machos)
{{- end }}
{{- if .Ents }}
		- [🔑 Entitlements](#entitlements)
{{- end }}
{{- if .Sandbox }}
		- [Sandbox Profiles](#sandbox-profiles)
{{- end }}
	- [DSC](#dsc)
		- [WebKit](#webkit)
		- [Dylibs](#dylibs)
{{- if .Dylibs }}
{{- if .Dylibs.New }}
		  - [🆕 NEW ({{ len .Dylibs.New }})](#-new-2)
{{- end }}
{{- if .Dylibs.Removed }}
		  - [❌ Removed ({{ len .Dylibs.Removed }})](#️-removed-2)
{{- end }}
{{- if .Dylibs.Updated }}
		  - [⬆️ Updated ({{ len .Dylibs.Updated }})](#️-updated-2)
{{- end }}
{{- end }}


# {{ .Title }}

## Inputs

- {{ .Old.IPSWPath | base }}
- {{ .New.IPSWPath | base }}

## Kernel
{{ if .Old.Kernel.Version }}
### Version

| iOS                                     | Version                                 | Build                                | Date                                  |
| :-------------------------------------- | :-------------------------------------- | :----------------------------------- | :------------------------------------ |
| {{ .Old.Version }} *({{ .Old.Build }})* | {{ .Old.Kernel.Version.KernelVersion.Darwin }} | {{ .Old.Kernel.Version.KernelVersion.XNU }} | {{ .Old.Kernel.Version.KernelVersion.Date.Format "Mon, 02Jan2006 15:04:05 MST" }} |
| {{ .New.Version }} *({{ .New.Build }})* | {{ .New.Kernel.Version.KernelVersion.Darwin }} | {{ .New.Kernel.Version.KernelVersion.XNU }} | {{ .New.Kernel.Version.KernelVersion.Date.Format "Mon, 02Jan2006 15:04:05 MST" }} |
{{ end -}}
{{ with .SameKernelNote }}
{{ . }}
{{ end -}}
{{ if .Kexts }}
### Kexts
{{ if .Kexts.New }}
### 🆕 NEW
{{ range .Kexts.New }}
- {{ . | code }}
{{ end }}
{{ end -}}
{{- if .Kexts.Removed }}
### ❌ Removed
{{ range .Kexts.Removed }}
- {{ . | code }}
{{ end }}
{{ end -}}
{{- if .Kexts.Updated }}
### ⬆️ Updated
<details>
  <summary><i>View Updated</i></summary>

{{ range $key, $value := .Kexts.Updated }}
#### {{ $key | base }}
> {{ $key | code }}
{{ $value | noescape }}
{{ end }}

</details>
{{ end -}}
{{ end -}}
{{ if .KDKs }}
## KDKs
- {{ .Old.KDK | code}}
- {{ .New.KDK | code}}

{{ .KDKs | noescape }}
{{ end -}}

{{- if .Machos }}
## MachOs
{{- range machoVolumes .Machos }}
### {{ .Name }}
{{ if .Diff.New }}
#### 🆕 NEW
{{ range .Diff.New }}
- {{ . | code }}
{{ end }}
{{ end -}}
{{- if .Diff.Removed }}
#### ❌ Removed
{{ range .Diff.Removed }}
- {{ . | code }}
{{ end }}
{{ end -}}
{{- if .Diff.Updated }}
#### ⬆️ Updated
<details>
  <summary><i>View Updated</i></summary>

{{ range $key, $value := .Diff.Updated }}
##### {{ $key | base }}
> {{ $key | code }}
{{ $value | noescape }}
{{ end }}

</details>
{{ end -}}
{{- end }}
{{- end }}
{{ if .Ents }}
### 🔑 Entitlements
{{- range entsVolumes .Ents }}
#### {{ .Name }}
<details>
  <summary><i>View Entitlements</i></summary>

  {{ .Content | noescape }}

</details>
{{- end }}
{{ end -}}

{{ if .Sandbox }}
## Sandbox Profiles
{{ .Sandbox | noescape }}
{{ end -}}

{{- if .Firmwares }}
## Firmwares
{{ if .Firmwares.New }}
### 🆕 NEW
{{ range .Firmwares.New }}
- {{ . | code }}
{{ end }}
{{ end -}}
{{- if .Firmwares.Removed }}
### ❌ Removed
{{ range .Firmwares.Removed }}
- {{ . | code }}
{{ end }}
{{ end -}}
{{- if .Firmwares.Updated }}
### ⬆️ Updated
<details>
  <summary><i>View Updated</i></summary>

{{ range $key, $value := .Firmwares.Updated }}
#### {{ $key | base }}
> {{ $key | code }}
{{ $value | noescape }}
{{ end }}

</details>
{{ end -}}
{{ end -}}

{{ if .Launchd }}
## launchd Config
{{ .Launchd | noescape }}
{{ end }}

{{ if .Localizations }}
## Localizations
{{- range plistVolumes .Localizations }}
### {{ .Name }}
{{ if .Diff.New }}
#### 🆕 NEW
{{ range $key, $value := .Diff.New }}
##### {{ $key | locbase }}
> {{ $key | code }}
~~~text
{{ $value }}
~~~
{{ end }}
{{ end -}}
{{- if .Diff.Removed }}
#### ❌ Removed
{{ range .Diff.Removed }}
- {{ . | code }}
{{ end }}
{{ end -}}
{{- if .Diff.Updated }}
#### ⬆️ Updated
<details>
  <summary><i>View Updated</i></summary>

{{ range $key, $value := .Diff.Updated }}
##### {{ $key | locbase }}
> {{ $key | code }}
{{ $value | noescape }}
{{ end }}

</details>
{{ end -}}
{{- end }}
{{- end }}

## DSC

{{ if .Old.Webkit }}
### WebKit

| iOS                                     | Version           |
| :-------------------------------------- | :---------------- |
| {{ .Old.Version }} *({{ .Old.Build }})* | {{ .Old.Webkit }} |
| {{ .New.Version }} *({{ .New.Build }})* | {{ .New.Webkit }} |
{{ end -}}
{{- if .Dylibs }}
### Dylibs
{{ if .Dylibs.New }}
#### 🆕 NEW
{{ range .Dylibs.New }}
- {{ . | code }}
{{ end }}
{{ end -}}
{{- if .Dylibs.Removed }}
#### ❌ Removed
{{ range .Dylibs.Removed }}
- {{ . | code }}
{{ end }}
{{ end -}}
{{- if .Dylibs.Updated }}
#### ⬆️ Updated
<details>
  <summary><i>View Updated</i></summary>

{{ range $key, $value := .Dylibs.Updated }}
##### {{ $key | base }}
> {{ $key | code }}
{{ $value | noescape }}
{{ end }}

</details>
{{ end -}}
{{ end -}}
`

var diffMDTmpl = template.Must(template.New("diff").
	Funcs(template.FuncMap{
		"noescape": func(value any) template.HTML {
			return template.HTML(fmt.Sprint(value))
		},
		"base": func(value string) template.HTML {
			return template.HTML(fmt.Sprintf("`%s`", filepath.Base(value)))
		},
		"locbase": func(value string) template.HTML {
			return template.HTML(fmt.Sprintf("`%s`", localizationDisplayName(value)))
		},
		"code": func(value string) template.HTML {
			return template.HTML(fmt.Sprintf("`%s`", value))
		},
		"slug": func(value string) string {
			return utils.Slugify(value)
		},
		"machoVolumes": templateMachoVolumes,
		"plistVolumes": templatePlistVolumes,
		"entsVolumes":  templateEntsVolumes,
	}).
	Parse(diffMarkdownTemplate))

// templateMachoVolumes returns the sorted-and-filtered per-volume MachOs
// entries for use inside Markdown/HTML templates. It mirrors the slice that
// the HTML page renderer pre-builds (convertMachoVolumeDiff) so both
// renderers walk the same ordered structure.
func templateMachoVolumes(m map[string]*mcmd.MachoDiff) []templateMachoVolume {
	if len(m) == 0 {
		return nil
	}
	out := make([]templateMachoVolume, 0, len(m))
	for _, vol := range sortedVolumeKeys(m) {
		md := m[vol]
		if !machoDiffHasContent(md) {
			continue
		}
		out = append(out, templateMachoVolume{Name: vol, Diff: md})
	}
	return out
}

type templateMachoVolume struct {
	Name string
	Diff *mcmd.MachoDiff
}

func templatePlistVolumes(m map[string]*PlistDiff) []templatePlistVolume {
	if len(m) == 0 {
		return nil
	}
	out := make([]templatePlistVolume, 0, len(m))
	for _, vol := range sortedVolumeKeys(m) {
		pd := m[vol]
		if !plistDiffHasContent(pd) {
			continue
		}
		out = append(out, templatePlistVolume{Name: vol, Diff: pd})
	}
	return out
}

type templatePlistVolume struct {
	Name string
	Diff *PlistDiff
}

func templateEntsVolumes(m map[string]string) []templateEntsVolume {
	if len(m) == 0 {
		return nil
	}
	keys := make([]string, 0, len(m))
	for k := range m {
		keys = append(keys, k)
	}
	keys = sortVolumeNames(keys)
	out := make([]templateEntsVolume, 0, len(keys))
	for _, vol := range keys {
		rendered := m[vol]
		if !entitlementsDiffHasContent(rendered) {
			continue
		}
		out = append(out, templateEntsVolume{Name: vol, Content: rendered})
	}
	return out
}

type templateEntsVolume struct {
	Name    string
	Content string
}

// htmlTaskFuncs is the FuncMap shared between the outer page template and
// the per-task sub-templates that render in-scope sections.
var htmlTaskFuncs = template.FuncMap{
	"dict": func(pairs ...any) map[string]any {
		m := make(map[string]any, len(pairs)/2)
		for i := 0; i < len(pairs)-1; i += 2 {
			m[pairs[i].(string)] = pairs[i+1]
		}
		return m
	},
	"slug": utils.Slugify,
}

var diffHTMLTmpl = template.Must(template.New("diff-html-page").
	Funcs(htmlTaskFuncs).
	Parse(diffHTMLPageTemplate))

// htmlTaskPartials defines the shared sub-templates that per-task HTML
// renderers compose into. machoDiffSection and plistDiffSection are the
// same blocks the outer page template uses, factored so per-task templates
// can produce byte-identical output without duplicating their bodies.
const htmlTaskPartials = `
{{ define "machoDiffSection" }}
{{- if .Diff.New }}
<h4 id="{{ .Prefix }}-new">New</h4>
<ul>{{ range .Diff.New }}<li><code>{{ . }}</code></li>{{ end }}</ul>
{{- end }}
{{- if .Diff.Removed }}
<h4 id="{{ .Prefix }}-removed">Removed</h4>
<ul>{{ range .Diff.Removed }}<li><code>{{ . }}</code></li>{{ end }}</ul>
{{- end }}
{{- if .Diff.Updated }}
<h4 id="{{ .Prefix }}-updated">Updated</h4>
<details>
  <summary>View Updated ({{ len .Diff.Updated }})</summary>
  {{- range .Diff.Updated }}
  <div class="diff-entry">
    <h5>{{ .Name }}</h5>
    <div class="diff-entry-path"><code>{{ .Path }}</code></div>
    {{ .Diff }}
  </div>
  {{- end }}
</details>
{{- end }}
{{ end }}

{{ define "plistDiffSection" }}
{{- if .Diff.New }}
<h4 id="{{ .Prefix }}-new">New</h4>
<details>
  <summary>View New ({{ len .Diff.New }})</summary>
  {{- range .Diff.New }}
  <div class="diff-entry">
    <h5>{{ .Name }}</h5>
    <div class="diff-entry-path"><code>{{ .Path }}</code></div>
    {{ .Content }}
  </div>
  {{- end }}
</details>
{{- end }}
{{- if .Diff.Removed }}
<h4 id="{{ .Prefix }}-removed">Removed</h4>
<ul>{{ range .Diff.Removed }}<li><code>{{ . }}</code></li>{{ end }}</ul>
{{- end }}
{{- if .Diff.Updated }}
<h4 id="{{ .Prefix }}-updated">Updated</h4>
<details>
  <summary>View Updated ({{ len .Diff.Updated }})</summary>
  {{- range .Diff.Updated }}
  <div class="diff-entry">
    <h5>{{ .Name }}</h5>
    <div class="diff-entry-path"><code>{{ .Path }}</code></div>
    {{ .Content }}
  </div>
  {{- end }}
</details>
{{- end }}
{{ end }}
`

// newHTMLTaskTemplate parses body in a new template that also has the
// shared partials registered, so per-task renderers can call
// {{ template "machoDiffSection" ... }} and {{ template "plistDiffSection" ... }}.
func newHTMLTaskTemplate(name, body string) (*template.Template, error) {
	tmpl := template.New(name).Funcs(htmlTaskFuncs)
	if _, err := tmpl.Parse(htmlTaskPartials); err != nil {
		return nil, err
	}
	if _, err := tmpl.Parse(body); err != nil {
		return nil, err
	}
	return tmpl, nil
}

// executeHTMLTaskTemplate renders body (after composing it with the shared
// partials) against data and returns the result as template.HTML.
func executeHTMLTaskTemplate(name, body string, data any) (template.HTML, error) {
	tmpl, err := newHTMLTaskTemplate(name, body)
	if err != nil {
		return "", err
	}
	var buf bytes.Buffer
	if err := tmpl.Execute(&buf, data); err != nil {
		return "", err
	}
	return template.HTML(buf.String()), nil
}

func (d *Diff) String() string {
	var buf bytes.Buffer
	if err := diffMDTmpl.Execute(&buf, d); err != nil {
		return fmt.Errorf("failed to execute diff template: %s", err).Error()
	}
	return buf.String()
}

// ToJSON saves the diff as a JSON file
func (d *Diff) ToJSON() error {
	report, err := buildReport(d)
	if err != nil {
		return err
	}
	dat, err := json.MarshalIndent(report, "", "  ")
	if err != nil {
		return err
	}

	if len(d.conf.Output) > 0 {
		if err := os.MkdirAll(d.conf.Output, 0755); err != nil {
			return err
		}
		fname := filepath.Join(d.conf.Output, d.TitleToFilename()+".json")
		log.Infof("Creating JSON diff file: %s", fname)
		return os.WriteFile(fname, dat, 0644)
	}

	fmt.Println(string(dat))

	return nil
}

type diffHTMLPageData struct {
	Title      string
	OldInput   string
	NewInput   string
	OldVersion string
	OldBuild   string
	NewVersion string
	NewBuild   string

	HasKernelVersion bool
	OldKernelDarwin  string
	OldKernelXNU     string
	OldKernelDate    string
	NewKernelDarwin  string
	NewKernelXNU     string
	NewKernelDate    string
	// SameKernelNote is [sameKernelNote] rendered to HTML (empty when the
	// kernelcache changed), so a Kernel section with no KEXT diff is never left
	// unexplained. Sourced from Diff.SameKernelNote like every other renderer.
	SameKernelNote template.HTML

	// TOC-only converted views: every in-scope task (mount-based or
	// top-level) keeps its sidebar TOC entry while the section bodies
	// themselves render via the per-task HTML fragments below. KDKs and
	// Sandbox are kept as plain template.HTML / string so the TOC's
	// `{{- if .KDKs }}` / `{{- if .Sandbox }}` truthiness checks keep
	// working without re-marshalling task fragments.
	Kexts         *htmlMachoDiff
	KDKs          template.HTML
	Sandbox       template.HTML
	Firmwares     *htmlMachoDiff
	IBoot         *htmlIBootDiff
	Machos        []htmlMachoVolume
	Ents          []htmlEntsVolume
	Features      []htmlPlistVolume
	Files         *htmlFileDiff
	Localizations []htmlPlistVolume

	// Per-task body fragments for both the mount-based (kexts/kdks/...)
	// and top-level (kexts/kdks/firmwares/iboot/sandbox) sections. Each
	// is produced by calling the owning task's HTML() method; if Empty()
	// is true the section emits no heading or body.
	KextsFragment         htmlSectionFragment
	KDKsFragment          htmlSectionFragment
	MachosFragment        htmlSectionFragment
	EntsFragment          htmlSectionFragment
	FirmwaresFragment     htmlSectionFragment
	IBootFragment         htmlSectionFragment
	LaunchdFragment       htmlSectionFragment
	SandboxFragment       htmlSectionFragment
	FilesFragment         htmlSectionFragment
	LocalizationsFragment htmlSectionFragment
	FeaturesFragment      htmlSectionFragment

	OldWebkit string
	NewWebkit string
	Dylibs    *htmlMachoDiff
}

// htmlSectionFragment is the outer-template view of a per-task HTML fragment.
// renderHTML builds one of these per in-scope task by combining the task's
// HTMLFragment (Heading + Body) with the section's stable anchor ID.
type htmlSectionFragment struct {
	Anchor  string
	Heading string
	Body    template.HTML
	Empty   bool
}

type htmlMachoDiff struct {
	New     []string
	Removed []string
	Updated []htmlUpdatedEntry
}

// htmlMachoVolume groups a single volume's MachoDiff for per-volume HTML
// rendering. Used by the d.Machos shape (cross-DMG).
type htmlMachoVolume struct {
	Name string
	Diff *htmlMachoDiff
}

type htmlUpdatedEntry struct {
	Name string
	Path string
	Diff template.HTML
}

type htmlNamedList struct {
	Name  string
	Items []string
}

type htmlIBootDiff struct {
	Versions []string
	New      []htmlNamedList
	Removed  []htmlNamedList
}

type htmlPlistEntry struct {
	Name    string
	Path    string
	Content template.HTML
}

type htmlPlistDiff struct {
	New     []htmlPlistEntry
	Removed []string
	Updated []htmlPlistEntry
}

// htmlPlistVolume groups a single volume's PlistDiff for per-volume HTML
// rendering (used by d.Features and d.Localizations).
type htmlPlistVolume struct {
	Name string
	Diff *htmlPlistDiff
}

// htmlEntsVolume groups a single volume's rendered entitlements diff for
// per-volume HTML rendering.
type htmlEntsVolume struct {
	Name    string
	Content template.HTML
}

func convertEntitlementsDiff(m map[string]string) []htmlEntsVolume {
	if len(m) == 0 {
		return nil
	}
	keys := make([]string, 0, len(m))
	for k := range m {
		keys = append(keys, k)
	}
	keys = sortVolumeNames(keys)
	out := make([]htmlEntsVolume, 0, len(keys))
	for _, vol := range keys {
		rendered := m[vol]
		if !entitlementsDiffHasContent(rendered) {
			continue
		}
		out = append(out, htmlEntsVolume{
			Name:    vol,
			Content: renderMarkdownFragment(rendered),
		})
	}
	return out
}

type htmlFileDiff struct {
	New     []htmlNamedList
	Removed []htmlNamedList
}

const diffHTMLPageTemplate = `<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta name="color-scheme" content="light dark">
  <title>{{ .Title }}</title>
  <style>
    :root {
      --radius: 18px;
      --background: oklch(0.985 0.004 247.858);
      --foreground: oklch(0.205 0.01 257.417);
      --card: oklch(0.999 0 0);
      --card-foreground: oklch(0.205 0.01 257.417);
      --muted: oklch(0.968 0.006 247.896);
      --muted-foreground: oklch(0.473 0.013 255.508);
      --primary: oklch(0.598 0.112 221.723);
      --primary-foreground: oklch(0.985 0 0);
      --accent: oklch(0.929 0.03 214.91);
      --accent-foreground: oklch(0.278 0.033 256.848);
      --border: oklch(0.923 0.01 252.096);
      --shadow-soft: 0 24px 60px -32px oklch(0.188 0.012 257.417 / 0.28);
      --shadow-panel: 0 18px 40px -28px oklch(0.188 0.012 257.417 / 0.22);
      --code-bg: oklch(0.25 0.014 253.1);
      --code-fg: oklch(0.96 0.004 247.858);
      --diff-add: oklch(0.30 0.10 145);
      --diff-add-bg: oklch(0.96 0.03 145);
      --diff-del: oklch(0.38 0.10 25);
      --diff-del-bg: oklch(0.96 0.03 25);
      --diff-mod: oklch(0.40 0.10 75);
      --diff-mod-bg: oklch(0.96 0.03 75);
      --hero-wash: radial-gradient(circle at top left, oklch(0.96 0.04 215 / 0.9), transparent 38%),
        radial-gradient(circle at top right, oklch(0.92 0.025 250 / 0.75), transparent 34%),
        linear-gradient(135deg, oklch(0.995 0.002 247.858), oklch(0.975 0.01 247.896));
    }

    @media (prefers-color-scheme: dark) {
      :root {
        --background: oklch(0.14 0.012 257.417);
        --foreground: oklch(0.96 0.004 247.858);
        --card: oklch(0.19 0.014 255.508);
        --card-foreground: oklch(0.96 0.004 247.858);
        --muted: oklch(0.24 0.012 255.508);
        --muted-foreground: oklch(0.7 0.013 255.508);
        --primary: oklch(0.72 0.104 221.723);
        --primary-foreground: oklch(0.205 0.01 257.417);
        --accent: oklch(0.28 0.02 224.12);
        --accent-foreground: oklch(0.96 0.004 247.858);
        --border: oklch(0.30 0.012 255.508);
        --shadow-soft: 0 30px 80px -40px oklch(0.02 0 0 / 0.65);
        --shadow-panel: 0 22px 46px -32px oklch(0.02 0 0 / 0.56);
        --code-bg: oklch(0.15 0.01 255.508);
        --code-fg: oklch(0.92 0.004 247.858);
        --diff-add: oklch(0.72 0.16 145);
        --diff-add-bg: oklch(0.22 0.04 145);
        --diff-del: oklch(0.72 0.16 25);
        --diff-del-bg: oklch(0.22 0.04 25);
        --diff-mod: oklch(0.76 0.12 75);
        --diff-mod-bg: oklch(0.22 0.03 75);
        --hero-wash: radial-gradient(circle at top left, oklch(0.26 0.036 215 / 0.55), transparent 38%),
          radial-gradient(circle at top right, oklch(0.22 0.02 250 / 0.5), transparent 34%),
          linear-gradient(135deg, oklch(0.18 0.012 257.417), oklch(0.15 0.012 255.508));
      }
    }

    * { box-sizing: border-box; }
    html { scroll-behavior: smooth; }

    body {
      margin: 0;
      min-height: 100vh;
      color: var(--foreground);
      background:
        radial-gradient(circle at top, oklch(0.9 0.02 215 / 0.18), transparent 30%),
        linear-gradient(180deg, color-mix(in oklab, var(--background) 92%, white 8%), var(--background));
      font-family: "Avenir Next", "Aptos", "Segoe UI", sans-serif;
      line-height: 1.65;
    }

    body::before {
      content: "";
      position: fixed;
      inset: 0;
      pointer-events: none;
      background-image: linear-gradient(to right, color-mix(in oklab, var(--border) 40%, transparent) 1px, transparent 1px),
        linear-gradient(to bottom, color-mix(in oklab, var(--border) 35%, transparent) 1px, transparent 1px);
      background-size: 32px 32px;
      mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.35), transparent 75%);
      opacity: 0.28;
    }

    a { color: color-mix(in oklab, var(--primary) 82%, var(--foreground) 18%); text-decoration: none; }
    a:hover { color: var(--primary); }
    code, pre, kbd { font-family: "IBM Plex Mono", "SFMono-Regular", "SF Mono", Consolas, monospace; }

    .report-shell { position: relative; max-width: 1500px; margin: 0 auto; padding: 28px 22px 72px; }

    .panel {
      background: color-mix(in oklab, var(--card) 94%, transparent 6%);
      color: var(--card-foreground);
      border: 1px solid color-mix(in oklab, var(--border) 90%, transparent 10%);
      border-radius: calc(var(--radius) + 2px);
      box-shadow: var(--shadow-panel);
      backdrop-filter: blur(16px);
    }

    .report-hero { position: relative; overflow: hidden; margin-bottom: 24px; padding: 28px; background: var(--hero-wash); }

    .report-hero::after {
      content: "";
      position: absolute;
      inset: auto -10% -35% auto;
      width: 320px; height: 320px;
      border-radius: 50%;
      background: radial-gradient(circle, color-mix(in oklab, var(--primary) 32%, transparent 68%) 0%, transparent 72%);
      opacity: 0.9;
      pointer-events: none;
    }

    .hero-topline {
      display: inline-flex; align-items: center; gap: 10px;
      margin-bottom: 16px; padding: 8px 12px;
      border-radius: 999px;
      background: color-mix(in oklab, var(--card) 80%, transparent 20%);
      border: 1px solid color-mix(in oklab, var(--border) 80%, transparent 20%);
      color: var(--muted-foreground);
      font-size: 0.78rem; letter-spacing: 0.14em; text-transform: uppercase;
    }

    .hero-grid {
      position: relative; z-index: 1;
      display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(280px, 1fr);
      gap: 18px; align-items: start;
    }

    .hero-title {
      margin: 0;
      font-family: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", serif;
      font-size: clamp(2.2rem, 4vw, 4rem);
      line-height: 1.02; letter-spacing: -0.04em;
      text-wrap: balance;
    }

    .hero-subtitle { max-width: 62ch; margin: 14px 0 4px; color: var(--muted-foreground); font-size: 1rem; }

    .hero-meta { display: grid; gap: 12px; }
    .meta-card {
      padding: 16px 18px; border-radius: calc(var(--radius) - 4px);
      background: color-mix(in oklab, var(--card) 82%, transparent 18%);
      border: 1px solid color-mix(in oklab, var(--border) 76%, transparent 24%);
      box-shadow: var(--shadow-soft);
    }
    .meta-label { display: block; margin-bottom: 6px; color: var(--muted-foreground); font-size: 0.78rem; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; }
    .meta-value { display: block; font-size: 1rem; font-weight: 700; word-break: break-word; }
    .meta-note { display: block; margin-top: 6px; color: var(--muted-foreground); font-size: 0.92rem; }

    .report-layout { display: grid; grid-template-columns: minmax(240px, 300px) minmax(0, 1fr); gap: 24px; align-items: start; }
    .report-layout.no-sidebar { grid-template-columns: minmax(0, 1fr); }

    .report-sidebar { position: sticky; top: 22px; padding: 18px; }
    .sidebar-title { margin: 0 0 14px; font-size: 0.8rem; letter-spacing: 0.14em; text-transform: uppercase; color: var(--muted-foreground); }

    .toc ul { list-style: none; margin: 0; padding: 0; }
    .toc ul ul { margin-top: 6px; margin-left: 10px; padding-left: 10px; border-left: 1px solid color-mix(in oklab, var(--border) 82%, transparent 18%); }
    .toc li { margin: 2px 0; }
    .toc a {
      display: block; padding: 8px 10px; border-radius: 12px;
      color: var(--muted-foreground); font-size: 0.92rem; line-height: 1.35;
      transition: background-color 120ms ease, color 120ms ease, transform 120ms ease;
    }
    .toc a:hover { background: color-mix(in oklab, var(--accent) 70%, transparent 30%); color: var(--foreground); transform: translateX(2px); }

    .report-main { min-width: 0; padding: 30px 32px; }

    .rc h2, .rc h3, .rc h4, .rc h5 { scroll-margin-top: 28px; }
    .rc h2 {
      margin: 2.5rem 0 1rem; padding-top: 1.4rem;
      border-top: 1px solid color-mix(in oklab, var(--border) 88%, transparent 12%);
      font-family: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", serif;
      font-size: clamp(1.55rem, 2vw, 2rem); line-height: 1.15; letter-spacing: -0.03em;
      text-wrap: balance;
    }
    .rc h2:first-of-type { margin-top: 0; padding-top: 0; border-top: 0; }
    .rc h3, .rc h4, .rc h5 {
      margin: 1.6rem 0 0.7rem; font-size: 0.82rem; font-weight: 800;
      letter-spacing: 0.16em; text-transform: uppercase; color: var(--muted-foreground);
    }
    .rc p, .rc li, .rc blockquote, .rc summary { font-size: 0.98rem; }
    .rc p { max-width: 74ch; }
    .rc ul, .rc ol { padding-left: 1.35rem; }
    .rc li + li { margin-top: 0.28rem; }
    .rc li::marker { color: color-mix(in oklab, var(--primary) 75%, var(--foreground) 25%); }

    .rc table {
      width: 100%; display: block; overflow-x: auto;
      margin: 1.2rem 0 1.6rem; border-collapse: separate; border-spacing: 0;
      border: 1px solid color-mix(in oklab, var(--border) 88%, transparent 12%);
      border-radius: calc(var(--radius) - 6px);
      background: color-mix(in oklab, var(--card) 92%, transparent 8%);
    }
    .rc thead th {
      background: color-mix(in oklab, var(--muted) 84%, transparent 16%);
      color: var(--foreground); font-size: 0.82rem; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase;
    }
    .rc caption {
      caption-side: bottom;
      padding: 12px 14px 0;
      color: var(--muted-foreground);
      font-size: 0.9rem;
      text-align: left;
    }
    .rc th, .rc td { padding: 12px 14px; border-bottom: 1px solid color-mix(in oklab, var(--border) 82%, transparent 18%); text-align: left; white-space: nowrap; }
    .rc tr:last-child td { border-bottom: 0; }
    .rc tbody tr:nth-child(even) { background: color-mix(in oklab, var(--muted) 48%, transparent 52%); }

    .rc blockquote {
      margin: 1.1rem 0; padding: 0.9rem 1rem;
      border-left: 3px solid color-mix(in oklab, var(--primary) 68%, transparent 32%);
      border-radius: 0 14px 14px 0;
      background: color-mix(in oklab, var(--accent) 52%, transparent 48%);
      color: var(--foreground);
    }

    .rc code {
      padding: 0.18rem 0.44rem; border-radius: 0.55rem;
      background: color-mix(in oklab, var(--muted) 72%, transparent 28%);
      color: var(--foreground); font-size: 0.88em;
    }

    .rc pre {
      overflow-x: auto; margin: 1rem 0 1.4rem; padding: 16px 18px;
      border: 1px solid color-mix(in oklab, var(--border) 60%, transparent 40%);
      border-radius: calc(var(--radius) - 4px);
      background: linear-gradient(180deg, color-mix(in oklab, var(--code-bg) 88%, black 12%), var(--code-bg));
      color: var(--code-fg);
      box-shadow: inset 0 1px 0 color-mix(in oklab, white 8%, transparent 92%);
    }
    .rc pre code { padding: 0; background: transparent; color: inherit; border-radius: 0; font-size: 0.9rem; }

    .rc .diff-add { color: var(--diff-add); background: var(--diff-add-bg); display: inline-block; width: 100%; padding: 0 4px; margin: 0 -4px; border-radius: 3px; }
    .rc .diff-del { color: var(--diff-del); background: var(--diff-del-bg); display: inline-block; width: 100%; padding: 0 4px; margin: 0 -4px; border-radius: 3px; }
    .rc .diff-mod { color: var(--diff-mod); background: var(--diff-mod-bg); display: inline-block; width: 100%; padding: 0 4px; margin: 0 -4px; border-radius: 3px; }

    .rc details {
      margin: 1.1rem 0 1.5rem; padding: 0.45rem 1rem 1rem;
      border: 1px solid color-mix(in oklab, var(--border) 85%, transparent 15%);
      border-radius: calc(var(--radius) - 4px);
      background: color-mix(in oklab, var(--card) 90%, transparent 10%);
    }
    .rc details[open] { box-shadow: var(--shadow-soft); }
    .rc summary {
      display: flex; align-items: center; justify-content: space-between; gap: 16px;
      cursor: pointer; list-style: none; font-weight: 700; color: var(--foreground); padding: 0.6rem 0;
      transition: color 150ms ease;
    }
    .rc summary:hover { color: var(--primary); }
    .rc summary::-webkit-details-marker { display: none; }
    .rc summary::after {
      content: "+"; display: inline-flex; align-items: center; justify-content: center;
      width: 1.6rem; height: 1.6rem; border-radius: 999px;
      background: color-mix(in oklab, var(--accent) 72%, transparent 28%);
      color: var(--accent-foreground); font-size: 1rem; font-weight: 700; flex-shrink: 0;
      transition: transform 200ms ease, background-color 150ms ease;
    }
    .rc summary:hover::after { background: color-mix(in oklab, var(--primary) 30%, var(--accent) 70%); }
    .rc details[open] summary::after { content: "\2212"; transform: rotate(180deg); }

    .rc .diff-entry { margin: 1.5rem 0; padding-top: 1rem; border-top: 1px solid color-mix(in oklab, var(--border) 60%, transparent 40%); }
    .rc .diff-entry:first-child { border-top: 0; padding-top: 0; margin-top: 0.5rem; }
    .rc .diff-entry-path {
      display: block; margin: 0.3rem 0 0.6rem; padding: 0.5rem 0.8rem;
      border-left: 3px solid color-mix(in oklab, var(--primary) 68%, transparent 32%);
      border-radius: 0 10px 10px 0;
      background: color-mix(in oklab, var(--accent) 40%, transparent 60%);
      color: var(--muted-foreground); font-size: 0.85rem;
    }
    .rc .diff-entry-path code { background: transparent; padding: 0; font-size: 0.82rem; }

    .rc hr { margin: 2rem 0; border: 0; border-top: 1px solid color-mix(in oklab, var(--border) 88%, transparent 12%); }

    a:focus-visible, summary:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; border-radius: 6px; }
    .toc a.active { background: color-mix(in oklab, var(--primary) 15%, transparent 85%); color: var(--foreground); font-weight: 600; }

    @media (prefers-reduced-motion: reduce) {
      *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; scroll-behavior: auto !important; }
    }

    @media (max-width: 1100px) {
      .hero-grid, .report-layout, .report-layout.no-sidebar { grid-template-columns: minmax(0, 1fr); }
      .report-sidebar { position: static; }
      .report-main { padding: 24px 22px; }
    }

    @media (max-width: 700px) {
      .report-shell { padding: 18px 14px 40px; }
      .report-hero { padding: 22px 18px; }
      .hero-title { font-size: 2rem; }
      .report-main { padding: 20px 16px; }
    }
  </style>
</head>
<body>
  <div class="report-shell">
    <header class="report-hero panel">
      <div class="hero-topline">
        <span>ipsw</span>
        <span>Diff Report</span>
      </div>
      <div class="hero-grid">
        <section>
          <h1 class="hero-title">{{ .Title }}</h1>
        </section>
        <section class="hero-meta">
          <div class="meta-card">
            <span class="meta-label">Old Input</span>
            <span class="meta-value">{{ .OldInput }}</span>
            {{- if .OldVersion }}<span class="meta-note">{{ .OldVersion }} &middot; {{ .OldBuild }}</span>{{ end }}
          </div>
          <div class="meta-card">
            <span class="meta-label">New Input</span>
            <span class="meta-value">{{ .NewInput }}</span>
            {{- if .NewVersion }}<span class="meta-note">{{ .NewVersion }} &middot; {{ .NewBuild }}</span>{{ end }}
          </div>
        </section>
      </div>
    </header>
    <div class="report-layout">
      <aside class="report-sidebar panel">
        <h2 class="sidebar-title">Contents</h2>
        <nav class="toc">
          <ul>
            <li><a href="#inputs">Inputs</a></li>
            <li><a href="#kernel">Kernel</a>
              {{- if .HasKernelVersion }}<ul><li><a href="#kernel-version">Version</a></li></ul>{{ end }}
            </li>
            {{- if .Kexts }}
            <li><a href="#kexts">Kexts</a>
              <ul>
                {{- if .Kexts.New }}<li><a href="#kexts-new">New ({{ len .Kexts.New }})</a></li>{{ end }}
                {{- if .Kexts.Removed }}<li><a href="#kexts-removed">Removed ({{ len .Kexts.Removed }})</a></li>{{ end }}
                {{- if .Kexts.Updated }}<li><a href="#kexts-updated">Updated ({{ len .Kexts.Updated }})</a></li>{{ end }}
              </ul>
            </li>
            {{- end }}
            {{- if .Machos }}
            <li><a href="#machos">MachOs</a>
              <ul>
                {{- range .Machos }}
                <li><a href="#machos-{{ .Name | slug }}">{{ .Name }}</a></li>
                {{- end }}
              </ul>
            </li>
            {{- end }}
            {{- if .Ents }}<li><a href="#entitlements">Entitlements</a></li>{{ end }}
            {{- if .Firmwares }}
            <li><a href="#firmwares">Firmwares</a>
              <ul>
                {{- if .Firmwares.New }}<li><a href="#fw-new">New ({{ len .Firmwares.New }})</a></li>{{ end }}
                {{- if .Firmwares.Removed }}<li><a href="#fw-removed">Removed ({{ len .Firmwares.Removed }})</a></li>{{ end }}
                {{- if .Firmwares.Updated }}<li><a href="#fw-updated">Updated ({{ len .Firmwares.Updated }})</a></li>{{ end }}
              </ul>
            </li>
            {{- end }}
            {{- if .IBoot }}
            <li><a href="#iboot">iBoot</a>
              <ul>
                {{- if .IBoot.New }}<li><a href="#iboot-new">New ({{ len .IBoot.New }})</a></li>{{ end }}
                {{- if .IBoot.Removed }}<li><a href="#iboot-removed">Removed ({{ len .IBoot.Removed }})</a></li>{{ end }}
              </ul>
            </li>
            {{- end }}
            {{- if not .LaunchdFragment.Empty }}<li><a href="#launchd">launchd Config</a></li>{{ end }}
            {{- if .Sandbox }}<li><a href="#sandbox-profiles">Sandbox Profiles</a></li>{{ end }}
            <li><a href="#dsc">DSC</a>
              <ul>
                {{- if .OldWebkit }}<li><a href="#webkit">WebKit</a></li>{{ end }}
                {{- if .Dylibs }}
                <li><a href="#dylibs">Dylibs</a>
                  <ul>
                    {{- if .Dylibs.New }}<li><a href="#dylibs-new">New ({{ len .Dylibs.New }})</a></li>{{ end }}
                    {{- if .Dylibs.Removed }}<li><a href="#dylibs-removed">Removed ({{ len .Dylibs.Removed }})</a></li>{{ end }}
                    {{- if .Dylibs.Updated }}<li><a href="#dylibs-updated">Updated ({{ len .Dylibs.Updated }})</a></li>{{ end }}
                  </ul>
                </li>
                {{- end }}
              </ul>
            </li>
            {{- if .Files }}
            <li><a href="#files">Files</a>
              <ul>
                {{- if .Files.New }}<li><a href="#files-new">New ({{ len .Files.New }})</a></li>{{ end }}
                {{- if .Files.Removed }}<li><a href="#files-removed">Removed ({{ len .Files.Removed }})</a></li>{{ end }}
              </ul>
            </li>
            {{- end }}
            {{- if .Localizations }}
            <li><a href="#localizations">Localizations</a>
              <ul>
                {{- range .Localizations }}
                <li><a href="#localizations-{{ .Name | slug }}">{{ .Name }}</a></li>
                {{- end }}
              </ul>
            </li>
            {{- end }}
            {{- if .Features }}
            <li><a href="#feature-flags">Feature Flags</a>
              <ul>
                {{- range .Features }}
                <li><a href="#features-{{ .Name | slug }}">{{ .Name }}</a></li>
                {{- end }}
              </ul>
            </li>
            {{- end }}
          </ul>
        </nav>
      </aside>
      <main class="report-main panel">
        <article class="rc">

          <h2 id="inputs">Inputs</h2>
          <ul>
            <li><code>{{ .OldInput }}</code></li>
            <li><code>{{ .NewInput }}</code></li>
          </ul>

          <h2 id="kernel">Kernel</h2>
          {{- if .HasKernelVersion }}
          <h3 id="kernel-version">Version</h3>
          <table>
            <caption>Kernel version comparison across the old and new inputs.</caption>
            <thead><tr><th>iOS</th><th>Version</th><th>Build</th><th>Date</th></tr></thead>
            <tbody>
              <tr><td>{{ .OldVersion }} <em>({{ .OldBuild }})</em></td><td>{{ .OldKernelDarwin }}</td><td>{{ .OldKernelXNU }}</td><td>{{ .OldKernelDate }}</td></tr>
              <tr><td>{{ .NewVersion }} <em>({{ .NewBuild }})</em></td><td>{{ .NewKernelDarwin }}</td><td>{{ .NewKernelXNU }}</td><td>{{ .NewKernelDate }}</td></tr>
            </tbody>
          </table>
          {{- end }}
          {{- with .SameKernelNote }}
          {{ . }}
          {{- end }}

          {{- if not .KextsFragment.Empty }}{{ .KextsFragment.Body }}{{- end }}

          {{- if not .KDKsFragment.Empty }}{{ .KDKsFragment.Body }}{{- end }}

          {{- if not .MachosFragment.Empty }}
          <h2 id="{{ .MachosFragment.Anchor }}">{{ .MachosFragment.Heading }}</h2>{{ .MachosFragment.Body }}
          {{- end }}
          {{- if not .EntsFragment.Empty }}
          <h2 id="{{ .EntsFragment.Anchor }}">{{ .EntsFragment.Heading }}</h2>{{ .EntsFragment.Body }}
          {{- end }}

          {{- if not .FirmwaresFragment.Empty }}{{ .FirmwaresFragment.Body }}{{- end }}

          {{- if not .IBootFragment.Empty }}{{ .IBootFragment.Body }}{{- end }}

          {{- if not .LaunchdFragment.Empty }}
          <h2 id="{{ .LaunchdFragment.Anchor }}">{{ .LaunchdFragment.Heading }}</h2>{{ .LaunchdFragment.Body }}
          {{- end }}

          {{- if not .SandboxFragment.Empty }}{{ .SandboxFragment.Body }}{{- end }}

          <h2 id="dsc">DSC</h2>
          {{- if .OldWebkit }}
          <h3 id="webkit">WebKit</h3>
          <table>
            <caption>WebKit version comparison across the old and new inputs.</caption>
            <thead><tr><th>iOS</th><th>Version</th></tr></thead>
            <tbody>
              <tr><td>{{ .OldVersion }} <em>({{ .OldBuild }})</em></td><td>{{ .OldWebkit }}</td></tr>
              <tr><td>{{ .NewVersion }} <em>({{ .NewBuild }})</em></td><td>{{ .NewWebkit }}</td></tr>
            </tbody>
          </table>
          {{- end }}

          {{- if .Dylibs }}
          <h3 id="dylibs">Dylibs</h3>
          {{- template "machoDiffSection" dict "Prefix" "dylibs" "Diff" .Dylibs }}
          {{- end }}

          {{- if not .FilesFragment.Empty }}
          <h2 id="{{ .FilesFragment.Anchor }}">{{ .FilesFragment.Heading }}</h2>{{ .FilesFragment.Body }}
          {{- end }}
          {{- if not .LocalizationsFragment.Empty }}
          <h2 id="{{ .LocalizationsFragment.Anchor }}">{{ .LocalizationsFragment.Heading }}</h2>{{ .LocalizationsFragment.Body }}
          {{- end }}
          {{- if not .FeaturesFragment.Empty }}
          <h2 id="{{ .FeaturesFragment.Anchor }}">{{ .FeaturesFragment.Heading }}</h2>{{ .FeaturesFragment.Body }}
          {{- end }}

        </article>
      </main>
    </div>
  </div>
  <script>
  (function(){
    var links=document.querySelectorAll('.toc a[href^="#"]');
    if(!links.length)return;
    var ids=[];links.forEach(function(a){ids.push(a.getAttribute('href').slice(1));});
    var io=new IntersectionObserver(function(entries){
      entries.forEach(function(e){
        if(e.isIntersecting){
          links.forEach(function(a){a.classList.remove('active');});
          var match=document.querySelector('.toc a[href="#'+e.target.id+'"]');
          if(match)match.classList.add('active');
        }
      });
    },{rootMargin:'-20% 0px -60% 0px'});
    ids.forEach(function(id){var el=document.getElementById(id);if(el)io.observe(el);});
  })();
  </script>
</body>
</html>

{{ define "machoDiffSection" }}
{{- if .Diff.New }}
<h4 id="{{ .Prefix }}-new">New</h4>
<ul>{{ range .Diff.New }}<li><code>{{ . }}</code></li>{{ end }}</ul>
{{- end }}
{{- if .Diff.Removed }}
<h4 id="{{ .Prefix }}-removed">Removed</h4>
<ul>{{ range .Diff.Removed }}<li><code>{{ . }}</code></li>{{ end }}</ul>
{{- end }}
{{- if .Diff.Updated }}
<h4 id="{{ .Prefix }}-updated">Updated</h4>
<details>
  <summary>View Updated ({{ len .Diff.Updated }})</summary>
  {{- range .Diff.Updated }}
  <div class="diff-entry">
    <h5>{{ .Name }}</h5>
    <div class="diff-entry-path"><code>{{ .Path }}</code></div>
    {{ .Diff }}
  </div>
  {{- end }}
</details>
{{- end }}
{{ end }}

{{ define "plistDiffSection" }}
{{- if .Diff.New }}
<h4 id="{{ .Prefix }}-new">New</h4>
<details>
  <summary>View New ({{ len .Diff.New }})</summary>
  {{- range .Diff.New }}
  <div class="diff-entry">
    <h5>{{ .Name }}</h5>
    <div class="diff-entry-path"><code>{{ .Path }}</code></div>
    {{ .Content }}
  </div>
  {{- end }}
</details>
{{- end }}
{{- if .Diff.Removed }}
<h4 id="{{ .Prefix }}-removed">Removed</h4>
<ul>{{ range .Diff.Removed }}<li><code>{{ . }}</code></li>{{ end }}</ul>
{{- end }}
{{- if .Diff.Updated }}
<h4 id="{{ .Prefix }}-updated">Updated</h4>
<details>
  <summary>View Updated ({{ len .Diff.Updated }})</summary>
  {{- range .Diff.Updated }}
  <div class="diff-entry">
    <h5>{{ .Name }}</h5>
    <div class="diff-entry-path"><code>{{ .Path }}</code></div>
    {{ .Content }}
  </div>
  {{- end }}
</details>
{{- end }}
{{ end }}
`

func renderCodeBlock(raw string) template.HTML {
	if raw == "" {
		return ""
	}

	var buf strings.Builder
	buf.WriteString("<pre><code>")
	buf.WriteString(html.EscapeString(raw))
	buf.WriteString("</code></pre>")
	return template.HTML(buf.String())
}

func renderMarkdownChunk(md string) string {
	if strings.TrimSpace(md) == "" {
		return ""
	}

	renderer := mdhtml.NewRenderer(mdhtml.RendererOptions{
		Flags: mdhtml.CommonFlags,
	})
	return string(markdown.ToHTML([]byte(md), nil, renderer))
}

func highlightDiff(raw string, highlightAddedRemoved bool) template.HTML {
	var buf strings.Builder
	first := true
	for line := range strings.SplitSeq(raw, "\n") {
		if !first {
			buf.WriteByte('\n')
		}
		first = false
		escaped := html.EscapeString(line)
		switch {
		case highlightAddedRemoved && strings.HasPrefix(line, "+"):
			buf.WriteString(`<span class="diff-add">`)
			buf.WriteString(escaped)
			buf.WriteString("</span>")
		case highlightAddedRemoved && strings.HasPrefix(line, "-"):
			buf.WriteString(`<span class="diff-del">`)
			buf.WriteString(escaped)
			buf.WriteString("</span>")
		case strings.HasPrefix(line, "~"):
			buf.WriteString(`<span class="diff-mod">`)
			buf.WriteString(escaped)
			buf.WriteString("</span>")
		default:
			buf.WriteString(escaped)
		}
	}
	return template.HTML(buf.String())
}

func renderHighlightedDiffBlock(raw string, highlightAddedRemoved bool) string {
	if raw == "" {
		return ""
	}

	var buf strings.Builder
	buf.WriteString("<pre><code>")
	buf.WriteString(string(highlightDiff(raw, highlightAddedRemoved)))
	buf.WriteString("</code></pre>")
	return buf.String()
}

func renderMarkdownFragment(md string) template.HTML {
	if md == "" {
		return ""
	}

	var out strings.Builder
	var markdownBuf strings.Builder
	var fencedBuf strings.Builder
	inFence := false
	fenceLang := ""

	flushMarkdown := func() {
		if markdownBuf.Len() == 0 {
			return
		}
		out.WriteString(renderMarkdownChunk(markdownBuf.String()))
		markdownBuf.Reset()
	}

	flushFence := func() {
		if fencedBuf.Len() == 0 {
			return
		}
		out.WriteString(renderHighlightedDiffBlock(fencedBuf.String(), fenceLang == "```diff"))
		fencedBuf.Reset()
	}

	lines := strings.Split(md, "\n")
	for idx, line := range lines {
		trimmed := strings.TrimSpace(line)

		if inFence {
			if trimmed == "```" {
				flushFence()
				inFence = false
				fenceLang = ""
				continue
			}
			if fencedBuf.Len() > 0 {
				fencedBuf.WriteByte('\n')
			}
			fencedBuf.WriteString(line)
			continue
		}

		if trimmed == "```diff" || trimmed == "```text" {
			flushMarkdown()
			inFence = true
			fenceLang = trimmed
			continue
		}

		markdownBuf.WriteString(line)
		if idx < len(lines)-1 {
			markdownBuf.WriteByte('\n')
		}
	}

	if inFence {
		markdownBuf.WriteString(fenceLang + "\n")
		markdownBuf.WriteString(fencedBuf.String())
	}

	flushMarkdown()

	return template.HTML(out.String())
}

func convertMachoDiff(md *mcmd.MachoDiff) *htmlMachoDiff {
	if md == nil {
		return nil
	}
	h := &htmlMachoDiff{
		New:     md.New,
		Removed: md.Removed,
	}
	for _, k := range slices.Sorted(maps.Keys(md.Updated)) {
		h.Updated = append(h.Updated, htmlUpdatedEntry{
			Name: filepath.Base(k),
			Path: k,
			Diff: renderMarkdownFragment(md.Updated[k]),
		})
	}
	return h
}

// convertPlistVolumeDiff turns a per-volume PlistDiff map into an ordered
// slice the HTML template iterates. Empty volumes are omitted. nameForPath
// chooses the display name used for entry headings.
func convertPlistVolumeDiff(m map[string]*PlistDiff, nameForPath func(string) string) []htmlPlistVolume {
	if len(m) == 0 {
		return nil
	}
	out := make([]htmlPlistVolume, 0, len(m))
	for _, vol := range sortedVolumeKeys(m) {
		pd := m[vol]
		if !plistDiffHasContent(pd) {
			continue
		}
		out = append(out, htmlPlistVolume{
			Name: vol,
			Diff: convertPlistDiffWithName(pd, nameForPath),
		})
	}
	return out
}

// convertMachoVolumeDiff turns the per-volume d.Machos map into an ordered
// slice the HTML template iterates. Empty volumes are omitted; volumes are
// emitted in canonical order (filesystem, SystemOS, AppOS, ExclaveOS) with
// any others trailing alphabetically.
func convertMachoVolumeDiff(m map[string]*mcmd.MachoDiff) []htmlMachoVolume {
	if len(m) == 0 {
		return nil
	}
	out := make([]htmlMachoVolume, 0, len(m))
	for _, vol := range sortedVolumeKeys(m) {
		md := m[vol]
		if !machoDiffHasContent(md) {
			continue
		}
		out = append(out, htmlMachoVolume{
			Name: vol,
			Diff: convertMachoDiff(md),
		})
	}
	return out
}

func sortedNamedLists(m map[string][]string) []htmlNamedList {
	var out []htmlNamedList
	for _, k := range slices.Sorted(maps.Keys(m)) {
		out = append(out, htmlNamedList{
			Name:  k,
			Items: slices.Sorted(slices.Values(m[k])),
		})
	}
	return out
}

func convertIBootDiff(ib *IBootDiff) *htmlIBootDiff {
	if ib == nil {
		return nil
	}

	out := &htmlIBootDiff{
		Versions: append([]string(nil), ib.Versions...),
		New:      sortedNamedLists(ib.New),
		Removed:  sortedNamedLists(ib.Removed),
	}

	if len(out.Versions) == 0 && len(out.New) == 0 && len(out.Removed) == 0 {
		return nil
	}

	return out
}

func convertPlistDiffWithName(pd *PlistDiff, nameForPath func(string) string) *htmlPlistDiff {
	if pd == nil {
		return nil
	}

	out := &htmlPlistDiff{
		Removed: slices.Sorted(slices.Values(pd.Removed)),
	}

	for _, k := range slices.Sorted(maps.Keys(pd.New)) {
		out.New = append(out.New, htmlPlistEntry{
			Name:    nameForPath(k),
			Path:    k,
			Content: renderCodeBlock(pd.New[k]),
		})
	}

	for _, k := range slices.Sorted(maps.Keys(pd.Updated)) {
		out.Updated = append(out.Updated, htmlPlistEntry{
			Name:    nameForPath(k),
			Path:    k,
			Content: renderMarkdownFragment(pd.Updated[k]),
		})
	}

	if len(out.New) == 0 && len(out.Removed) == 0 && len(out.Updated) == 0 {
		return nil
	}

	return out
}

func convertFileDiff(fd *FileDiff) *htmlFileDiff {
	if fd == nil {
		return nil
	}

	order := []string{"IPSW", "filesystem", "SystemOS", "AppOS", "ExclaveOS"}
	out := &htmlFileDiff{}

	for _, name := range order {
		if items, ok := fd.New[name]; ok && len(items) > 0 {
			out.New = append(out.New, htmlNamedList{Name: name, Items: slices.Sorted(slices.Values(items))})
		}
	}

	for _, name := range order {
		if items, ok := fd.Removed[name]; ok && len(items) > 0 {
			out.Removed = append(out.Removed, htmlNamedList{Name: name, Items: slices.Sorted(slices.Values(items))})
		}
	}

	if len(out.New) == 0 && len(out.Removed) == 0 {
		return nil
	}

	return out
}

// htmlSectionAnchors maps each in-scope renderer's JSONKey() to the stable
// section anchor ID the outer template embeds in <h2 id="...">. These IDs
// must not change without coordinating with downstream link consumers.
//
// Top-level task fragments (kexts, kdks, firmwares, iboot, sandbox)
// embed their own `<hN id="...">` heading inside Body, so they do not
// participate in the outer-template anchor wrapping handled by this
// map; they still appear here as documentation of the canonical
// section ID each fragment owns.
var htmlSectionAnchors = map[string]string{
	"kexts":         "kexts",
	"kdks":          "kdks",
	"machos":        "machos",
	"ents":          "entitlements",
	"firmwares":     "firmwares",
	"iboot":         "iboot",
	"launchd":       "launchd",
	"sandbox":       "sandbox-profiles",
	"files":         "files",
	"localizations": "localizations",
	"features":      "feature-flags",
}

// buildHTMLSectionFragment calls r.HTML(), then wraps the result in the
// outer-template view that the page renderer expects. Empty tasks produce
// a fragment with Empty=true so the outer template renders nothing.
func buildHTMLSectionFragment(r interface {
	JSONKey() string
	Empty() bool
	HTML() (HTMLFragment, error)
}) (htmlSectionFragment, error) {
	anchor, ok := htmlSectionAnchors[r.JSONKey()]
	if !ok {
		return htmlSectionFragment{}, fmt.Errorf("no html anchor registered for task %q", r.JSONKey())
	}
	if r.Empty() {
		return htmlSectionFragment{Anchor: anchor, Empty: true}, nil
	}
	frag, err := r.HTML()
	if err != nil {
		return htmlSectionFragment{}, err
	}
	return htmlSectionFragment{
		Anchor:  anchor,
		Heading: frag.Heading,
		Body:    frag.Body,
	}, nil
}

func (d *Diff) renderHTML() (string, error) {
	var htmlBuf bytes.Buffer

	// Top-level tasks (kexts/kdks/firmwares/iboot/sandbox) and mount-based
	// renderers (machos/ents/launchd/files/localizations/features) all
	// expose HTML()/Empty(); build one HTML fragment per task in source order.
	kexts := newKextsTask(d)
	kdks := newKDKsTask(d)
	machos := newMachosRenderer(d.Machos)
	ents := newEntsRenderer(d.Ents)
	firmwares := newFirmwaresTask(d)
	iboot := newIBootTask(d)
	launchd := newLaunchdRenderer(d.Launchd)
	sandbox := newSandboxTask(d)
	files := newFilesRenderer(d.Files)
	locs := newLocsRenderer(d.Localizations)
	features := newFeaturesRenderer(d.Features)

	kextsFrag, err := buildHTMLSectionFragment(kexts)
	if err != nil {
		return "", err
	}
	kdksFrag, err := buildHTMLSectionFragment(kdks)
	if err != nil {
		return "", err
	}
	machosFrag, err := buildHTMLSectionFragment(machos)
	if err != nil {
		return "", err
	}
	entsFrag, err := buildHTMLSectionFragment(ents)
	if err != nil {
		return "", err
	}
	firmwaresFrag, err := buildHTMLSectionFragment(firmwares)
	if err != nil {
		return "", err
	}
	ibootFrag, err := buildHTMLSectionFragment(iboot)
	if err != nil {
		return "", err
	}
	launchdFrag, err := buildHTMLSectionFragment(launchd)
	if err != nil {
		return "", err
	}
	sandboxFrag, err := buildHTMLSectionFragment(sandbox)
	if err != nil {
		return "", err
	}
	filesFrag, err := buildHTMLSectionFragment(files)
	if err != nil {
		return "", err
	}
	locsFrag, err := buildHTMLSectionFragment(locs)
	if err != nil {
		return "", err
	}
	featuresFrag, err := buildHTMLSectionFragment(features)
	if err != nil {
		return "", err
	}

	data := diffHTMLPageData{
		Title:                 d.Title,
		OldInput:              filepath.Base(d.Old.IPSWPath),
		NewInput:              filepath.Base(d.New.IPSWPath),
		OldVersion:            d.Old.Version,
		OldBuild:              d.Old.Build,
		NewVersion:            d.New.Version,
		NewBuild:              d.New.Build,
		OldWebkit:             d.Old.Webkit,
		NewWebkit:             d.New.Webkit,
		Kexts:                 convertMachoDiff(d.Kexts),
		Machos:                convertMachoVolumeDiff(d.Machos),
		Dylibs:                convertMachoDiff(d.Dylibs),
		Firmwares:             convertMachoDiff(d.Firmwares),
		IBoot:                 convertIBootDiff(d.IBoot),
		Features:              convertPlistVolumeDiff(d.Features, filepath.Base),
		Files:                 convertFileDiff(d.Files),
		Localizations:         convertPlistVolumeDiff(d.Localizations, localizationDisplayName),
		Ents:                  convertEntitlementsDiff(d.Ents),
		KDKs:                  renderMarkdownFragment(d.KDKs),
		Sandbox:               renderMarkdownFragment(d.Sandbox),
		KextsFragment:         kextsFrag,
		KDKsFragment:          kdksFrag,
		MachosFragment:        machosFrag,
		EntsFragment:          entsFrag,
		FirmwaresFragment:     firmwaresFrag,
		IBootFragment:         ibootFrag,
		LaunchdFragment:       launchdFrag,
		SandboxFragment:       sandboxFrag,
		FilesFragment:         filesFrag,
		LocalizationsFragment: locsFrag,
		FeaturesFragment:      featuresFrag,
	}

	data.SameKernelNote = renderMarkdownFragment(d.SameKernelNote())
	if d.Old.Kernel.Version != nil {
		data.HasKernelVersion = true
		data.OldKernelDarwin = d.Old.Kernel.Version.KernelVersion.Darwin
		data.OldKernelXNU = d.Old.Kernel.Version.KernelVersion.XNU
		data.OldKernelDate = d.Old.Kernel.Version.KernelVersion.Date.Format(
			"Mon, 02 Jan 2006 15:04:05 MST",
		)
	}
	if d.New.Kernel.Version != nil {
		data.NewKernelDarwin = d.New.Kernel.Version.KernelVersion.Darwin
		data.NewKernelXNU = d.New.Kernel.Version.KernelVersion.XNU
		data.NewKernelDate = d.New.Kernel.Version.KernelVersion.Date.Format(
			"Mon, 02 Jan 2006 15:04:05 MST",
		)
	}

	if err := diffHTMLTmpl.Execute(&htmlBuf, data); err != nil {
		return "", err
	}

	return htmlBuf.String(), nil
}

func (d *Diff) ToHTML() error {
	rendered, err := d.renderHTML()
	if err != nil {
		return err
	}

	if len(d.conf.Output) == 0 {
		fmt.Println(rendered)
		return nil
	}

	if err := os.MkdirAll(d.conf.Output, 0755); err != nil {
		return err
	}

	fname := filepath.Join(d.conf.Output, d.TitleToFilename()+".html")
	log.Infof("Creating HTML diff file: %s", fname)
	return os.WriteFile(fname, []byte(rendered), 0644)
}
