// Package storage provides file-based persistence for HMI interface definitions.
package storage

import (
	"encoding/xml"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"sort"
	"strconv"
	"strings"
	"time"
	"unicode"
)

// ErrNotFound is returned when the requested interface does not exist.
var ErrNotFound = errors.New("interface not found")

// InterfaceMeta is the lightweight representation returned by List.
type InterfaceMeta struct {
	ID      string `json:"id"`
	Name    string `json:"name"`
	Version int    `json:"version"`
	// Kind is "plot" for split-layout plot panels, empty for free-form panels.
	Kind string `json:"kind,omitempty"`
}

// VersionMeta describes a single persisted revision of an interface.
type VersionMeta struct {
	Version int       `json:"version"`
	Name    string    `json:"name"`
	Tag     string    `json:"tag,omitempty"`
	Current bool      `json:"current"`
	SavedAt time.Time `json:"savedAt"`
}

// Store persists interface definitions as XML files under {storageDir}/interfaces/
// and workspace-level JSON blobs directly in {storageDir}.
type Store struct {
	rootDir string // {storageDir} — for workspace-level files (groups.json, etc.)
	dir     string // {storageDir}/interfaces
}

// New opens (and creates, if needed) the storage directory.
func New(storageDir string) (*Store, error) {
	dir := filepath.Join(storageDir, "interfaces")
	if err := os.MkdirAll(dir, 0o755); err != nil {
		return nil, fmt.Errorf("create interfaces dir: %w", err)
	}
	return &Store{rootDir: storageDir, dir: dir}, nil
}

// ReadGroups returns the raw JSON array of group tree nodes.
// Returns an empty JSON array if no groups have been saved yet.
func (s *Store) ReadGroups() ([]byte, error) {
	data, err := os.ReadFile(filepath.Join(s.rootDir, "groups.json"))
	if errors.Is(err, os.ErrNotExist) {
		return []byte("[]"), nil
	}
	return data, err
}

// WriteGroups persists the group tree. data must be a valid JSON array.
func (s *Store) WriteGroups(data []byte) error {
	return os.WriteFile(filepath.Join(s.rootDir, "groups.json"), data, 0o644)
}

// validateID returns an error if id could be used for path traversal or is
// otherwise unsafe.  Valid IDs contain only letters, digits, hyphens, and
// underscores.
func validateID(id string) error {
	if id == "" {
		return fmt.Errorf("interface ID must not be empty")
	}
	for _, r := range id {
		if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '-' && r != '_' {
			return fmt.Errorf("invalid character %q in interface ID", r)
		}
	}
	return nil
}

func (s *Store) filePath(id string) string {
	return filepath.Join(s.dir, id+".xml")
}

// List returns metadata for every stored interface.
func (s *Store) List() ([]InterfaceMeta, error) {
	entries, err := os.ReadDir(s.dir)
	if err != nil {
		return nil, err
	}
	var out []InterfaceMeta
	for _, e := range entries {
		name := e.Name()
		if e.IsDir() || !strings.HasSuffix(strings.ToLower(name), ".xml") {
			continue
		}
		// Skip versioned backups: id.v1.xml, id.v2.xml, etc.
		if isVersioned(name) {
			continue
		}

		id := strings.TrimSuffix(name, filepath.Ext(name))
		meta, err := s.readMeta(id)
		if err != nil {
			continue // skip corrupt files silently
		}
		out = append(out, meta)
	}
	if out == nil {
		out = []InterfaceMeta{}
	}
	return out, nil
}

func isVersioned(name string) bool {
	// Format: <id>.v<number>.xml
	parts := strings.Split(name, ".")
	if len(parts) != 3 {
		return false
	}
	vPart := parts[1]
	if !strings.HasPrefix(vPart, "v") {
		return false
	}
	_, err := strconv.Atoi(vPart[1:])
	return err == nil
}

// rootAttrs is used to parse only the top-level XML attributes.
type rootAttrs struct {
	XMLName xml.Name `xml:"interface"`
	ID      string   `xml:"id,attr"`
	Name    string   `xml:"name,attr"`
	Version int      `xml:"version,attr"`
	Tag     string   `xml:"tag,attr"`
	Kind    string   `xml:"kind,attr"`
}

