package main

import (
	"bytes"
	"encoding/json"
	"errors"
	"fmt"
	"image"
	"io"
	"os"
	"path/filepath"
	"sort"
	"strings"
)

// ---------------------------------------------------------------------------
// Manifest model
// ---------------------------------------------------------------------------

// Callout is one annotation drawn onto a copy of a step's screenshot.
//
// Coordinates are in PIXELS of the source image, origin top-left.
//
//	circle  centre (x,y), radius r, label drawn inside
//	rect    top-left (x,y), size (w,h)
//	arrow   tail (x,y) -> head (x2,y2)
type Callout struct {
	Kind  string `json:"kind"`
	X     int    `json:"x"`
	Y     int    `json:"y"`
	X2    int    `json:"x2,omitempty"`
	Y2    int    `json:"y2,omitempty"`
	W     int    `json:"w,omitempty"`
	H     int    `json:"h,omitempty"`
	R     int    `json:"r,omitempty"`
	Label string `json:"label,omitempty"`
	Color string `json:"color,omitempty"`
}

// Step is one numbered instruction with its screenshot.
type Step struct {
	Number   int       `json:"number"`
	Image    string    `json:"image"`
	Title    string    `json:"title"`
	Body     string    `json:"body,omitempty"`
	Note     string    `json:"note,omitempty"`
	Callouts []Callout `json:"callouts,omitempty"`
}

// Manifest is the whole document description.
type Manifest struct {
	Title     string `json:"title"`
	Author    string `json:"author,omitempty"`
	Intro     string `json:"intro,omitempty"`
	ImagesDir string `json:"images_dir,omitempty"`
	Steps     []Step `json:"steps"`

	// Populated by loadManifest; unexported, so never read from or written
	// to JSON.
	path      string
	raw       []byte
	stepOff   []int
	calloutOf [][]int
	imagesDir string
}

var calloutKinds = map[string]bool{"circle": true, "rect": true, "arrow": true}

// ---------------------------------------------------------------------------
// Byte-offset bookkeeping, so problems can be reported as file:line:col
// ---------------------------------------------------------------------------

// lineCol converts a byte offset into a 1-based line and column.
func lineCol(raw []byte, off int) (int, int) {
	if off < 0 {
		off = 0
	}
	if off > len(raw) {
		off = len(raw)
	}
	line := 1 + bytes.Count(raw[:off], []byte("\n"))
	nl := bytes.LastIndexByte(raw[:off], '\n')
	col := off - nl // nl == -1 gives off+1, which is what we want
	return line, col
}

// elementOffsets decodes the JSON object in raw and returns the byte offset of
// the first byte of every element of the array stored under key. The offsets are
// relative to raw. ok is false when the key is absent or is not an array.
func elementOffsets(raw []byte, key string) (offs []int, ok bool) {
	dec := json.NewDecoder(bytes.NewReader(raw))
	tok, err := dec.Token()
	if err != nil {
		return nil, false
	}
	if d, isDelim := tok.(json.Delim); !isDelim || d != '{' {
		return nil, false
	}
	for dec.More() {
		kt, err := dec.Token()
		if err != nil {
			return nil, false
		}
		name, _ := kt.(string)
		if name != key {
			var skip json.RawMessage
			if err := dec.Decode(&skip); err != nil {
				return nil, false
			}
			continue
		}
		open, err := dec.Token()
		if err != nil {
			return nil, false
		}
		if d, isDelim := open.(json.Delim); !isDelim || d != '[' {
			return nil, false
		}
		for dec.More() {
			start := skipSpace(raw, int(dec.InputOffset()))
			var elem json.RawMessage
			if err := dec.Decode(&elem); err != nil {
				return offs, true
			}
			offs = append(offs, start)
		}
		return offs, true
	}
	return nil, false
}

func skipSpace(raw []byte, i int) int {
	for i < len(raw) {
		switch raw[i] {
		case ' ', '\t', '\r', '\n', ',':
			i++
		default:
			return i
		}
	}
	return i
}

