// Package release — формат манифеста релиза артефактории и его подпись
// Ed25519. Используется тремя сторонами:
//
//   - cmd/bj-release   — собирает манифест из артефактов и подписывает;
//   - cmd/bj-artifactory — отдаёт манифест и файлы по HTTP;
//   - bj-server (auto-update) — скачивает манифест, проверяет подпись,
//     сравнивает версии и обновляет компоненты.
//
// Доверие строится на одном корневом Ed25519-ключе: приватный держит
// издатель (мы), публичный зашит в клиента (bj-server) и в установщик.
// Манифест подписывается целиком в каноничном виде — клиент проверяет
// подпись ДО того, как доверять версиям и хешам артефактов.
package release

import (
	"crypto/ed25519"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"os"
	"time"
)

// CurrentSchema — версия формата манифеста (на случай эволюции).
const CurrentSchema = 1

// Artifact — один распространяемый файл (бинарь/скрипт/архив).
type Artifact struct {
	Name    string `json:"name"`              // логическое имя: "bj-server", "install-validata.sh"
	File    string `json:"file"`              // имя файла в хранилище
	Version string `json:"version,omitempty"` // версия компонента (если своя)
	SHA256  string `json:"sha256"`            // hex-хеш содержимого
	Size    int64  `json:"size"`              // размер в байтах
	Exec    bool   `json:"exec,omitempty"`    // ставить ли +x при установке
}

// Manifest — описание релиза. Подписывается целиком (каноничный JSON).
type Manifest struct {
	Schema     int        `json:"schema"`
	Version    string     `json:"version"`            // версия релиза, напр. "1.2.0"
	Channel    string     `json:"channel"`            // "stable" | "beta"
	ReleasedAt time.Time  `json:"released_at"`        // время сборки релиза
	Notes      string     `json:"notes,omitempty"`    // что нового (для UI)
	MinVersion string     `json:"min_version,omitempty"` // мин. версия для прямого апгрейда
	Artifacts  []Artifact `json:"artifacts"`
}

// SignedManifest — манифест + отделённая подпись. Именно это лежит в
// /v1/manifest.json и скачивается клиентом.
//
// Payload хранится как base64 от каноничного JSON Manifest — НЕ как
// вложенный JSON. Иначе json.MarshalIndent переформатировал бы содержимое
// и подписанные байты разошлись бы с прочитанными.
type SignedManifest struct {
	Payload   string `json:"payload"`   // base64(каноничный JSON Manifest)
	Signature string `json:"signature"` // base64(ed25519 over каноничным JSON)
	KeyID     string `json:"key_id"`    // идентификатор публичного ключа
}

// Canonical сериализует манифест детерминированно (для подписи/проверки).
// json.Marshal в Go стабилен по порядку полей структуры — этого достаточно,
// т.к. подписываем и проверяем одни и те же байты Payload.
func (m *Manifest) Canonical() ([]byte, error) {
	if m.Schema == 0 {
		m.Schema = CurrentSchema
	}
	return json.Marshal(m)
}

// Sign подписывает манифест приватным ключом и возвращает SignedManifest.
func Sign(m *Manifest, priv ed25519.PrivateKey, keyID string) (*SignedManifest, error) {
	payload, err := m.Canonical()
	if err != nil {
		return nil, fmt.Errorf("release: canonical: %w", err)
	}
	sig := ed25519.Sign(priv, payload)
	return &SignedManifest{
		Payload:   base64.StdEncoding.EncodeToString(payload),
		Signature: base64.StdEncoding.EncodeToString(sig),
		KeyID:     keyID,
	}, nil
}