func (s *Store) readMeta(id string) (InterfaceMeta, error) {
	data, err := os.ReadFile(s.filePath(id))
	if err != nil {
		return InterfaceMeta{}, err
	}
	var root rootAttrs
	if err := xml.Unmarshal(data, &root); err != nil {
		return InterfaceMeta{}, err
	}
	return InterfaceMeta{ID: id, Name: root.Name, Version: root.Version, Kind: root.Kind}, nil
}

// Get returns the raw XML bytes for the interface with the given ID.
func (s *Store) Get(id string) ([]byte, error) {
	if err := validateID(id); err != nil {
		return nil, fmt.Errorf("%w: %v", ErrNotFound, err)
	}
	data, err := os.ReadFile(s.filePath(id))
	if errors.Is(err, os.ErrNotExist) {
		return nil, ErrNotFound
	}
	return data, err
}

// Create stores new interface XML and returns the generated ID.
// The ID is derived from the name attribute; a timestamp suffix is added to
// ensure uniqueness. If tag is non-empty it is stamped as the revision label.
func (s *Store) Create(xmlData []byte, tag string) (string, error) {
	var root rootAttrs
	if err := xml.Unmarshal(xmlData, &root); err != nil {
		return "", fmt.Errorf("invalid XML: %w", err)
	}

	id := root.ID
	if id == "" {
		id = slugify(root.Name)
	}
	// Guarantee uniqueness.
	if _, err := os.Stat(s.filePath(id)); err == nil {
		id = id + "-" + fmt.Sprintf("%d", time.Now().UnixMilli())
	}

	// Initialize version to 1 and stamp the generated ID so the persisted XML
	// is self-consistent (the client sends an empty id for new interfaces).
	updatedData, err := setXMLAttribute(xmlData, "version", "1")
	if err != nil {
		return "", fmt.Errorf("initialize version attribute: %w", err)
	}
	updatedData, err = setXMLAttribute(updatedData, "id", id)
	if err != nil {
		return "", fmt.Errorf("stamp id attribute: %w", err)
	}
	if tag != "" {
		updatedData, err = setXMLAttribute(updatedData, "tag", tag)
		if err != nil {
			return "", fmt.Errorf("set tag attribute: %w", err)
		}
	}

	return id, os.WriteFile(s.filePath(id), updatedData, 0o644)
}

// Update replaces the XML for an existing interface, preserving the previous
// version as a separate file. If tag is non-empty it is stamped as the label
// for the new revision.
func (s *Store) Update(id string, xmlData []byte, tag string) error {
	if err := validateID(id); err != nil {
		return ErrNotFound
	}

	oldPath := s.filePath(id)
	oldData, err := os.ReadFile(oldPath)
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return ErrNotFound
		}
		return err
	}

	// Parse current version to name the backup file.
	var oldRoot rootAttrs
	if err := xml.Unmarshal(oldData, &oldRoot); err != nil {
		return fmt.Errorf("parse existing XML: %w", err)
	}
	if oldRoot.Version < 1 {
		oldRoot.Version = 1
	}

	// Move old file to backup path: id.vN.xml
	backupPath := filepath.Join(s.dir, fmt.Sprintf("%s.v%d.xml", id, oldRoot.Version))
	if err := os.WriteFile(backupPath, oldData, 0o644); err != nil {
		return fmt.Errorf("create backup: %w", err)
	}

	// Update version in new data.
	newVersion := oldRoot.Version + 1
	updatedData, err := setXMLAttribute(xmlData, "version", fmt.Sprintf("%d", newVersion))
	if err != nil {
		return fmt.Errorf("update version attribute: %w", err)
	}
	if tag != "" {
		updatedData, err = setXMLAttribute(updatedData, "tag", tag)
		if err != nil {
			return fmt.Errorf("set tag attribute: %w", err)
		}
	}

	return os.WriteFile(oldPath, updatedData, 0o644)
}