// ---------------------------------------------------------------------------
// Loading
// ---------------------------------------------------------------------------

var errNoManifest = errors.New("no manifest file")

func loadManifest(path string) (*Manifest, error) {
	raw, err := os.ReadFile(path)
	if err != nil {
		if os.IsNotExist(err) {
			return nil, errNoManifest
		}
		return nil, fmt.Errorf("cannot read manifest %s: %w", path, err)
	}
	m := &Manifest{path: path, raw: raw}
	dec := json.NewDecoder(bytes.NewReader(raw))
	dec.DisallowUnknownFields()
	if err := dec.Decode(m); err != nil {
		return nil, decodeError(path, raw, err)
	}
	// Reject trailing rubbish after the top-level object.
	if _, err := dec.Token(); err != io.EOF {
		off := int(dec.InputOffset())
		ln, col := lineCol(raw, off)
		return nil, fmt.Errorf("%s:%d:%d: unexpected trailing data after the manifest object", path, ln, col)
	}

	m.stepOff, _ = elementOffsets(raw, "steps")
	m.calloutOf = make([][]int, len(m.Steps))
	for i := range m.Steps {
		if i < len(m.stepOff) {
			end := len(raw)
			if i+1 < len(m.stepOff) {
				end = m.stepOff[i+1]
			}
			sub := raw[m.stepOff[i]:end]
			if co, ok := elementOffsets(sub, "callouts"); ok {
				for j := range co {
					co[j] += m.stepOff[i]
				}
				m.calloutOf[i] = co
			}
		}
	}

	base := filepath.Dir(path)
	if m.ImagesDir == "" {
		m.imagesDir = base
	} else if filepath.IsAbs(m.ImagesDir) {
		m.imagesDir = filepath.Clean(m.ImagesDir)
	} else {
		m.imagesDir = filepath.Join(base, m.ImagesDir)
	}
	return m, nil
}

func decodeError(path string, raw []byte, err error) error {
	var se *json.SyntaxError
	if errors.As(err, &se) {
		ln, col := lineCol(raw, int(se.Offset))
		return fmt.Errorf("%s:%d:%d: manifest is not valid JSON: %s", path, ln, col, se.Error())
	}
	var ue *json.UnmarshalTypeError
	if errors.As(err, &ue) {
		ln, col := lineCol(raw, int(ue.Offset))
		field := ue.Field
		if field == "" {
			field = "value"
		}
		return fmt.Errorf("%s:%d:%d: manifest field %s: cannot use JSON %s as %s",
			path, ln, col, field, ue.Value, ue.Type)
	}
	return fmt.Errorf("%s: manifest is not usable: %v", path, err)
}

// stepLine returns "file:line" for step index i, or just the file when the
// offset is unknown.
func (m *Manifest) stepLine(i int) string {
	if i >= 0 && i < len(m.stepOff) {
		ln, col := lineCol(m.raw, m.stepOff[i])
		return fmt.Sprintf("%s:%d:%d", m.path, ln, col)
	}
	return m.path
}

func (m *Manifest) calloutLine(i, j int) string {
	if i >= 0 && i < len(m.calloutOf) && j >= 0 && j < len(m.calloutOf[i]) {
		ln, col := lineCol(m.raw, m.calloutOf[i][j])
		return fmt.Sprintf("%s:%d:%d", m.path, ln, col)
	}
	return m.stepLine(i)
}

// ---------------------------------------------------------------------------
// Problems
// ---------------------------------------------------------------------------

// Problem is one validation finding.
type Problem struct {
	Severity string `json:"severity"` // "error" or "warning"
	Code     string `json:"code"`
	Where    string `json:"where"` // file:line:col when known
	Message  string `json:"message"`
}

func (p Problem) String() string {
	return fmt.Sprintf("%s: [%s] %s: %s", p.Severity, p.Code, p.Where, p.Message)
}

type problemList struct {
	items []Problem
}

