package qslcard

import (
	"bytes"
	"encoding/json"
	"fmt"
	"regexp"
	"strings"
)

// SchemaVersion is the template document version this package reads/writes.
const SchemaVersion = 1

// Card is the physical card geometry. All element coordinates live in this
// pixel space; the editor and renderer scale uniformly for display.
type Card struct {
	W       int `json:"w"`
	H       int `json:"h"`
	DPI     int `json:"dpi"`
	BleedPx int `json:"bleed_px"`
}

// Crop is a rectangle in source-photo pixel space.
type Crop struct {
	X int `json:"x"`
	Y int `json:"y"`
	W int `json:"w"`
	H int `json:"h"`
}

// Scrim is an optional darkening overlay on the hero photo that improves
// text legibility on busy or bright photos.
type Scrim struct {
	Enabled bool    `json:"enabled"`
	Color   string  `json:"color"`
	Opacity float64 `json:"opacity"`
}

// Hero is the full-bleed background photo of the card.
type Hero struct {
	Photo string `json:"photo"`
	Crop  Crop   `json:"crop"`
	Scrim *Scrim `json:"scrim,omitempty"`
}

// Shine controls the wavy gloss band of the gel presets.
type Shine struct {
	Coverage float64 `json:"coverage"`
	Opacity  float64 `json:"opacity"`
}

// Halo is the soft blurred glow behind the text stack.
type Halo struct {
	Color   string  `json:"color"`
	Blur    float64 `json:"blur"`
	Opacity float64 `json:"opacity"`
}

// ShadowFx is the offset drop shadow of a text stack.
type ShadowFx struct {
	Dx      float64 `json:"dx"`
	Dy      float64 `json:"dy"`
	Blur    float64 `json:"blur"`
	Color   string  `json:"color"`
	Opacity float64 `json:"opacity"`
}

// Bevel is the dark/light offset pair that fakes an extruded edge.
type Bevel struct {
	Dx    float64 `json:"dx"`
	Dy    float64 `json:"dy"`
	Dark  string  `json:"dark"`
	Light string  `json:"light"`
}

// StyleParams are the tunable knobs of a style preset. Which keys a preset
// accepts is declared in presets.go; Validate rejects params a preset does
// not understand so typos surface in the UI instead of silently dropping.
type StyleParams struct {
	Gradient     []string  `json:"gradient,omitempty"`
	Shine        *Shine    `json:"shine,omitempty"`
	OutlineColor string    `json:"outline_color,omitempty"`
	OutlineWidth float64   `json:"outline_width,omitempty"`
	Halo         *Halo     `json:"halo,omitempty"`
	Shadow       *ShadowFx `json:"shadow,omitempty"`
	BevelOffset  *Bevel    `json:"bevel_offset,omitempty"`
	Color        string    `json:"color,omitempty"`
	Fx           *FxParams `json:"fx,omitempty"`
}

// FxParams tunes the canvas call renderer (glossy bubble / western 3D). All
// optional pointers — nil falls back to the renderer's per-preset default.
// Persisted in the template so per-call tweaks survive a round-trip; the values
// are consumed entirely by the frontend (textFx.ts).
type FxParams struct {
	Plump  *float64 `json:"plump,omitempty"`
	Edge   *float64 `json:"edge,omitempty"`
	OuterW *float64 `json:"outerw,omitempty"`
	Gloss  *float64 `json:"gloss,omitempty"`
	GlossH *float64 `json:"gloss_h,omitempty"`
	GlossI *float64 `json:"gloss_i,omitempty"`
	InnerB *float64 `json:"inner_b,omitempty"`
	Depth  *float64 `json:"depth,omitempty"`
	Angle  *float64 `json:"angle,omitempty"`
	Slant  *float64 `json:"slant,omitempty"`
	Grunge *float64 `json:"grunge,omitempty"`
	Bevel  *float64 `json:"bevel,omitempty"`
	Seed   *float64 `json:"seed,omitempty"`
	Dark   string   `json:"dark,omitempty"`  // dark inter-letter edge (per colour palette)
	Outer  string   `json:"outer,omitempty"` // silver/rim colour (glossy)
}