// Versions returns metadata for every persisted revision of the interface,
// ordered newest-first. The current file is flagged with Current=true.
func (s *Store) Versions(id string) ([]VersionMeta, error) {
	if err := validateID(id); err != nil {
		return nil, ErrNotFound
	}

	// Current revision.
	curInfo, err := os.Stat(s.filePath(id))
	if errors.Is(err, os.ErrNotExist) {
		return nil, ErrNotFound
	} else if err != nil {
		return nil, err
	}
	curRoot, err := s.readRoot(s.filePath(id))
	if err != nil {
		return nil, err
	}

	out := []VersionMeta{{
		Version: curRoot.Version,
		Name:    curRoot.Name,
		Tag:     curRoot.Tag,
		Current: true,
		SavedAt: curInfo.ModTime(),
	}}

	// Backup revisions: id.vN.xml
	entries, err := os.ReadDir(s.dir)
	if err != nil {
		return nil, err
	}
	prefix := id + ".v"
	for _, e := range entries {
		name := e.Name()
		if e.IsDir() || !strings.HasPrefix(name, prefix) || !strings.HasSuffix(name, ".xml") {
			continue
		}
		if !isVersioned(name) {
			continue
		}
		p := filepath.Join(s.dir, name)
		info, err := e.Info()
		if err != nil {
			continue
		}
		root, err := s.readRoot(p)
		if err != nil {
			continue
		}
		out = append(out, VersionMeta{
			Version: root.Version,
			Name:    root.Name,
			Tag:     root.Tag,
			SavedAt: info.ModTime(),
		})
	}

	sort.Slice(out, func(i, j int) bool { return out[i].Version > out[j].Version })
	return out, nil
}

// GetVersion returns the raw XML bytes for a specific revision of an interface.
func (s *Store) GetVersion(id string, version int) ([]byte, error) {
	if err := validateID(id); err != nil {
		return nil, ErrNotFound
	}

	// The current file may hold the requested version.
	curRoot, err := s.readRoot(s.filePath(id))
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return nil, ErrNotFound
		}
		return nil, err
	}
	if curRoot.Version == version {
		return os.ReadFile(s.filePath(id))
	}

	backupPath := filepath.Join(s.dir, fmt.Sprintf("%s.v%d.xml", id, version))
	data, err := os.ReadFile(backupPath)
	if errors.Is(err, os.ErrNotExist) {
		return nil, ErrNotFound
	}
	return data, err
}

// versionPath returns the on-disk path for a specific revision, resolving the
// current file when version matches the live revision.
func (s *Store) versionPath(id string, version int) (string, error) {
	cur := s.filePath(id)
	curRoot, err := s.readRoot(cur)
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return "", ErrNotFound
		}
		return "", err
	}
	if curRoot.Version == version {
		return cur, nil
	}
	backup := filepath.Join(s.dir, fmt.Sprintf("%s.v%d.xml", id, version))
	if _, err := os.Stat(backup); err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return "", ErrNotFound
		}
		return "", err
	}
	return backup, nil
}

// SetVersionTag sets (or clears, when tag is empty) the label of a specific
// revision in place, without creating a new revision.
func (s *Store) SetVersionTag(id string, version int, tag string) error {
	if err := validateID(id); err != nil {
		return ErrNotFound
	}
	path, err := s.versionPath(id, version)
	if err != nil {
		return err
	}
	data, err := os.ReadFile(path)
	if err != nil {
		return err
	}
	updated, err := setXMLAttribute(data, "tag", tag)
	if err != nil {
		return fmt.Errorf("set tag attribute: %w", err)
	}
	return os.WriteFile(path, updated, 0o644)
}

// Promote makes a past revision the current one by re-saving its content as a
// new revision on top of history. The existing current revision is preserved
// as a backup, so promotion is non-destructive.
func (s *Store) Promote(id string, version int) error {
	data, err := s.GetVersion(id, version)
	if err != nil {
		return err
	}
	return s.Update(id, data, fmt.Sprintf("restored from v%d", version))
}

// Fork creates a brand-new interface from the content of a specific revision,
// assigning a fresh unique ID and resetting its version to 1.
func (s *Store) Fork(id string, version int) (string, error) {
	data, err := s.GetVersion(id, version)
	if err != nil {
		return "", err
	}
	newID := id + "-fork-" + fmt.Sprintf("%d", time.Now().UnixMilli())
	updated, err := setXMLAttribute(data, "version", "1")
	if err != nil {
		return "", fmt.Errorf("reset version attribute: %w", err)
	}
	updated, err = setXMLAttribute(updated, "id", newID)
	if err != nil {
		return "", fmt.Errorf("stamp id attribute: %w", err)
	}
	updated, err = setXMLAttribute(updated, "tag", "")
	if err != nil {
		return "", fmt.Errorf("clear tag attribute: %w", err)
	}
	return newID, os.WriteFile(s.filePath(newID), updated, 0o644)
}

