// Package updates implements a mechanism for checking if software updates are
// available, and fetching a changelog.
//
// Given a domain, the latest version of the software is queried in DNS from
// "_updates.<domain>" as a TXT record. If a new version is available, the
// changelog compared to a last known version can be retrieved. A changelog base
// URL and public key for signatures has to be specified explicitly.
//
// Downloading or upgrading to the latest version is not part of this package.
package updates

import (
	"bytes"
	"context"
	"crypto/ed25519"
	"encoding/json"
	"errors"
	"fmt"
	"net/http"
	"strconv"
	"strings"
	"time"

	"github.com/prometheus/client_golang/prometheus"
	"github.com/prometheus/client_golang/prometheus/promauto"

	"github.com/mjl-/mox/dns"
	"github.com/mjl-/mox/metrics"
	"github.com/mjl-/mox/mlog"
	"github.com/mjl-/mox/moxio"
)

var xlog = mlog.New("updates")

var (
	metricLookup = promauto.NewHistogramVec(
		prometheus.HistogramOpts{
			Name:    "mox_updates_lookup_duration_seconds",
			Help:    "Updates lookup with result.",
			Buckets: []float64{0.001, 0.005, 0.01, 0.05, 0.100, 0.5, 1, 5, 10, 20, 30},
		},
		[]string{"result"},
	)
	metricFetchChangelog = promauto.NewHistogramVec(
		prometheus.HistogramOpts{
			Name:    "mox_updates_fetchchangelog_duration_seconds",
			Help:    "Fetch changelog with result.",
			Buckets: []float64{0.001, 0.005, 0.01, 0.05, 0.100, 0.5, 1, 5, 10, 20, 30},
		},
		[]string{"result"},
	)
)

var (
	// Lookup errors.
	ErrDNS             = errors.New("updates: dns error")
	ErrRecordSyntax    = errors.New("updates: dns record syntax")
	ErrNoRecord        = errors.New("updates: no dns record")
	ErrMultipleRecords = errors.New("updates: multiple dns records")
	ErrBadVersion      = errors.New("updates: malformed version")

	// Fetch changelog errors.
	ErrChangelogFetch = errors.New("updates: fetching changelog")
)

// Change is a an entry in the changelog, a released version.
type Change struct {
	PubKey []byte // Key used for signing.
	Sig    []byte // Signature over text, with ed25519.
	Text   string // Signed changelog entry, starts with header similar to email, with at least fields "version" and "date".
}

// Changelog is returned as JSON.
//
// The changelog itself is not signed, only individual changes. The goal is to
// prevent a potential future different domain owner from notifying users about
// new versions.
type Changelog struct {
	Changes []Change
}

// Lookup looks up the updates DNS TXT record at "_updates.<domain>" and returns
// the parsed form.
func Lookup(ctx context.Context, resolver dns.Resolver, domain dns.Domain) (rversion Version, rrecord *Record, rerr error) {
	log := xlog.WithContext(ctx)
	start := time.Now()
	defer func() {
		var result = "ok"
		if rerr != nil {
			result = "error"
		}
		metricLookup.WithLabelValues(result).Observe(float64(time.Since(start)) / float64(time.Second))
		log.Debugx("updates lookup result", rerr, mlog.Field("domain", domain), mlog.Field("version", rversion), mlog.Field("record", rrecord), mlog.Field("duration", time.Since(start)))
	}()

	nctx, cancel := context.WithTimeout(ctx, 30*time.Second)
	defer cancel()
	name := "_updates." + domain.ASCII + "."
	txts, err := dns.WithPackage(resolver, "updates").LookupTXT(nctx, name)
	if dns.IsNotFound(err) {
		return Version{}, nil, ErrNoRecord
	} else if err != nil {
		return Version{}, nil, fmt.Errorf("%w: %s", ErrDNS, err)
	}
	var record *Record
	for _, txt := range txts {
		r, isupdates, err := ParseRecord(txt)
		if !isupdates {
			continue
		} else if err != nil {
			return Version{}, nil, err
		}
		if record != nil {
			return Version{}, nil, ErrMultipleRecords
		}
		record = r
	}

	if record == nil {
		return Version{}, nil, ErrNoRecord
	}
	return record.Latest, record, nil
}