// setKeys lists the JSON names of the params that are actually set, for
// per-preset whitelisting.
func (p *StyleParams) setKeys() []string {
	if p == nil {
		return nil
	}
	var keys []string
	if len(p.Gradient) > 0 {
		keys = append(keys, "gradient")
	}
	if p.Shine != nil {
		keys = append(keys, "shine")
	}
	if p.OutlineColor != "" {
		keys = append(keys, "outline_color")
	}
	if p.OutlineWidth != 0 {
		keys = append(keys, "outline_width")
	}
	if p.Halo != nil {
		keys = append(keys, "halo")
	}
	if p.Shadow != nil {
		keys = append(keys, "shadow")
	}
	if p.BevelOffset != nil {
		keys = append(keys, "bevel_offset")
	}
	if p.Color != "" {
		keys = append(keys, "color")
	}
	if p.Fx != nil {
		keys = append(keys, "fx")
	}
	return keys
}

// Element types.
const (
	ElemCallsign = "callsign"
	ElemOperator = "operator"
	ElemInfoLine = "info_line"
	ElemCountry  = "country"
	ElemInsert   = "insert"
)

// Element is one item placed on the card. The Type discriminator decides
// which fields are meaningful (a single struct keeps the JSON and the TS
// mirror simple; validation enforces per-type requirements).
type Element struct {
	Type   string  `json:"type"`
	X      float64 `json:"x"`
	Y      float64 `json:"y"`
	Rotate float64 `json:"rotate,omitempty"`
	Hidden bool    `json:"hidden,omitempty"` // toggled off in the editor; kept in the template

	// Text elements (callsign / operator / info_line).
	Text        string       `json:"text,omitempty"`
	Font        string       `json:"font,omitempty"`
	Size        float64      `json:"size,omitempty"`
	StylePreset string       `json:"style_preset,omitempty"`
	StyleParams *StyleParams `json:"style_params,omitempty"`

	// Country block. "auto" derives flag and label from the active profile
	// callsign via internal/dxcc at render time.
	Flag  string `json:"flag,omitempty"`
	Label string `json:"label,omitempty"`

	// Photo insert.
	Photo    string  `json:"photo,omitempty"`
	W        float64 `json:"w,omitempty"`
	BorderPx int     `json:"border_px,omitempty"`
	Shadow   bool    `json:"shadow,omitempty"`
}

// QSOBox is the white confirmation box filled per QSO at render time.
type QSOBox struct {
	Enabled   bool     `json:"enabled"`
	X         float64  `json:"x"`
	Y         float64  `json:"y"`
	W         float64  `json:"w"`
	H         float64  `json:"h"`
	BG        string   `json:"bg"`
	BGOpacity float64  `json:"bg_opacity"`
	FG        string   `json:"fg,omitempty"` // text colour (default dark)
	Radius    float64  `json:"radius"`
	Title     string   `json:"title"`
	Fields    []string `json:"fields"`
	Footer    string   `json:"footer"`
}

// Template is one full card design (schema v1, section 3 of the spec).
type Template struct {
	Schema   int       `json:"schema"`
	Name     string    `json:"name"`
	Card     Card      `json:"card"`
	Hero     Hero      `json:"hero"`
	Elements []Element `json:"elements"`
	QSOBox   *QSOBox   `json:"qso_box,omitempty"`
}

// Parse decodes a template document, rejecting unknown JSON keys so schema
// drift is caught immediately rather than silently ignored.
func Parse(data []byte) (Template, error) {
	var t Template
	dec := json.NewDecoder(bytes.NewReader(data))
	dec.DisallowUnknownFields()
	if err := dec.Decode(&t); err != nil {
		return t, fmt.Errorf("parse template: %w", err)
	}
	return t, nil
}

// Encode serializes a template to its canonical JSON form.
func Encode(t Template) ([]byte, error) {
	b, err := json.Marshal(t)
	if err != nil {
		return nil, fmt.Errorf("encode template: %w", err)
	}
	return b, nil
}

// qsoBoxFields are the QSO fields the renderer knows how to display.
var qsoBoxFields = map[string]bool{
	"qso_date": true, "time_on": true, "band": true, "mode": true,
	"rst_sent": true, "freq": true, "submode": true,
}

// placeholderRe matches {namespace.key} placeholders in text fields.
var placeholderRe = regexp.MustCompile(`\{([a-zA-Z_]+)\.([a-zA-Z_0-9]+)\}`)

// placeholderNamespaces are the only namespaces a template may reference.
var placeholderNamespaces = map[string]bool{"profile": true, "qso": true}

