// Command stepshot turns a folder of screenshots plus a JSON manifest into a
// numbered, annotated guide and publishes it as HTML, DOCX and Markdown.
package main

import (
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"time"
)

const (
	appName     = "stepshot"
	toolVersion = "1.0.0"
)

// ---------------------------------------------------------------------------
// Shared Techlosoft UX helpers (verbatim across the tool line)
// ---------------------------------------------------------------------------

func reorderFlags(args []string, valueFlags map[string]bool) []string {
	var flags, positional []string
	for i := 0; i < len(args); i++ {
		a := args[i]
		name := strings.TrimLeft(a, "-")
		if strings.HasPrefix(a, "-") && valueFlags[name] {
			flags = append(flags, a)
			if i+1 < len(args) {
				i++
				flags = append(flags, args[i])
			}
			continue
		}
		if strings.HasPrefix(a, "-") {
			flags = append(flags, a)
			continue
		}
		positional = append(positional, a)
	}
	return append(flags, positional...)
}

func humanBytes(n int64) string {
	const unit = 1024
	if n < unit {
		return fmt.Sprintf("%d B", n)
	}
	div, exp := int64(unit), 0
	for x := n / unit; x >= unit; x /= unit {
		div *= unit
		exp++
	}
	return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
}

// ---------------------------------------------------------------------------
// Usage
// ---------------------------------------------------------------------------

func usageText() string {
	return fmt.Sprintf(`%s %s - screenshots plus a manifest, published as a real document
Techlosoft - Screenshot to Documentation (Team variant)

USAGE
  %s check  --manifest <file.json> [--strict] [--json]
  %s build  --manifest <file.json> --out-dir <dir> [--format html,docx,md]
                    [--name <base>] [--ledger <file.jsonl>] [--force] [--json]
  %s verify <file.docx> [--json]
  %s ledger --ledger <file.jsonl> [--json]
  %s help | -h | --help

COMMANDS
  check    Validate the manifest against the image folder and report every
           problem, with file:line:col into the manifest wherever it is known.
           Exits 1 if any error was found, 0 if only warnings were.
  build    Draw the callouts onto COPIES of the screenshots and write the
           document in each requested format, plus the annotated PNGs. Refuses
           to overwrite existing files unless --force is given. Every build can
           append a JSON-lines record of what it produced to a ledger.
  verify   Open an existing .docx, check that every required part is present,
           that every XML part parses, and that every relationship id used by
           word/document.xml resolves.
  ledger   List the build records written so far.

FLAGS
  --manifest <file>  JSON manifest describing the steps (check, build).
  --out-dir <dir>    Directory the build writes into. Created if missing.
  --format <list>    Comma-separated output formats: html, docx, md.
                     Default "html,docx,md".
  --name <base>      Base file name for the outputs. Defaults to the manifest
                     file name without its extension.
  --ledger <file>    Append a JSON-lines build record to this file.
  --force            Allow existing output files to be replaced.
  --strict           check only: treat warnings as errors.
  --json             Machine-readable JSON output.

Short forms -m, -o, -f and -n are accepted for --manifest, --out-dir, --format
and --name. Flags may appear before or after positional arguments.

SOURCE IMAGES ARE NEVER MODIFIED. Callouts are drawn on copies written into the
output directory; the originals are only ever opened for reading.

EXAMPLES
  %s check --manifest guide.json
  %s build --manifest guide.json --out-dir dist --format html,docx,md
  %s build --manifest guide.json --out-dir dist --ledger builds.jsonl --force
  %s verify dist/guide.docx --json
  %s ledger --ledger builds.jsonl
`, appName, toolVersion, appName, appName, appName, appName, appName,
		appName, appName, appName, appName, appName)
}

func usage()       { fmt.Fprint(os.Stderr, usageText()) }
func usageStdout() { fmt.Fprint(os.Stdout, usageText()) }

func fail(format string, args ...any) {
	fmt.Fprintf(os.Stderr, "%s: %s\n", appName, fmt.Sprintf(format, args...))
	os.Exit(1)
}

