package main

// Loading and validating binding-set files.

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

// Binding is one line of a binding-set file.
type Binding struct {
	Action   string `json:"action"`
	Chord    string `json:"chord"`
	Override bool   `json:"override,omitempty"`
	Note     string `json:"note,omitempty"`
}

// BindingSet is the on-disk shape of a binding-set file.
type BindingSet struct {
	Set      string    `json:"set"`
	Platform string    `json:"platform,omitempty"`
	Bindings []Binding `json:"bindings"`
}

// SetInfo describes a loaded file.
type SetInfo struct {
	Name     string `json:"set"`
	File     string `json:"file"`
	Platform string `json:"platform,omitempty"`
	SHA256   string `json:"sha256"`
	Count    int    `json:"bindings"`
	IsBase   bool   `json:"base"`
}

// LoadedBinding is one normalised binding with its provenance.
type LoadedBinding struct {
	Set      string
	File     string
	Action   string
	Raw      string
	Seq      Sequence
	Canon    string
	Override bool
	Note     string
	IsBase   bool
	Index    int // 1-based position inside its file, for error messages
}

// Priority decides who wins a merge. Lower wins. It depends only on the
// binding's own properties, never on the order the files were listed in.
const (
	prioOverride = 0 // a set explicitly marked this binding "override": true
	prioBase     = 1 // the team standard passed with --base
	prioPersonal = 2 // an ordinary personal set
)

func (b LoadedBinding) priority() int {
	if b.Override {
		return prioOverride
	}
	if b.IsBase {
		return prioBase
	}
	return prioPersonal
}

func (b LoadedBinding) origin() string {
	switch b.priority() {
	case prioOverride:
		if b.IsBase {
			return "team standard, marked override"
		}
		return "personal set, marked override"
	case prioBase:
		return "team standard"
	default:
		return "personal set"
	}
}

// describe renders "action -> chord (set, origin)" for explanations.
func (b LoadedBinding) describe() string {
	return fmt.Sprintf("%s -> %s from %s (%s, %s)", b.Action, b.Canon, b.Set, b.origin(), b.File)
}

// loadSetFile reads one binding-set file and normalises every chord in it.
func loadSetFile(path string, isBase bool) (SetInfo, []LoadedBinding, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		if os.IsNotExist(err) {
			return SetInfo{}, nil, fmt.Errorf("no binding set at %s", path)
		}
		return SetInfo{}, nil, fmt.Errorf("cannot read %s: %w", path, err)
	}
	sum := sha256.Sum256(data)

	var bs BindingSet
	dec := json.NewDecoder(bytes.NewReader(data))
	dec.DisallowUnknownFields()
	if err := dec.Decode(&bs); err != nil {
		return SetInfo{}, nil, fmt.Errorf("%s is not a valid binding set: %w", path, err)
	}

	name := strings.TrimSpace(bs.Set)
	if name == "" {
		name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
	}
	if name == "" {
		return SetInfo{}, nil, fmt.Errorf("%s: cannot determine a set name", path)
	}
	if len(bs.Bindings) == 0 {
		return SetInfo{}, nil, fmt.Errorf("%s: set %q contains no bindings", path, name)
	}

	info := SetInfo{
		Name:     name,
		File:     path,
		Platform: strings.ToLower(strings.TrimSpace(bs.Platform)),
		SHA256:   hex.EncodeToString(sum[:]),
		IsBase:   isBase,
	}
	if info.Platform != "" && info.Platform != OSWindows && info.Platform != OSMacOS && info.Platform != "linux" && info.Platform != "any" {
		return SetInfo{}, nil, fmt.Errorf("%s: platform %q is not one of windows, macos, linux, any", path, bs.Platform)
	}

	out := make([]LoadedBinding, 0, len(bs.Bindings))
	seenAction := map[string]int{}
	for i, b := range bs.Bindings {
		n := i + 1
		action := strings.TrimSpace(b.Action)
		if action == "" {
			return SetInfo{}, nil, fmt.Errorf("%s: binding %d has an empty action", path, n)
		}
		if prev, dup := seenAction[action]; dup {
			return SetInfo{}, nil, fmt.Errorf("%s: set %q binds action %q twice (bindings %d and %d)", path, name, action, prev, n)
		}
		seenAction[action] = n
		seq, err := ParseSequence(b.Chord)
		if err != nil {
			return SetInfo{}, nil, fmt.Errorf("%s: binding %d (action %q): %v", path, n, action, err)
		}
		out = append(out, LoadedBinding{
			Set:      name,
			File:     path,
			Action:   action,
			Raw:      strings.TrimSpace(b.Chord),
			Seq:      seq,
			Canon:    seq.String(),
			Override: b.Override,
			Note:     strings.TrimSpace(b.Note),
			IsBase:   isBase,
			Index:    n,
		})
	}
	info.Count = len(out)
	return info, out, nil
}

// Inputs is everything the analysis commands need from disk.
type Inputs struct {
	Sets     []SetInfo
	Bindings []LoadedBinding
	BaseFile string
	BaseSet  string
}

// loadInputs loads an optional base file plus every personal set. Set names
// must be unique, because the merge tie-break is by set name.
func loadInputs(basePath string, setPaths []string) (Inputs, error) {
	var in Inputs
	byName := map[string]string{}

	load := func(p string, isBase bool) error {
		info, bs, err := loadSetFile(p, isBase)
		if err != nil {
			return err
		}
		if prev, dup := byName[info.Name]; dup {
			return fmt.Errorf("set name %q is declared by both %s and %s - names must be unique because merge ties break on them", info.Name, prev, p)
		}
		byName[info.Name] = p
		in.Sets = append(in.Sets, info)
		in.Bindings = append(in.Bindings, bs...)
		if isBase {
			in.BaseFile = p
			in.BaseSet = info.Name
		}
		return nil
	}

	if basePath != "" {
		if err := load(basePath, true); err != nil {
			return Inputs{}, err
		}
	}
	seenPath := map[string]bool{}
	for _, p := range setPaths {
		abs, err := filepath.Abs(p)
		if err != nil {
			return Inputs{}, fmt.Errorf("cannot resolve %q: %v", p, err)
		}
		if seenPath[abs] {
			continue
		}
		seenPath[abs] = true
		if basePath != "" {
			babs, _ := filepath.Abs(basePath)
			if babs == abs {
				return Inputs{}, fmt.Errorf("%s is passed as both --base and --set", p)
			}
		}
		if err := load(p, false); err != nil {
			return Inputs{}, err
		}
	}
	if len(in.Bindings) == 0 {
		return Inputs{}, fmt.Errorf("no bindings were loaded")
	}
	sort.SliceStable(in.Sets, func(i, j int) bool {
		if in.Sets[i].IsBase != in.Sets[j].IsBase {
			return in.Sets[i].IsBase
		}
		return in.Sets[i].Name < in.Sets[j].Name
	})
	return in, nil
}

// setNames returns the loaded set names, base first then alphabetical.
func (in Inputs) setNames() []string {
	out := make([]string, 0, len(in.Sets))
	for _, s := range in.Sets {
		out = append(out, s.Name)
	}
	return out
}