// Validate checks a template for structural problems. photoExists reports
// whether a photo referenced by the template is available (nil skips the
// check, e.g. while a draft is being edited). Errors are descriptive — they
// surface verbatim in the designer UI.
func Validate(t Template, photoExists func(name string) bool) error {
	if t.Schema != SchemaVersion {
		return fmt.Errorf("unsupported schema version %d (expected %d)", t.Schema, SchemaVersion)
	}
	if t.Card.W < 100 || t.Card.H < 100 || t.Card.W > 10000 || t.Card.H > 10000 {
		return fmt.Errorf("card size %dx%d out of range", t.Card.W, t.Card.H)
	}
	if t.Card.DPI <= 0 {
		return fmt.Errorf("card dpi must be positive")
	}
	if t.Card.BleedPx < 0 {
		return fmt.Errorf("card bleed_px must not be negative")
	}
	if t.Hero.Photo == "" {
		return fmt.Errorf("hero photo missing")
	}
	if t.Hero.Crop.W <= 0 || t.Hero.Crop.H <= 0 {
		return fmt.Errorf("hero crop has empty size")
	}
	if photoExists != nil && !photoExists(t.Hero.Photo) {
		return fmt.Errorf("hero photo file %q not found", t.Hero.Photo)
	}
	for i := range t.Elements {
		if err := validateElement(t.Card, t.Elements[i], photoExists); err != nil {
			return fmt.Errorf("element %d (%s): %w", i, t.Elements[i].Type, err)
		}
	}
	if b := t.QSOBox; b != nil && b.Enabled {
		if !inBounds(t.Card, b.X, b.Y) || !inBounds(t.Card, b.X+b.W, b.Y+b.H) {
			return fmt.Errorf("qso box (%g,%g %gx%g) falls outside the card", b.X, b.Y, b.W, b.H)
		}
		for _, f := range b.Fields {
			if !qsoBoxFields[f] {
				return fmt.Errorf("unknown qso box field %q", f)
			}
		}
		if err := validatePlaceholders(b.Title); err != nil {
			return err
		}
		if err := validatePlaceholders(b.Footer); err != nil {
			return err
		}
	}
	return nil
}

func validateElement(c Card, e Element, photoExists func(string) bool) error {
	switch e.Type {
	case ElemCallsign, ElemOperator, ElemInfoLine:
		if strings.TrimSpace(e.Text) == "" {
			return fmt.Errorf("text is empty")
		}
		if e.Font == "" {
			return fmt.Errorf("font is empty")
		}
		if e.Size <= 0 {
			return fmt.Errorf("size must be positive")
		}
		if _, ok := Presets[e.StylePreset]; !ok {
			return fmt.Errorf("unknown style_preset %q", e.StylePreset)
		}
		if err := validateStyleParams(e.StylePreset, e.StyleParams); err != nil {
			return err
		}
		if err := validatePlaceholders(e.Text); err != nil {
			return err
		}
	case ElemCountry:
		if e.Size <= 0 {
			return fmt.Errorf("size must be positive")
		}
	case ElemInsert:
		if e.Photo == "" {
			return fmt.Errorf("photo is empty")
		}
		if e.W <= 0 {
			return fmt.Errorf("width must be positive")
		}
		if photoExists != nil && !photoExists(e.Photo) {
			return fmt.Errorf("photo file %q not found", e.Photo)
		}
		if !inBounds(c, e.X+e.W, e.Y) {
			return fmt.Errorf("insert extends outside the card")
		}
	default:
		return fmt.Errorf("unknown element type %q", e.Type)
	}
	if !inBounds(c, e.X, e.Y) {
		return fmt.Errorf("position (%g,%g) falls outside the card", e.X, e.Y)
	}
	return nil
}

// inBounds allows the bleed margin around the trimmed card.
func inBounds(c Card, x, y float64) bool {
	m := float64(c.BleedPx)
	return x >= -m && y >= -m && x <= float64(c.W)+m && y <= float64(c.H)+m
}

func validateStyleParams(preset string, p *StyleParams) error {
	allowed := Presets[preset].AllowedParams
	for _, k := range p.setKeys() {
		if !allowed[k] {
			return fmt.Errorf("style param %q not accepted by preset %q", k, preset)
		}
	}
	return nil
}

func validatePlaceholders(text string) error {
	for _, m := range placeholderRe.FindAllStringSubmatch(text, -1) {
		if !placeholderNamespaces[m[1]] {
			return fmt.Errorf("unknown placeholder namespace %q in %q", m[1], m[0])
		}
	}
	return nil
}