func usageErr(format string, args ...any) {
	fmt.Fprintf(os.Stderr, "%s: %s\n\n", appName, fmt.Sprintf(format, args...))
	usage()
	os.Exit(1)
}

// ---------------------------------------------------------------------------
// main
// ---------------------------------------------------------------------------

var valueFlags = map[string]bool{
	"manifest": true, "m": true,
	"out-dir": true, "o": true,
	"format": true, "f": true,
	"name": true, "n": true,
	"ledger": true,
}

func newFlagSet(name string) *flag.FlagSet {
	fs := flag.NewFlagSet(name, flag.ContinueOnError)
	fs.SetOutput(os.Stderr)
	fs.Usage = usage
	return fs
}

func main() {
	args := os.Args[1:]
	if len(args) == 0 {
		// Double-clicked in Explorer rather than run from a prompt: ask the
		// one question the program needs and stay on screen. Printing usage
		// and exiting here is what made the window vanish instantly.
		if interactiveConsole() {
			runGuided()
			return
		}
		usage()
		os.Exit(1)
	}
	switch args[0] {
	case "help", "-h", "--help":
		usageStdout()
		os.Exit(0)
	}
	cmd := args[0]
	rest := args[1:]
	for _, a := range rest {
		if a == "-h" || a == "--help" || a == "help" {
			usageStdout()
			os.Exit(0)
		}
	}
	switch cmd {
	case "check":
		cmdCheck(rest)
	case "build":
		cmdBuild(rest)
	case "verify":
		cmdVerify(rest)
	case "ledger":
		cmdLedger(rest)
	default:
		usageErr("unknown command %q", cmd)
	}
}

func manifestMissing(path string) {
	fmt.Fprintf(os.Stderr, "%s: no manifest at %s\n", appName, path)
	fmt.Fprintf(os.Stderr, "A manifest is a JSON file listing the ordered steps. Minimal example:\n")
	fmt.Fprintf(os.Stderr, "  {\"title\":\"How to X\",\"steps\":[{\"number\":1,\"image\":\"01.png\",\"title\":\"Open it\"}]}\n")
	os.Exit(1)
}

// ---------------------------------------------------------------------------
// check
// ---------------------------------------------------------------------------

func cmdCheck(argv []string) {
	fs := newFlagSet("check")
	manifest := fs.String("manifest", "", "manifest JSON file")
	fs.StringVar(manifest, "m", "", "shorthand for --manifest")
	strict := fs.Bool("strict", false, "treat warnings as errors")
	asJSON := fs.Bool("json", false, "JSON output")
	if err := fs.Parse(reorderFlags(argv, valueFlags)); err != nil {
		os.Exit(1)
	}
	if *manifest == "" && fs.NArg() > 0 {
		*manifest = fs.Arg(0)
	}
	if *manifest == "" {
		usageErr("check needs --manifest <file.json>")
	}

	m, err := loadManifest(*manifest)
	if err != nil {
		if errors.Is(err, errNoManifest) {
			manifestMissing(*manifest)
		}
		if *asJSON {
			emitJSON(map[string]any{
				"manifest": *manifest,
				"ok":       false,
				"problems": []Problem{{Severity: "error", Code: "unparseable-manifest", Where: *manifest, Message: err.Error()}},
			})
			os.Exit(1)
		}
		fail("%v", err)
	}

	problems, imgs := validate(m)
	nErr, nWarn := countProblems(problems)
	ok := nErr == 0 && (!*strict || nWarn == 0)

	if *asJSON {
		emitJSON(map[string]any{
			"manifest":     *manifest,
			"images_dir":   m.imagesDir,
			"title":        m.Title,
			"steps":        len(m.Steps),
			"errors":       nErr,
			"warnings":     nWarn,
			"strict":       *strict,
			"ok":           ok,
			"problems":     nonNilProblems(problems),
			"step_images":  imageSummaries(m, imgs),
			"generated_at": time.Now().UTC().Format(time.RFC3339),
		})
		if !ok {
			os.Exit(1)
		}
		return
	}

	fmt.Printf("StepShot manifest check\n")
	fmt.Printf("manifest : %s\n", *manifest)
	fmt.Printf("images   : %s\n", m.imagesDir)
	fmt.Printf("title    : %s\n", displayOrNone(m.Title))
	fmt.Printf("steps    : %d\n\n", len(m.Steps))
	if len(problems) == 0 {
		fmt.Println("No problems found.")
	} else {
		for _, p := range problems {
			fmt.Printf("%-7s [%s] %s\n         %s\n", p.Severity, p.Code, p.Where, p.Message)
		}
		fmt.Println()
	}
	fmt.Printf("%d error(s), %d warning(s)\n", nErr, nWarn)
	if !ok {
		if nErr == 0 {
			fmt.Printf("--strict: warnings are being treated as errors\n")
		}
		os.Exit(1)
	}
}