// FetchChangelog fetches the changelog compared against the base version, which
// can be the Version zero value.
//
// The changelog is requested using HTTP GET from baseURL with optional "from"
// query string parameter.
//
// Individual changes are verified using pubKey. If any signature is invalid, an
// error is returned.
//
// A changelog can be maximum 1 MB.
func FetchChangelog(ctx context.Context, baseURL string, base Version, pubKey []byte) (changelog *Changelog, rerr error) {
	log := xlog.WithContext(ctx)
	start := time.Now()
	defer func() {
		var result = "ok"
		if rerr != nil {
			result = "error"
		}
		metricFetchChangelog.WithLabelValues(result).Observe(float64(time.Since(start)) / float64(time.Second))
		log.Debugx("updates fetch changelog result", rerr, mlog.Field("baseurl", baseURL), mlog.Field("base", base), mlog.Field("duration", time.Since(start)))
	}()

	url := baseURL + "?from=" + base.String()
	nctx, cancel := context.WithTimeout(ctx, time.Minute)
	defer cancel()
	req, err := http.NewRequestWithContext(nctx, "GET", url, nil)
	if err != nil {
		return nil, fmt.Errorf("making request: %v", err)
	}
	resp, err := http.DefaultClient.Do(req)
	if resp == nil {
		resp = &http.Response{StatusCode: 0}
	}
	metrics.HTTPClientObserve(ctx, "updates", req.Method, resp.StatusCode, err, start)
	if err != nil {
		return nil, fmt.Errorf("%w: making http request: %s", ErrChangelogFetch, err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("%w: http status: %s", ErrChangelogFetch, resp.Status)
	}
	var cl Changelog
	if err := json.NewDecoder(&moxio.LimitReader{R: resp.Body, Limit: 1024 * 1024}).Decode(&cl); err != nil {
		return nil, fmt.Errorf("%w: parsing changelog: %s", ErrChangelogFetch, err)
	}
	for _, c := range cl.Changes {
		if !bytes.Equal(c.PubKey, pubKey) {
			return nil, fmt.Errorf("%w: verifying change: signed with unknown public key %x instead of %x", ErrChangelogFetch, c.PubKey, pubKey)
		}
		if !ed25519.Verify(c.PubKey, []byte(c.Text), c.Sig) {
			return nil, fmt.Errorf("%w: verifying change: invalid signature for change", ErrChangelogFetch)
		}
	}

	return &cl, nil
}

// Check checks for an updated version through DNS and fetches a
// changelog if so.
func Check(ctx context.Context, resolver dns.Resolver, domain dns.Domain, lastKnown Version, changelogBaseURL string, pubKey []byte) (rversion Version, rrecord *Record, changelog *Changelog, rerr error) {
	log := xlog.WithContext(ctx)
	start := time.Now()
	defer func() {
		log.Debugx("updates check result", rerr, mlog.Field("domain", domain), mlog.Field("lastKnown", lastKnown), mlog.Field("changelogbaseurl", changelogBaseURL), mlog.Field("version", rversion), mlog.Field("record", rrecord), mlog.Field("duration", time.Since(start)))
	}()

	latest, record, err := Lookup(ctx, resolver, domain)
	if err != nil {
		return latest, record, nil, err
	}

	if latest.After(lastKnown) {
		changelog, err = FetchChangelog(ctx, changelogBaseURL, lastKnown, pubKey)
	}
	return latest, record, changelog, err
}

// Version is a specified version in an updates records.
type Version struct {
	Major int
	Minor int
	Patch int
}

// After returns if v comes after ov.
func (v Version) After(ov Version) bool {
	return v.Major > ov.Major || v.Major == ov.Major && v.Minor > ov.Minor || v.Major == ov.Major && v.Minor == ov.Minor && v.Patch > ov.Patch
}

// String returns a human-reasonable version, also for use in the updates
// record.
func (v Version) String() string {
	return fmt.Sprintf("v%d.%d.%d", v.Major, v.Minor, v.Patch)
}

// ParseVersion parses a version as used in an updates records.
//
// Rules:
//   - Optionally start with "v"
//   - A dash and anything after it is ignored, e.g. for non-release modifiers.
//   - Remaining string must be three dot-separated numbers.
func ParseVersion(s string) (Version, error) {
	s = strings.TrimPrefix(s, "v")
	s = strings.Split(s, "-")[0]
	t := strings.Split(s, ".")
	if len(t) != 3 {
		return Version{}, fmt.Errorf("%w: %v", ErrBadVersion, t)
	}
	nums := make([]int, 3)
	for i, v := range t {
		n, err := strconv.ParseInt(v, 10, 32)
		if err != nil {
			return Version{}, fmt.Errorf("%w: parsing int %q: %s", ErrBadVersion, v, err)
		}
		nums[i] = int(n)
	}
	return Version{nums[0], nums[1], nums[2]}, nil
}

// Record is an updates DNS record.
type Record struct {
	Version string  // v=UPDATES0, required and must always be first.
	Latest  Version // l=<version>, required.
}

// ParseRecord parses an updates DNS TXT record as served at
func ParseRecord(txt string) (record *Record, isupdates bool, err error) {
	l := strings.Split(txt, ";")
	vkv := strings.SplitN(strings.TrimSpace(l[0]), "=", 2)
	if len(vkv) != 2 || vkv[0] != "v" || !strings.EqualFold(vkv[1], "UPDATES0") {
		return nil, false, nil
	}

	r := &Record{Version: "UPDATES0"}
	seen := map[string]bool{}
	for _, t := range l[1:] {
		kv := strings.SplitN(strings.TrimSpace(t), "=", 2)
		if len(kv) != 2 {
			return nil, true, ErrRecordSyntax
		}
		k := strings.ToLower(kv[0])
		if seen[k] {
			return nil, true, fmt.Errorf("%w: duplicate key %q", ErrRecordSyntax, k)
		}
		seen[k] = true
		switch k {
		case "l":
			v, err := ParseVersion(kv[1])
			if err != nil {
				return nil, true, fmt.Errorf("%w: %s", ErrRecordSyntax, err)
			}
			r.Latest = v
		default:
			continue
		}
	}
	return r, true, nil
}