func (pl *problemList) errf(code, where, format string, args ...any) {
	pl.items = append(pl.items, Problem{"error", code, where, fmt.Sprintf(format, args...)})
}

func (pl *problemList) warnf(code, where, format string, args ...any) {
	pl.items = append(pl.items, Problem{"warning", code, where, fmt.Sprintf(format, args...)})
}

func (pl *problemList) errors() int {
	n := 0
	for _, p := range pl.items {
		if p.Severity == "error" {
			n++
		}
	}
	return n
}

func (pl *problemList) warnings() int { return len(pl.items) - pl.errors() }

// ---------------------------------------------------------------------------
// Validation
// ---------------------------------------------------------------------------

// stepImage is the resolved, decoded screenshot for one step.
type stepImage struct {
	Rel    string // as written in the manifest
	Abs    string // resolved on disk
	Bounds image.Rectangle
	Bytes  int64
	SHA256 string
	OK     bool
}

// validate checks the manifest against the image folder. It returns every
// problem found plus the per-step resolved images (OK == false where the image
// could not be used).
func validate(m *Manifest) ([]Problem, []stepImage) {
	var pl problemList
	imgs := make([]stepImage, len(m.Steps))

	if strings.TrimSpace(m.Title) == "" {
		pl.errf("empty-document-title", m.path, "the manifest has no \"title\"")
	}
	if len(m.Steps) == 0 {
		pl.errf("no-steps", m.path, "the manifest lists no steps")
	}
	if fi, err := os.Stat(m.imagesDir); err != nil {
		pl.errf("missing-images-dir", m.path, "images directory %s cannot be read: %v", m.imagesDir, err)
	} else if !fi.IsDir() {
		pl.errf("missing-images-dir", m.path, "images path %s is not a directory", m.imagesDir)
	}

	seenNumber := map[int]int{}   // number -> first step index
	seenImage := map[string]int{} // cleaned rel path -> first step index
	referenced := map[string]bool{}

	for i := range m.Steps {
		st := &m.Steps[i]
		where := m.stepLine(i)

		if st.Number <= 0 {
			pl.errf("bad-step-number", where, "step %d has number %d; numbers must be 1 or greater", i+1, st.Number)
		} else if first, dup := seenNumber[st.Number]; dup {
			pl.errf("duplicate-step-number", where,
				"step number %d is already used by the step at %s", st.Number, m.stepLine(first))
		} else {
			seenNumber[st.Number] = i
		}
		if strings.TrimSpace(st.Title) == "" {
			pl.errf("empty-title", where, "step %d has an empty title", i+1)
		}

		rel := strings.TrimSpace(st.Image)
		if rel == "" {
			pl.errf("missing-image", where, "step %d has no \"image\"", i+1)
			continue
		}
		if filepath.IsAbs(rel) {
			pl.errf("image-outside-folder", where,
				"step %d image %q is an absolute path; images must be named relative to the images directory", i+1, rel)
			continue
		}
		clean := filepath.Clean(filepath.FromSlash(rel))
		if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
			pl.errf("image-outside-folder", where,
				"step %d image %q escapes the images directory", i+1, rel)
			continue
		}
		referenced[clean] = true
		if first, dup := seenImage[clean]; dup {
			pl.warnf("duplicate-image", where,
				"step %d reuses image %q, already used by the step at %s", i+1, rel, m.stepLine(first))
		} else {
			seenImage[clean] = i
		}

		abs := filepath.Join(m.imagesDir, clean)
		si := stepImage{Rel: rel, Abs: abs}
		fi, err := os.Stat(abs)
		switch {
		case err != nil && os.IsNotExist(err):
			pl.errf("missing-image", where, "step %d image %q does not exist at %s", i+1, rel, abs)
			imgs[i] = si
			continue
		case err != nil:
			pl.errf("unreadable-image", where, "step %d image %q cannot be read: %v", i+1, rel, err)
			imgs[i] = si
			continue
		case fi.IsDir():
			pl.errf("unreadable-image", where, "step %d image %q is a directory", i+1, rel)
			imgs[i] = si
			continue
		}
		si.Bytes = fi.Size()

		img, sum, err := decodePNGFile(abs)
		if err != nil {
			pl.errf("unreadable-image", where, "step %d image %q is not a usable PNG: %v", i+1, rel, err)
			imgs[i] = si
			continue
		}
		si.SHA256 = sum
		si.Bounds = img.Bounds()
		si.OK = true
		imgs[i] = si

		w, h := si.Bounds.Dx(), si.Bounds.Dy()
		for j := range st.Callouts {
			validateCallout(&pl, m, i, j, w, h)
		}
	}

	// Orphan images: files in the images directory that no step references.
	if entries, err := os.ReadDir(m.imagesDir); err == nil {
		var orphans []string
		for _, e := range entries {
			if e.IsDir() {
				continue
			}
			name := e.Name()
			if !strings.EqualFold(filepath.Ext(name), ".png") {
				continue
			}
			if !referenced[filepath.Clean(name)] {
				orphans = append(orphans, name)
			}
		}
		sort.Strings(orphans)
		for _, o := range orphans {
			pl.warnf("orphan-image", filepath.Join(m.imagesDir, o),
				"%s is in the images directory but no step references it", o)
		}
	}

	return pl.items, imgs
}