// Verify проверяет подпись и возвращает разобранный Manifest. Это
// единственный способ получить доверенный Manifest на стороне клиента.
func Verify(sm *SignedManifest, pub ed25519.PublicKey) (*Manifest, error) {
	sig, err := base64.StdEncoding.DecodeString(sm.Signature)
	if err != nil {
		return nil, fmt.Errorf("release: decode signature: %w", err)
	}
	payload, err := base64.StdEncoding.DecodeString(sm.Payload)
	if err != nil {
		return nil, fmt.Errorf("release: decode payload: %w", err)
	}
	if !ed25519.Verify(pub, payload, sig) {
		return nil, errors.New("release: подпись манифеста недействительна")
	}
	var m Manifest
	if err := json.Unmarshal(payload, &m); err != nil {
		return nil, fmt.Errorf("release: unmarshal payload: %w", err)
	}
	if m.Schema != CurrentSchema {
		return nil, fmt.Errorf("release: неподдерживаемая схема манифеста %d (ожидается %d)", m.Schema, CurrentSchema)
	}
	return &m, nil
}

// HashFile считает sha256 (hex) и размер файла — для заполнения Artifact.
func HashFile(path string) (sha string, size int64, err error) {
	f, err := os.Open(path)
	if err != nil {
		return "", 0, err
	}
	defer f.Close()
	h := sha256.New()
	n, err := io.Copy(h, f)
	if err != nil {
		return "", 0, err
	}
	return hex.EncodeToString(h.Sum(nil)), n, nil
}

// VerifyArtifact проверяет, что файл по пути совпадает с Artifact (хеш+размер).
func VerifyArtifact(path string, a Artifact) error {
	sha, size, err := HashFile(path)
	if err != nil {
		return err
	}
	if size != a.Size {
		return fmt.Errorf("release: размер %s не совпал: %d != %d", a.Name, size, a.Size)
	}
	if sha != a.SHA256 {
		return fmt.Errorf("release: sha256 %s не совпал", a.Name)
	}
	return nil
}

// --- Ключи ---

// LoadPrivateKey читает Ed25519 приватный ключ из файла (base64 seed, 32 байта).
func LoadPrivateKey(path string) (ed25519.PrivateKey, error) {
	b, err := os.ReadFile(path)
	if err != nil {
		return nil, err
	}
	seed, err := base64.StdEncoding.DecodeString(stringsTrim(string(b)))
	if err != nil {
		return nil, fmt.Errorf("release: decode seed: %w", err)
	}
	if len(seed) != ed25519.SeedSize {
		return nil, fmt.Errorf("release: неверный размер seed %d (ожидается %d)", len(seed), ed25519.SeedSize)
	}
	return ed25519.NewKeyFromSeed(seed), nil
}

// LoadPublicKey читает Ed25519 публичный ключ из файла (base64, 32 байта).
func LoadPublicKey(path string) (ed25519.PublicKey, error) {
	b, err := os.ReadFile(path)
	if err != nil {
		return nil, err
	}
	pub, err := base64.StdEncoding.DecodeString(stringsTrim(string(b)))
	if err != nil {
		return nil, fmt.Errorf("release: decode pubkey: %w", err)
	}
	if len(pub) != ed25519.PublicKeySize {
		return nil, fmt.Errorf("release: неверный размер pubkey %d", len(pub))
	}
	return ed25519.PublicKey(pub), nil
}

// ParsePublicKey разбирает публичный ключ из base64-строки (для зашитого в клиента).
func ParsePublicKey(b64 string) (ed25519.PublicKey, error) {
	pub, err := base64.StdEncoding.DecodeString(stringsTrim(b64))
	if err != nil {
		return nil, err
	}
	if len(pub) != ed25519.PublicKeySize {
		return nil, fmt.Errorf("release: неверный размер pubkey %d", len(pub))
	}
	return ed25519.PublicKey(pub), nil
}

func stringsTrim(s string) string {
	// убираем пробелы/переводы строк вокруг base64
	start, end := 0, len(s)
	for start < end && (s[start] == ' ' || s[start] == '\n' || s[start] == '\r' || s[start] == '\t') {
		start++
	}
	for end > start && (s[end-1] == ' ' || s[end-1] == '\n' || s[end-1] == '\r' || s[end-1] == '\t') {
		end--
	}
	return s[start:end]
}
