package jetstream

import (
	"context"
	_ "embed"
	"encoding/json"
	"fmt"
	"log/slog"
	"net/http"
	"net/url"
	"strconv"
	"time"

	comatproto "github.com/bluesky-social/indigo/api/atproto"
	"github.com/gorilla/websocket"
	"github.com/klauspost/compress/zstd"
)

// bsky jetstream compresses with this dictionary, and hydrant's frames decode with it too
//
//go:embed zstd_dictionary
var zstdDictionary []byte

const (
	EventKindCommit   = "commit"
	EventKindAccount  = "account"
	EventKindIdentity = "identity"

	CommitOperationCreate = "create"
	CommitOperationUpdate = "update"
	CommitOperationDelete = "delete"
)

// hydrant pings every 30s, so 90s of silence means the connection is dead
const defaultReadTimeout = 90 * time.Second

// refused dials fail instantly, without the wait this loop spins hot
const reconnectDelay = time.Second

// indigo's jetstream event plus a live flag on commits, which is what hydrant's /subscribe sends
type Event struct {
	Did      string                                  `json:"did"`
	TimeUS   int64                                   `json:"time_us"`
	Kind     string                                  `json:"kind,omitempty"`
	Commit   *Commit                                 `json:"commit,omitempty"`
	Account  *comatproto.SyncSubscribeRepos_Account  `json:"account,omitempty"`
	Identity *comatproto.SyncSubscribeRepos_Identity `json:"identity,omitempty"`
}

type Commit struct {
	Rev        string          `json:"rev,omitempty"`
	Operation  string          `json:"operation,omitempty"`
	Collection string          `json:"collection,omitempty"`
	RKey       string          `json:"rkey,omitempty"`
	Record     json.RawMessage `json:"record,omitempty"`
	CID        string          `json:"cid,omitempty"`
	// plain jetstream never sends this, so nil counts as live
	Live *bool `json:"live,omitempty"`
}

func (c *Commit) IsLive() bool {
	return c.Live == nil || *c.Live
}

type StreamConfig struct {
	Endpoint    string
	Collections []string
	// hydrant only backfills when asked here, plain jetstream doesn't know the param
	EventTypes []string
	Compress   bool

	// read on every connect, ok false replays from 0 unless TailWhenNoCursor is set
	Cursor           func() (timeUs int64, ok bool, err error)
	TailWhenNoCursor bool
	// each hydrant stamps time_us its own way, so resume a little before the cursor
	Rewind time.Duration

	ReadTimeout    time.Duration
	ReconnectDelay time.Duration
	UserAgent      string
}

// the handler saves the cursor in the same transaction as the event, so a crash can't keep one without the other
type Stream struct {
	cfg StreamConfig
	l   *slog.Logger
	dec *zstd.Decoder
}

func NewStream(cfg StreamConfig, logger *slog.Logger) (*Stream, error) {
	if cfg.Cursor == nil {
		return nil, fmt.Errorf("stream needs a cursor source")
	}
	if cfg.ReadTimeout == 0 {
		cfg.ReadTimeout = defaultReadTimeout
	}
	if cfg.ReconnectDelay == 0 {
		cfg.ReconnectDelay = reconnectDelay
	}
	s := &Stream{cfg: cfg, l: logger}
	if cfg.Compress {
		dec, err := zstd.NewReader(nil, zstd.WithDecoderDicts(zstdDictionary))
		if err != nil {
			return nil, fmt.Errorf("creating zstd decoder: %w", err)
		}
		s.dec = dec
	}
	return s, nil
}

// a handler error drops the connection, so the event comes back from the cursor
func (s *Stream) Run(ctx context.Context, handle func(context.Context, *Event) error) error {
	for {
		if err := s.readOnce(ctx, handle); err != nil && ctx.Err() == nil {
			s.l.Error("reading stream", "err", err)
		}
		select {
		case <-ctx.Done():
			return nil
		case <-time.After(s.cfg.ReconnectDelay):
		}
	}
}

// nil leaves the cursor param off, which starts at the live tail
func (s *Stream) resumeCursor() (*int64, error) {
	us, ok, err := s.cfg.Cursor()
	if err != nil {
		return nil, err
	}
	if !ok {
		if s.cfg.TailWhenNoCursor {
			return nil, nil
		}
		us = 0
	}
	cursor := max(us-s.cfg.Rewind.Microseconds(), 0)
	return &cursor, nil
}

func (s *Stream) url(cursor *int64) (string, error) {
	u, err := url.Parse(s.cfg.Endpoint)
	if err != nil {
		return "", fmt.Errorf("parsing endpoint %q: %w", s.cfg.Endpoint, err)
	}
	q := u.Query()
	q.Del("cursor")
	if cursor != nil {
		q.Set("cursor", strconv.FormatInt(*cursor, 10))
	}
	q.Del("wantedCollections")
	for _, nsid := range s.cfg.Collections {
		q.Add("wantedCollections", nsid)
	}
	q.Del("wantedEventTypes")
	for _, t := range s.cfg.EventTypes {
		q.Add("wantedEventTypes", t)
	}
	u.RawQuery = q.Encode()
	return u.String(), nil
}

func (s *Stream) readOnce(ctx context.Context, handle func(context.Context, *Event) error) error {
	cursor, err := s.resumeCursor()
	if err != nil {
		return fmt.Errorf("reading cursor: %w", err)
	}
	target, err := s.url(cursor)
	if err != nil {
		return err
	}

	header := http.Header{}
	if s.cfg.UserAgent != "" {
		header.Set("User-Agent", s.cfg.UserAgent)
	}
	if s.dec != nil {
		header.Set("Socket-Encoding", "zstd")
	}

	s.l.Info("connecting to stream", "url", target)
	con, _, err := websocket.DefaultDialer.DialContext(ctx, target, header)
	if err != nil {
		return err
	}
	defer con.Close()
	// a quiet stream blocks the read and never checks ctx, so cancel closes the conn
	defer context.AfterFunc(ctx, func() { con.Close() })()

	alive := func() error { return con.SetReadDeadline(time.Now().Add(s.cfg.ReadTimeout)) }
	con.SetPingHandler(func(data string) error {
		if err := alive(); err != nil {
			return err
		}
		return con.WriteControl(websocket.PongMessage, []byte(data), time.Now().Add(time.Second))
	})

	for {
		if err := alive(); err != nil {
			return err
		}
		_, msg, err := con.ReadMessage()
		if err != nil {
			return fmt.Errorf("reading message: %w", err)
		}
		if s.dec != nil {
			if msg, err = s.dec.DecodeAll(msg, nil); err != nil {
				return fmt.Errorf("decompressing message: %w", err)
			}
		}
		var e Event
		if err := json.Unmarshal(msg, &e); err != nil {
			s.l.Warn("skipping undecodable frame", "err", err)
			continue
		}
		if err := handle(ctx, &e); err != nil {
			return fmt.Errorf("handling event at %d: %w", e.TimeUS, err)
		}
	}
}