// readRoot parses the top-level interface attributes from the file at path.
func (s *Store) readRoot(path string) (rootAttrs, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return rootAttrs{}, err
	}
	var root rootAttrs
	if err := xml.Unmarshal(data, &root); err != nil {
		return rootAttrs{}, err
	}
	return root, nil
}

// setXMLAttribute is a primitive helper to update a top-level attribute in an
// XML blob without full unmarshal/marshal (which might mess up formatting).
func setXMLAttribute(data []byte, attr, value string) ([]byte, error) {
	// Very basic implementation: look for the attribute in the first 512 bytes.
	// A proper implementation would use an XML encoder or a better regex.
	s := string(data)

	// Locate the root element's opening tag, skipping any XML declaration
	// (<?xml ... ?>), comments, or processing instructions. Operating on the
	// first '>' in the document would match the '?>' of the XML declaration and
	// corrupt its version attribute (producing invalid XML).
	tagStart := -1
	for i := 0; i+1 < len(s); i++ {
		if s[i] == '<' && s[i+1] != '?' && s[i+1] != '!' {
			tagStart = i
			break
		}
	}
	if tagStart == -1 {
		return nil, errors.New("malformed XML: no root element")
	}
	rel := strings.Index(s[tagStart:], ">")
	if rel == -1 {
		return nil, errors.New("malformed XML: no root tag end")
	}
	tagEnd := tagStart + rel
	rootTag := s[tagStart:tagEnd]

	attrSearch := " " + attr + "=\""
	idx := strings.Index(rootTag, attrSearch)
	if idx == -1 {
		// Attribute not found, insert it before the tag ends.
		insertIdx := tagEnd
		if s[tagEnd-1] == '/' {
			insertIdx-- // handle <tag />
		}
		return []byte(s[:insertIdx] + fmt.Sprintf(" %s=\"%s\"", attr, value) + s[insertIdx:]), nil
	}

	// Attribute found, replace its value (idx is relative to rootTag).
	valStart := tagStart + idx + len(attrSearch)
	valEnd := strings.Index(s[valStart:], "\"")
	if valEnd == -1 {
		return nil, errors.New("malformed XML: attribute value not closed")
	}
	return []byte(s[:valStart] + value + s[valStart+valEnd:]), nil
}

// Delete moves the interface with the given ID — and all of its version
// backups — into a timestamped trash folder so it can be recovered if needed.
// Nothing is destroyed; recovery is a matter of moving the files back.
func (s *Store) Delete(id string) error {
	if err := validateID(id); err != nil {
		return ErrNotFound
	}
	if _, err := os.Stat(s.filePath(id)); err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return ErrNotFound
		}
		return err
	}

	trashDir := filepath.Join(s.rootDir, "trash", "interfaces", fmt.Sprintf("%s.%d", id, time.Now().UnixMilli()))
	if err := os.MkdirAll(trashDir, 0o755); err != nil {
		return fmt.Errorf("create trash dir: %w", err)
	}

	// Move the current file plus every versioned backup (id.vN.xml), preserving
	// their original names so a restore is a straight move-back.
	entries, err := os.ReadDir(s.dir)
	if err != nil {
		return err
	}
	for _, e := range entries {
		name := e.Name()
		if e.IsDir() {
			continue
		}
		if name == id+".xml" || (strings.HasPrefix(name, id+".v") && isVersioned(name)) {
			if err := os.Rename(filepath.Join(s.dir, name), filepath.Join(trashDir, name)); err != nil {
				return fmt.Errorf("move %s to trash: %w", name, err)
			}
		}
	}
	return nil
}

// Clone duplicates an interface, assigning a new unique ID.
func (s *Store) Clone(srcID string) (string, error) {
	if err := validateID(srcID); err != nil {
		return "", ErrNotFound
	}
	data, err := s.Get(srcID)
	if err != nil {
		return "", err
	}
	newID := srcID + "-copy-" + fmt.Sprintf("%d", time.Now().UnixMilli())
	return newID, os.WriteFile(s.filePath(newID), data, 0o644)
}

// slugify converts a human-readable name into a URL-safe identifier.
func slugify(s string) string {
	var b strings.Builder
	for _, r := range strings.ToLower(s) {
		switch {
		case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
			b.WriteRune(r)
		default:
			if b.Len() > 0 && b.String()[b.Len()-1] != '-' {
				b.WriteByte('-')
			}
		}
	}
	result := strings.Trim(b.String(), "-")
	if result == "" {
		return "interface"
	}
	return result
}