func displayOrNone(s string) string {
	if strings.TrimSpace(s) == "" {
		return "(none)"
	}
	return s
}

func countProblems(ps []Problem) (nErr, nWarn int) {
	for _, p := range ps {
		if p.Severity == "error" {
			nErr++
		} else {
			nWarn++
		}
	}
	return
}

func nonNilProblems(ps []Problem) []Problem {
	if ps == nil {
		return []Problem{}
	}
	return ps
}

type imageSummary struct {
	Step     int    `json:"step"`
	Image    string `json:"image"`
	Path     string `json:"path"`
	Width    int    `json:"width"`
	Height   int    `json:"height"`
	Bytes    int64  `json:"bytes"`
	Human    string `json:"bytes_human"`
	SHA256   string `json:"sha256"`
	Callouts int    `json:"callouts"`
	OK       bool   `json:"ok"`
}

func imageSummaries(m *Manifest, imgs []stepImage) []imageSummary {
	out := make([]imageSummary, 0, len(imgs))
	for i, si := range imgs {
		out = append(out, imageSummary{
			Step:     m.Steps[i].Number,
			Image:    si.Rel,
			Path:     si.Abs,
			Width:    si.Bounds.Dx(),
			Height:   si.Bounds.Dy(),
			Bytes:    si.Bytes,
			Human:    humanBytes(si.Bytes),
			SHA256:   si.SHA256,
			Callouts: len(m.Steps[i].Callouts),
			OK:       si.OK,
		})
	}
	return out
}

func emitJSON(v any) {
	enc := json.NewEncoder(os.Stdout)
	enc.SetIndent("", "  ")
	enc.SetEscapeHTML(false)
	if err := enc.Encode(v); err != nil {
		fail("%v", err)
	}
}

// ---------------------------------------------------------------------------
// build
// ---------------------------------------------------------------------------

// OutputFile records one written artefact.
type OutputFile struct {
	Path   string `json:"path"`
	Format string `json:"format"`
	Bytes  int64  `json:"bytes"`
	Human  string `json:"bytes_human"`
	SHA256 string `json:"sha256"`
}

// SourceRecord proves which capture a document came from.
type SourceRecord struct {
	Step          int    `json:"step"`
	Image         string `json:"image"`
	SourceSHA256  string `json:"source_sha256"`
	Annotated     string `json:"annotated"`
	AnnotatedSHA  string `json:"annotated_sha256"`
	CalloutsDrawn int    `json:"callouts_drawn"`
}

// LedgerRecord is one JSON line of the build ledger.
type LedgerRecord struct {
	TS             time.Time      `json:"ts"`
	Tool           string         `json:"tool"`
	Manifest       string         `json:"manifest"`
	ManifestSHA256 string         `json:"manifest_sha256"`
	Title          string         `json:"title"`
	Steps          int            `json:"steps"`
	OutDir         string         `json:"out_dir"`
	Formats        []string       `json:"formats"`
	Sources        []SourceRecord `json:"sources"`
	Outputs        []OutputFile   `json:"outputs"`
	DocxVerified   *bool          `json:"docx_verified,omitempty"`
}

var knownFormats = map[string]bool{"html": true, "docx": true, "md": true}

