// Package endpoint defines the endpoint-distribution payload (the api_domains /
// mirror_urls / emergency / notice / channel bundle) that the client merges
// into its endpoint pool, plus normalization, validation and anti-rollback
// verification on top of the sign envelope.
//
// See infra/domains/CLIENT-CONTRACT.md for the consuming contract.
package endpoint

import (
	"encoding/json"
	"errors"
	"fmt"
	"net/url"
	"sort"
	"strings"

	"github.com/wangjia/pangolin/infra/domains/tools/internal/notice"
	"github.com/wangjia/pangolin/infra/domains/tools/internal/sign"
)

// ErrRollback is returned when a document's version is not strictly greater than
// the version the client already trusts (downgrade / replay protection).
var ErrRollback = errors.New("endpoint: document version is not newer than current (rollback rejected)")

// Payload is the signed body of an endpoints document.
type Payload struct {
	// APIDomains is the ordered API domain pool the client should try (doc/05 §3).
	APIDomains []string `json:"api_domains"`
	// MirrorURLs are the full URLs (≥3) where the next signed document lives.
	MirrorURLs []string `json:"mirror_urls"`
	// EmergencyNodesHint is an optional opaque hint pointing the client at where
	// to fetch emergency node parameters; never the parameters themselves.
	EmergencyNodesHint []string `json:"emergency_nodes_hint,omitempty"`
	// Notice is an optional inline announcement (same schema as /v1/notices).
	Notice *notice.Notice `json:"notice,omitempty"`
	// Channel scopes a document to a distribution channel so sensitive built-in
	// parameters can be rotated per package (doc/06 §3 客户端). Empty = all.
	Channel string `json:"channel,omitempty"`
}

// Normalize trims, lowercases and de-duplicates domains, and de-duplicates
// mirror URLs, producing a stable ordering so re-signing identical input yields
// identical bytes.
func (p *Payload) Normalize() {
	p.APIDomains = normalizeHosts(p.APIDomains)
	p.MirrorURLs = dedupSorted(strings.TrimSpace, p.MirrorURLs)
	p.EmergencyNodesHint = dedupSorted(strings.TrimSpace, p.EmergencyNodesHint)
	p.Channel = strings.TrimSpace(p.Channel)
}

// Validate enforces the schema invariants the client relies on.
func (p Payload) Validate() error {
	if len(p.APIDomains) == 0 {
		return fmt.Errorf("endpoint: api_domains must contain at least one domain")
	}
	for _, d := range p.APIDomains {
		if !isHostname(d) {
			return fmt.Errorf("endpoint: %q is not a valid hostname", d)
		}
	}
	if len(p.MirrorURLs) < 1 {
		return fmt.Errorf("endpoint: mirror_urls must contain at least one URL")
	}
	for _, m := range p.MirrorURLs {
		u, err := url.Parse(m)
		if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" {
			return fmt.Errorf("endpoint: mirror_url %q must be an absolute http(s) URL", m)
		}
	}
	if p.Notice != nil {
		if err := p.Notice.Validate(); err != nil {
			return err
		}
	}
	return nil
}

// Build normalizes and validates p, then wraps it into a signed envelope and
// signs it. priv is the offline private key; keyID selects the verifying key.
func Build(p Payload, keyID string, version uint64, issuedAt string, priv []byte) (sign.Envelope, error) {
	p.Normalize()
	if err := p.Validate(); err != nil {
		return sign.Envelope{}, err
	}
	raw, err := json.Marshal(p)
	if err != nil {
		return sign.Envelope{}, err
	}
	env := sign.Envelope{
		Version:  version,
		IssuedAt: issuedAt,
		KeyID:    keyID,
		Payload:  raw,
	}
	if err := sign.Sign(priv, &env); err != nil {
		return sign.Envelope{}, err
	}
	return env, nil
}

// Decode parses the payload out of a (already-verified) envelope.
func Decode(env sign.Envelope) (Payload, error) {
	var p Payload
	if err := json.Unmarshal(env.Payload, &p); err != nil {
		return Payload{}, fmt.Errorf("endpoint: cannot decode payload: %w", err)
	}
	return p, nil
}

// VerifyDocument runs the full client-side acceptance check on raw bytes:
//  1. signature valid under one of the ring's keys (supports key rotation),
//  2. version strictly greater than currentVersion (anti-rollback),
//  3. payload passes schema validation.
//
// currentVersion is the version the client already trusts (0 if none yet).
func VerifyDocument(raw []byte, ring sign.KeyRing, currentVersion uint64) (sign.Envelope, Payload, error) {
	env, err := sign.Parse(raw)
	if err != nil {
		return sign.Envelope{}, Payload{}, err
	}
	if err := sign.Verify(env, ring); err != nil {
		return sign.Envelope{}, Payload{}, err
	}
	if env.Version <= currentVersion {
		return sign.Envelope{}, Payload{}, ErrRollback
	}
	p, err := Decode(env)
	if err != nil {
		return sign.Envelope{}, Payload{}, err
	}
	if err := p.Validate(); err != nil {
		return sign.Envelope{}, Payload{}, err
	}
	return env, p, nil
}

// --- helpers ---

func normalizeHosts(in []string) []string {
	return dedupSorted(func(s string) string {
		return strings.ToLower(strings.TrimSpace(s))
	}, in)
}

func dedupSorted(norm func(string) string, in []string) []string {
	seen := map[string]bool{}
	out := make([]string, 0, len(in))
	for _, s := range in {
		s = norm(s)
		if s == "" || seen[s] {
			continue
		}
		seen[s] = true
		out = append(out, s)
	}
	sort.Strings(out)
	if len(out) == 0 {
		return nil
	}
	return out
}

// isHostname does a conservative check: 1..253 chars, dot-separated labels of
// [a-z0-9-], not starting/ending with hyphen, at least two labels.
func isHostname(h string) bool {
	if len(h) == 0 || len(h) > 253 {
		return false
	}
	labels := strings.Split(h, ".")
	if len(labels) < 2 {
		return false
	}
	for _, l := range labels {
		if len(l) == 0 || len(l) > 63 {
			return false
		}
		if l[0] == '-' || l[len(l)-1] == '-' {
			return false
		}
		for i := 0; i < len(l); i++ {
			c := l[i]
			ok := (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-'
			if !ok {
				return false
			}
		}
	}
	return true
}
