// Package updatecheck provides a mechanism for periodically checking
// for updates to Coder.
//
// The update check is performed by querying the GitHub API for the
// latest release of Coder.
package updatecheck

import (
	"context"
	"database/sql"
	"encoding/json"
	"io"
	"net/http"
	"time"

	"github.com/google/go-github/v43/github"
	"golang.org/x/mod/semver"
	"golang.org/x/xerrors"

	"cdr.dev/slog"

	"github.com/coder/coder/v2/coderd/database"
	"github.com/coder/coder/v2/coderd/database/dbauthz"
)

const (
	// defaultURL defines the URL to check for the latest version of Coder.
	defaultURL = "https://api.github.com/repos/coder/coder/releases/latest"
)

// Checker is responsible for periodically checking for updates.
type Checker struct {
	ctx        context.Context
	cancel     context.CancelFunc
	db         database.Store
	log        slog.Logger
	opts       Options
	firstCheck chan struct{}
	closed     chan struct{}
}

// Options set optional parameters for the update check.
type Options struct {
	// Client is the HTTP client to use for the update check,
	// if omitted, http.DefaultClient will be used.
	Client *http.Client
	// URL is the URL to check for the latest version of Coder,
	// if omitted, the default URL will be used.
	URL string
	// Interval is the interval at which to check for updates,
	// default 24h.
	Interval time.Duration
	// UpdateTimeout sets the timeout for the update check,
	// default 30s.
	UpdateTimeout time.Duration
	// Notify is called when a newer version of Coder (than the
	// last update check) is available.
	Notify func(r Result)
}

// New returns a new Checker that periodically checks for Coder updates.
func New(db database.Store, log slog.Logger, opts Options) *Checker {
	if opts.Client == nil {
		opts.Client = http.DefaultClient
	}
	if opts.URL == "" {
		opts.URL = defaultURL
	}
	if opts.Interval == 0 {
		opts.Interval = 24 * time.Hour
	}
	if opts.UpdateTimeout == 0 {
		opts.UpdateTimeout = 30 * time.Second
	}
	if opts.Notify == nil {
		opts.Notify = func(r Result) {}
	}

	ctx, cancel := context.WithCancel(context.Background())
	c := &Checker{
		ctx:        ctx,
		cancel:     cancel,
		db:         db,
		log:        log,
		opts:       opts,
		firstCheck: make(chan struct{}),
		closed:     make(chan struct{}),
	}
	go c.start()
	return c
}

// Result is the result from the last update check.
type Result struct {
	Checked time.Time `json:"checked,omitempty"`
	Version string    `json:"version,omitempty"`
	URL     string    `json:"url,omitempty"`
}

// Latest returns the latest version of Coder.
func (c *Checker) Latest(ctx context.Context) (r Result, err error) {
	select {
	case <-c.ctx.Done():
		return r, c.ctx.Err()
	case <-ctx.Done():
		return r, ctx.Err()
	case <-c.firstCheck:
	}

	return c.lastUpdateCheck(ctx)
}

func (c *Checker) init() (Result, error) {
	defer close(c.firstCheck)

	r, err := c.lastUpdateCheck(c.ctx)
	if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
		return Result{}, xerrors.Errorf("last update check: %w", err)
	}
	if r.Checked.IsZero() || time.Since(r.Checked) > c.opts.Interval {
		r, err = c.update()
		if err != nil {
			return Result{}, xerrors.Errorf("update check failed: %w", err)
		}
	}

	return r, nil
}

func (c *Checker) start() {
	defer close(c.closed)

	r, err := c.init()
	if err != nil {
		if xerrors.Is(err, context.Canceled) {
			return
		}
		c.log.Error(c.ctx, "init failed", slog.Error(err))
	} else {
		c.opts.Notify(r)
	}

	t := time.NewTicker(c.opts.Interval)
	defer t.Stop()

	diff := time.Until(r.Checked.Add(c.opts.Interval))
	if diff > 0 {
		c.log.Debug(c.ctx, "time until next update check", slog.F("duration", diff))
		t.Reset(diff)
	} else {
		c.log.Debug(c.ctx, "time until next update check", slog.F("duration", c.opts.Interval))
	}

	for {
		select {
		case <-t.C:
			rr, err := c.update()
			if err != nil {
				if xerrors.Is(err, context.Canceled) {
					return
				}
				c.log.Error(c.ctx, "update check failed", slog.Error(err))
			} else {
				c.notifyIfNewer(r, rr)
				r = rr
			}
			c.log.Debug(c.ctx, "time until next update check", slog.F("duration", c.opts.Interval))
			t.Reset(c.opts.Interval)
		case <-c.ctx.Done():
			return
		}
	}
}

func (c *Checker) update() (r Result, err error) {
	ctx, cancel := context.WithTimeout(c.ctx, c.opts.UpdateTimeout)
	defer cancel()

	c.log.Debug(c.ctx, "checking for update")
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.opts.URL, nil)
	if err != nil {
		return r, xerrors.Errorf("new request: %w", err)
	}
	resp, err := c.opts.Client.Do(req)
	if err != nil {
		return r, xerrors.Errorf("client do: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		b, _ := io.ReadAll(resp.Body)
		return r, xerrors.Errorf("unexpected status code %d: %s", resp.StatusCode, b)
	}

	var rr github.RepositoryRelease
	err = json.NewDecoder(resp.Body).Decode(&rr)
	if err != nil {
		return r, xerrors.Errorf("json decode: %w", err)
	}

	r = Result{
		Checked: time.Now(),
		Version: rr.GetTagName(),
		URL:     rr.GetHTMLURL(),
	}
	c.log.Debug(ctx, "update check result", slog.F("latest_version", r.Version))

	b, err := json.Marshal(r)
	if err != nil {
		return r, xerrors.Errorf("json marshal result: %w", err)
	}

	// nolint:gocritic // Inserting the last update check is a system function.
	err = c.db.UpsertLastUpdateCheck(dbauthz.AsSystemRestricted(ctx), string(b))
	if err != nil {
		return r, err
	}

	return r, nil
}

func (c *Checker) notifyIfNewer(prev, next Result) {
	if (prev.Version == "" && next.Version != "") || semver.Compare(next.Version, prev.Version) > 0 {
		c.opts.Notify(next)
	}
}

func (c *Checker) lastUpdateCheck(ctx context.Context) (r Result, err error) {
	// nolint:gocritic // Getting the last update check is a system function.
	s, err := c.db.GetLastUpdateCheck(dbauthz.AsSystemRestricted(ctx))
	if err != nil {
		return r, err
	}
	return r, json.Unmarshal([]byte(s), &r)
}

func (c *Checker) Close() error {
	c.cancel()
	<-c.closed
	return nil
}