func validateCallout(pl *problemList, m *Manifest, i, j, w, h int) {
	c := &m.Steps[i].Callouts[j]
	where := m.calloutLine(i, j)
	kind := strings.ToLower(strings.TrimSpace(c.Kind))
	if kind == "" {
		kind = "circle"
	}
	if !calloutKinds[kind] {
		pl.errf("bad-callout-kind", where,
			"step %d callout %d has kind %q; expected circle, rect or arrow", i+1, j+1, c.Kind)
		return
	}
	c.Kind = kind

	if c.Color != "" {
		if _, err := parseColor(c.Color); err != nil {
			pl.errf("bad-callout-color", where, "step %d callout %d: %v", i+1, j+1, err)
		}
	}

	inBounds := func(x, y int, label string) bool {
		if x < 0 || y < 0 || x >= w || y >= h {
			pl.errf("callout-out-of-bounds", where,
				"step %d callout %d %s (%d,%d) is outside the %dx%d image", i+1, j+1, label, x, y, w, h)
			return false
		}
		return true
	}

	switch kind {
	case "circle":
		if !inBounds(c.X, c.Y, "centre") {
			return
		}
		r := c.R
		if r == 0 {
			r = defaultCircleRadius
		}
		if r < 0 {
			pl.errf("bad-callout-size", where, "step %d callout %d has negative radius %d", i+1, j+1, c.R)
			return
		}
		if c.X-r < 0 || c.Y-r < 0 || c.X+r >= w || c.Y+r >= h {
			pl.warnf("callout-clipped", where,
				"step %d callout %d circle (radius %d) extends past the %dx%d image and will be clipped", i+1, j+1, r, w, h)
		}
	case "rect":
		if !inBounds(c.X, c.Y, "top-left corner") {
			return
		}
		if c.W <= 0 || c.H <= 0 {
			pl.errf("bad-callout-size", where,
				"step %d callout %d rectangle needs positive w and h, got %dx%d", i+1, j+1, c.W, c.H)
			return
		}
		if c.X+c.W > w || c.Y+c.H > h {
			pl.warnf("callout-clipped", where,
				"step %d callout %d rectangle %dx%d at (%d,%d) extends past the %dx%d image and will be clipped",
				i+1, j+1, c.W, c.H, c.X, c.Y, w, h)
		}
	case "arrow":
		okTail := inBounds(c.X, c.Y, "tail")
		okHead := inBounds(c.X2, c.Y2, "head")
		if !okTail || !okHead {
			return
		}
		if c.X == c.X2 && c.Y == c.Y2 {
			pl.errf("bad-callout-size", where,
				"step %d callout %d arrow has zero length (tail and head are both (%d,%d))", i+1, j+1, c.X, c.Y)
		}
	}
}