func parseFormats(s string) ([]string, error) {
	if strings.TrimSpace(s) == "" {
		return nil, errors.New("--format needs at least one of html, docx, md")
	}
	seen := map[string]bool{}
	var out []string
	for _, part := range strings.Split(s, ",") {
		f := strings.ToLower(strings.TrimSpace(part))
		if f == "" {
			continue
		}
		if f == "markdown" {
			f = "md"
		}
		if !knownFormats[f] {
			return nil, fmt.Errorf("unknown format %q; expected html, docx or md", part)
		}
		if !seen[f] {
			seen[f] = true
			out = append(out, f)
		}
	}
	if len(out) == 0 {
		return nil, errors.New("--format needs at least one of html, docx, md")
	}
	sort.Slice(out, func(i, j int) bool { return formatRank(out[i]) < formatRank(out[j]) })
	return out, nil
}

func formatRank(f string) int {
	switch f {
	case "html":
		return 0
	case "docx":
		return 1
	default:
		return 2
	}
}

const mediaDirName = "media"

func cmdBuild(argv []string) {
	fs := newFlagSet("build")
	manifest := fs.String("manifest", "", "manifest JSON file")
	fs.StringVar(manifest, "m", "", "shorthand for --manifest")
	outDir := fs.String("out-dir", "", "output directory")
	fs.StringVar(outDir, "o", "", "shorthand for --out-dir")
	format := fs.String("format", "html,docx,md", "output formats")
	fs.StringVar(format, "f", "html,docx,md", "shorthand for --format")
	name := fs.String("name", "", "base file name for the outputs")
	fs.StringVar(name, "n", "", "shorthand for --name")
	ledger := fs.String("ledger", "", "append a JSON-lines build record here")
	force := fs.Bool("force", false, "allow existing output files to be replaced")
	asJSON := fs.Bool("json", false, "JSON output")
	if err := fs.Parse(reorderFlags(argv, valueFlags)); err != nil {
		os.Exit(1)
	}
	if *manifest == "" && fs.NArg() > 0 {
		*manifest = fs.Arg(0)
	}
	if *manifest == "" {
		usageErr("build needs --manifest <file.json>")
	}
	if *outDir == "" {
		usageErr("build needs --out-dir <dir>")
	}
	formats, err := parseFormats(*format)
	if err != nil {
		usageErr("%v", err)
	}

	m, err := loadManifest(*manifest)
	if err != nil {
		if errors.Is(err, errNoManifest) {
			manifestMissing(*manifest)
		}
		fail("%v", err)
	}
	problems, imgs := validate(m)
	nErr, nWarn := countProblems(problems)
	if nErr > 0 {
		if *asJSON {
			emitJSON(map[string]any{
				"manifest": *manifest,
				"ok":       false,
				"errors":   nErr,
				"warnings": nWarn,
				"problems": nonNilProblems(problems),
			})
			os.Exit(1)
		}
		fmt.Fprintf(os.Stderr, "%s: refusing to build, the manifest has %d error(s):\n", appName, nErr)
		for _, p := range problems {
			if p.Severity == "error" {
				fmt.Fprintf(os.Stderr, "  [%s] %s: %s\n", p.Code, p.Where, p.Message)
			}
		}
		fmt.Fprintf(os.Stderr, "Run %s check --manifest %s for the full report.\n", appName, *manifest)
		os.Exit(1)
	}

	base := *name
	if base == "" {
		base = strings.TrimSuffix(filepath.Base(*manifest), filepath.Ext(*manifest))
	}
	if base == "" || strings.ContainsAny(base, `/\`) {
		usageErr("--name %q must be a plain file name with no path separators", base)
	}

	// Sort the steps by their declared number so document order is the
	// authors' numbering, not the order the JSON happens to be written in.
	order := make([]int, len(m.Steps))
	for i := range order {
		order[i] = i
	}
	sort.SliceStable(order, func(a, b int) bool { return m.Steps[order[a]].Number < m.Steps[order[b]].Number })

	// Annotate every step onto a COPY. The source file is never reopened for
	// writing anywhere in this program.
	doc := renderDoc{Title: m.Title, Author: m.Author, Intro: m.Intro,
		Generator: fmt.Sprintf("%s %s", appName, toolVersion)}
	var sources []SourceRecord
	var unknownGlyphs int
	for _, i := range order {
		st := m.Steps[i]
		src, srcSum, err := decodePNGFile(imgs[i].Abs)
		if err != nil {
			fail("step %d image %s: %v", st.Number, imgs[i].Abs, err)
		}
		cs := resolveCallouts(st.Callouts)
		out, unknown := annotate(src, cs)
		unknownGlyphs += unknown
		pngBytes, err := encodePNG(out)
		if err != nil {
			fail("step %d: cannot encode annotated PNG: %v", st.Number, err)
		}
		media := fmt.Sprintf("step-%02d-%s", st.Number, filepath.Base(filepath.FromSlash(st.Image)))
		sum := sha256.Sum256(pngBytes)
		doc.Steps = append(doc.Steps, renderStep{
			Number: st.Number, Title: st.Title, Body: st.Body, Note: st.Note,
			SourceRel: st.Image, MediaName: media, PNG: pngBytes,
			W: out.Bounds().Dx(), H: out.Bounds().Dy(), Callouts: cs,
		})
		sources = append(sources, SourceRecord{
			Step: st.Number, Image: st.Image, SourceSHA256: srcSum,
			Annotated:    filepath.ToSlash(filepath.Join(mediaDirName, media)),
			AnnotatedSHA: hex.EncodeToString(sum[:]), CalloutsDrawn: len(cs),
		})
	}

	// Plan the writes, then refuse as a whole if anything is in the way.
	type pending struct {
		path   string
		format string
		data   []byte
	}
	var plan []pending
	mediaDir := filepath.Join(*outDir, mediaDirName)
	for _, s := range doc.Steps {
		plan = append(plan, pending{filepath.Join(mediaDir, s.MediaName), "png", s.PNG})
	}
	for _, f := range formats {
		switch f {
		case "html":
			plan = append(plan, pending{filepath.Join(*outDir, base+".html"), "html", []byte(renderHTML(doc))})
		case "md":
			plan = append(plan, pending{filepath.Join(*outDir, base+".md"), "md", []byte(renderMarkdown(doc, mediaDirName))})
		case "docx":
			data, err := renderDOCX(doc)
			if err != nil {
				fail("cannot assemble the .docx package: %v", err)
			}
			plan = append(plan, pending{filepath.Join(*outDir, base+".docx"), "docx", data})
		}
	}

	if !*force {
		var inTheWay []string
		for _, p := range plan {
			if _, err := os.Stat(p.path); err == nil {
				inTheWay = append(inTheWay, p.path)
			}
		}
		if len(inTheWay) > 0 {
			fmt.Fprintf(os.Stderr, "%s: refusing to overwrite %d existing output file(s):\n", appName, len(inTheWay))
			for _, p := range inTheWay {
				fmt.Fprintf(os.Stderr, "  %s\n", p)
			}
			fmt.Fprintf(os.Stderr, "Pass --force to replace them, or choose an empty --out-dir.\n")
			os.Exit(1)
		}
	}

	if err := os.MkdirAll(mediaDir, 0o755); err != nil {
		fail("cannot create %s: %v", mediaDir, err)
	}
	var outputs []OutputFile
	for _, p := range plan {
		if err := os.WriteFile(p.path, p.data, 0o644); err != nil {
			fail("cannot write %s: %v", p.path, err)
		}
		sum := sha256.Sum256(p.data)
		outputs = append(outputs, OutputFile{
			Path: p.path, Format: p.format, Bytes: int64(len(p.data)),
			Human: humanBytes(int64(len(p.data))), SHA256: hex.EncodeToString(sum[:]),
		})
	}

	// Verify our own .docx by reading the file back off disk.
	var docxRep *DocxReport
	for _, p := range plan {
		if p.format != "docx" {
			continue
		}
		data, err := os.ReadFile(p.path)
		if err != nil {
			fail("cannot read back %s for verification: %v", p.path, err)
		}
		rep := verifyDocxBytes(p.path, data)
		docxRep = &rep
		if !rep.OK {
			fmt.Fprintf(os.Stderr, "%s: the .docx written to %s did not verify:\n", appName, p.path)
			for _, pr := range rep.Problems {
				fmt.Fprintf(os.Stderr, "  %s\n", pr)
			}
			os.Exit(1)
		}
	}

	var rec LedgerRecord
	if *ledger != "" {
		mSum := sha256.Sum256(m.raw)
		rec = LedgerRecord{
			TS: time.Now().UTC(), Tool: fmt.Sprintf("%s %s", appName, toolVersion),
			Manifest: *manifest, ManifestSHA256: hex.EncodeToString(mSum[:]),
			Title: m.Title, Steps: len(doc.Steps), OutDir: *outDir,
			Formats: formats, Sources: sources, Outputs: outputs,
		}
		if docxRep != nil {
			ok := docxRep.OK
			rec.DocxVerified = &ok
		}
		if err := appendLedger(*ledger, rec); err != nil {
			fail("%v", err)
		}
	}

	if *asJSON {
		out := map[string]any{
			"manifest":        *manifest,
			"manifest_sha256": hashOf(m.raw),
			"title":           m.Title,
			"out_dir":         *outDir,
			"formats":         formats,
			"steps":           len(doc.Steps),
			"warnings":        nWarn,
			"problems":        nonNilProblems(problems),
			"sources":         sources,
			"outputs":         outputs,
			"ok":              true,
		}
		if docxRep != nil {
			out["docx_verification"] = docxRep
		}
		if *ledger != "" {
			out["ledger"] = *ledger
		}
		if unknownGlyphs > 0 {
			out["unrenderable_label_characters"] = unknownGlyphs
		}
		emitJSON(out)
		return
	}

	fmt.Printf("StepShot build\n")
	fmt.Printf("manifest : %s\n", *manifest)
	fmt.Printf("title    : %s\n", m.Title)
	fmt.Printf("steps    : %d\n", len(doc.Steps))
	fmt.Printf("out dir  : %s\n", *outDir)
	fmt.Printf("formats  : %s\n\n", strings.Join(formats, ", "))
	for _, o := range outputs {
		fmt.Printf("  %-5s %-52s %10s  %s\n", o.Format, o.Path, o.Human, o.SHA256[:12])
	}
	fmt.Println()
	if docxRep != nil {
		fmt.Printf("docx     : verified - %d parts, %d XML parts parsed, %d/%d relationship ids resolved\n",
			len(docxRep.Parts), docxRep.XMLParts, docxRep.References, docxRep.References)
	}
	fmt.Printf("sources  : %d screenshot(s) read, 0 modified\n", len(sources))
	if unknownGlyphs > 0 {
		fmt.Printf("note     : %d label character(s) have no glyph in the embedded set and were drawn as a box\n", unknownGlyphs)
	}
	if nWarn > 0 {
		fmt.Printf("\n%d warning(s) - run %s check --manifest %s for detail\n", nWarn, appName, *manifest)
		for _, p := range problems {
			if p.Severity == "warning" {
				fmt.Printf("  [%s] %s: %s\n", p.Code, p.Where, p.Message)
			}
		}
	}
	if *ledger != "" {
		fmt.Printf("\nledger   : appended 1 record to %s\n", *ledger)
	}
}

func hashOf(b []byte) string {
	sum := sha256.Sum256(b)
	return hex.EncodeToString(sum[:])
}

// ---------------------------------------------------------------------------
// verify
// ---------------------------------------------------------------------------

func cmdVerify(argv []string) {
	fs := newFlagSet("verify")
	asJSON := fs.Bool("json", false, "JSON output")
	if err := fs.Parse(reorderFlags(argv, valueFlags)); err != nil {
		os.Exit(1)
	}
	if fs.NArg() != 1 {
		usageErr("verify needs exactly one <file.docx>")
	}
	path := fs.Arg(0)
	data, err := os.ReadFile(path)
	if err != nil {
		fail("cannot read %s: %v", path, err)
	}
	rep := verifyDocxBytes(path, data)
	if rep.Problems == nil {
		rep.Problems = []string{}
	}
	if *asJSON {
		emitJSON(rep)
		if !rep.OK {
			os.Exit(1)
		}
		return
	}
	fmt.Printf("StepShot docx verification\n")
	fmt.Printf("file     : %s (%s)\n", rep.Path, humanBytes(rep.Bytes))
	fmt.Printf("parts    : %d\n", len(rep.Parts))
	for _, p := range rep.Parts {
		fmt.Printf("           %s\n", p)
	}
	fmt.Printf("xml      : %d part(s) parsed\n", rep.XMLParts)
	fmt.Printf("media    : %d PNG part(s)\n", rep.MediaParts)
	fmt.Printf("rels     : %d declared, %d referenced by word/document.xml\n", rep.Relationships, rep.References)
	fmt.Println()
	if rep.OK {
		fmt.Println("OK - the package is structurally valid.")
		return
	}
	for _, p := range rep.Problems {
		fmt.Printf("problem  : %s\n", p)
	}
	os.Exit(1)
}

// ---------------------------------------------------------------------------
// ledger
// ---------------------------------------------------------------------------

func appendLedger(path string, rec LedgerRecord) error {
	line, err := json.Marshal(rec)
	if err != nil {
		return fmt.Errorf("cannot encode ledger record: %w", err)
	}
	if dir := filepath.Dir(path); dir != "" && dir != "." {
		if err := os.MkdirAll(dir, 0o755); err != nil {
			return fmt.Errorf("cannot create %s: %w", dir, err)
		}
	}
	f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
	if err != nil {
		return fmt.Errorf("cannot open ledger %s: %w", path, err)
	}
	defer f.Close()
	if _, err := f.Write(append(line, '\n')); err != nil {
		return fmt.Errorf("cannot append to ledger %s: %w", path, err)
	}
	return f.Close()
}

func loadLedger(path string) ([]LedgerRecord, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		if os.IsNotExist(err) {
			return nil, fmt.Errorf("no ledger file at %s", path)
		}
		return nil, fmt.Errorf("cannot read ledger %s: %w", path, err)
	}
	var recs []LedgerRecord
	for i, line := range strings.Split(string(data), "\n") {
		t := strings.TrimSpace(line)
		if t == "" {
			continue
		}
		var r LedgerRecord
		if err := json.Unmarshal([]byte(t), &r); err != nil {
			return nil, fmt.Errorf("ledger %s line %d is not valid JSON: %w", path, i+1, err)
		}
		recs = append(recs, r)
	}
	return recs, nil
}

func cmdLedger(argv []string) {
	fs := newFlagSet("ledger")
	ledger := fs.String("ledger", "", "ledger file (JSON lines)")
	asJSON := fs.Bool("json", false, "JSON output")
	if err := fs.Parse(reorderFlags(argv, valueFlags)); err != nil {
		os.Exit(1)
	}
	if *ledger == "" && fs.NArg() > 0 {
		*ledger = fs.Arg(0)
	}
	if *ledger == "" {
		usageErr("ledger needs --ledger <file.jsonl>")
	}
	recs, err := loadLedger(*ledger)
	if err != nil {
		fail("%v", err)
	}
	if *asJSON {
		if recs == nil {
			recs = []LedgerRecord{}
		}
		emitJSON(map[string]any{"ledger": *ledger, "count": len(recs), "records": recs})
		return
	}
	fmt.Printf("ledger   : %s\n", *ledger)
	fmt.Printf("builds   : %d\n\n", len(recs))
	for i, r := range recs {
		fmt.Printf("#%d  %s  %s\n", i+1, r.TS.Format(time.RFC3339), r.Title)
		fmt.Printf("     manifest %s\n     sha256   %s\n", r.Manifest, r.ManifestSHA256)
		fmt.Printf("     steps %d, formats %s\n", r.Steps, strings.Join(r.Formats, ","))
		for _, o := range r.Outputs {
			if o.Format == "png" {
				continue
			}
			fmt.Printf("       %-5s %-46s %10s  %s\n", o.Format, o.Path, o.Human, o.SHA256[:12])
		}
	}
}
