// Package catalog loads, validates, resolves, and queries the nest tool
// catalog (AGENTS.md §2).
package catalog

import (
	"errors"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"strconv"
	"strings"

	"gopkg.in/yaml.v3"
)

const (
	// SupportedVersion is the only catalog schema version this build accepts.
	SupportedVersion = 1
	// SystemPath is the package-managed, read-only catalog location (§2.1).
	SystemPath = "/usr/share/nest/tools.yaml"
	// DefaultURL is the pinned HTTPS location `nest refresh` fetches from (§4.6).
	DefaultURL = "https://raw.githubusercontent.com/parrotsec/nest/main/tools.yaml"
)

// ErrNotExist marks a missing catalog file, distinct from a malformed one.
var ErrNotExist = errors.New("catalog file does not exist")

// Container holds the tool-specific options appended to nest's own runtime
// flags on container runs (§2.2).
type Container struct {
	Network   string   `yaml:"network" json:"network"`
	CapAdd    []string `yaml:"cap_add" json:"cap_add"`
	ExtraArgs []string `yaml:"extra_args" json:"extra_args"`
}

// Tool is a single catalog entry.
type Tool struct {
	Name        string     `yaml:"name" json:"name"`
	Description string     `yaml:"description" json:"description"`
	Package     string     `yaml:"package" json:"package"`
	Executables []string   `yaml:"executables" json:"-"`
	Images      []string   `yaml:"images" json:"images"`
	Container   *Container `yaml:"container" json:"container"`
	Tags        []string   `yaml:"tags" json:"tags"`
}

// Catalog is the parsed tools.yaml document.
type Catalog struct {
	Version int    `yaml:"version" json:"version"`
	Tools   []Tool `yaml:"tools" json:"tools"`
}

// Locations are the filesystem paths of the two catalog copies (§2.1).
type Locations struct {
	User   string
	System string
}

// DefaultLocations computes the standard copy locations, honoring XDG.
func DefaultLocations(getenv func(string) string) Locations {
	dataHome := getenv("XDG_DATA_HOME")
	if dataHome == "" {
		dataHome = filepath.Join(getenv("HOME"), ".local", "share")
	}
	return Locations{
		User:   filepath.Join(dataHome, "nest", "tools.yaml"),
		System: SystemPath,
	}
}

// Load reads and validates the catalog at path.
func Load(path string) (*Catalog, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		if os.IsNotExist(err) {
			return nil, fmt.Errorf("%w: %s", ErrNotExist, path)
		}
		return nil, fmt.Errorf("cannot read catalog %s: %w", path, err)
	}
	return Parse(data)
}

// Parse validates raw catalog YAML and returns the catalog.
func Parse(data []byte) (*Catalog, error) {
	var c Catalog
	if err := yaml.Unmarshal(data, &c); err != nil {
		return nil, fmt.Errorf("invalid catalog YAML: %w", err)
	}
	if err := c.Validate(); err != nil {
		return nil, err
	}
	c.normalize()
	return &c, nil
}

// Validate checks the structural rules of §2.3 and reports every problem found.
func (c *Catalog) Validate() error {
	var problems []string
	if c.Version != SupportedVersion {
		problems = append(problems, fmt.Sprintf("unsupported catalog version %d (expected %d)", c.Version, SupportedVersion))
	}
	seen := make(map[string]bool, len(c.Tools))
	for i, t := range c.Tools {
		where := "tools[" + strconv.Itoa(i) + "]"
		if t.Name != "" {
			where = "tool " + strconv.Quote(t.Name)
		}
		if t.Name == "" {
			problems = append(problems, where+": missing name")
		} else if seen[t.Name] {
			problems = append(problems, where+": duplicate name")
		}
		seen[t.Name] = true
		if t.Description == "" {
			problems = append(problems, where+": missing description")
		}
		if t.Package == "" {
			problems = append(problems, where+": missing package")
		}
		for _, exe := range t.Executables {
			if !filepath.IsAbs(exe) {
				problems = append(problems, where+": executable "+strconv.Quote(exe)+" is not an absolute path")
			}
		}
		for _, img := range t.Images {
			if img == "" {
				problems = append(problems, where+": empty image reference")
			}
		}
	}
	if len(problems) > 0 {
		return fmt.Errorf("invalid catalog:\n  - %s", strings.Join(problems, "\n  - "))
	}
	return nil
}

// normalize applies schema defaults (§2.3) and replaces nil slices with empty
// ones so JSON output never emits null for collections (§4.8 key-presence rule).
func (c *Catalog) normalize() {
	for i := range c.Tools {
		t := &c.Tools[i]
		if len(t.Executables) == 0 {
			t.Executables = []string{"/usr/bin/" + t.Name}
		}
		if t.Images == nil {
			t.Images = []string{}
		}
		if t.Tags == nil {
			t.Tags = []string{}
		}
		if t.Container != nil {
			if t.Container.CapAdd == nil {
				t.Container.CapAdd = []string{}
			}
			if t.Container.ExtraArgs == nil {
				t.Container.ExtraArgs = []string{}
			}
		}
	}
}

// HasImage reports whether the tool has a container image available (§2.3).
func (t *Tool) HasImage() bool {
	return len(t.Images) > 0
}

// Resolve picks the effective catalog copy per §2.1: an explicit override wins;
// otherwise the valid copy with the highest version, user copy winning ties.
// A malformed user copy is ignored with a warning; a malformed system copy, or
// no catalog at all, is a fatal error.
func Resolve(override string, loc Locations, stderr io.Writer) (*Catalog, string, error) {
	if override != "" {
		c, err := Load(override)
		if err != nil {
			return nil, "", fmt.Errorf("cannot load catalog from %s: %w", override, err)
		}
		return c, override, nil
	}

	user, userErr := Load(loc.User)
	if userErr != nil {
		user = nil
		if !errors.Is(userErr, ErrNotExist) {
			fmt.Fprintf(stderr, "warning: ignoring malformed user catalog %s: %v\n", loc.User, userErr)
		}
	}

	sys, sysErr := Load(loc.System)
	if sysErr != nil {
		if errors.Is(sysErr, ErrNotExist) {
			if user == nil {
				return nil, "", fmt.Errorf("no catalog found (looked in %s and %s)", loc.User, loc.System)
			}
			return user, loc.User, nil
		}
		return nil, "", fmt.Errorf("system catalog %s is invalid: %w", loc.System, sysErr)
	}

	if user != nil && user.Version >= sys.Version {
		return user, loc.User, nil
	}
	return sys, loc.System, nil
}

// Find returns the tool with the given name.
func (c *Catalog) Find(name string) (*Tool, bool) {
	for i := range c.Tools {
		if c.Tools[i].Name == name {
			return &c.Tools[i], true
		}
	}
	return nil, false
}

// Search returns tools matching term, case-insensitively, over name,
// description, package, and tags (§4.2).
func (c *Catalog) Search(term string) []*Tool {
	term = strings.ToLower(term)
	var matches []*Tool
	for i := range c.Tools {
		t := &c.Tools[i]
		if strings.Contains(strings.ToLower(t.Name), term) ||
			strings.Contains(strings.ToLower(t.Description), term) ||
			strings.Contains(strings.ToLower(t.Package), term) ||
			tagMatch(t.Tags, term) {
			matches = append(matches, t)
		}
	}
	return matches
}

func tagMatch(tags []string, term string) bool {
	for _, tag := range tags {
		if strings.Contains(strings.ToLower(tag), term) {
			return true
		}
	}
	return false
}
