// Cast file I/O for the SessionForge JSON-lines format.
//
// Line 1 is a header object, every following line is either an event array
//
//	[elapsed_seconds, "o"|"e", "chunk"]          (optional 4th field "b64")
//
// or, as the final line, a footer object holding the recorded exit code.
//
// Timestamps are parsed as EXACT decimals into big.Rat and are never converted
// to float64 anywhere in the edit path.
package main

import (
	"bufio"
	"bytes"
	"encoding/base64"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"math/big"
	"os"
	"strconv"
	"strings"
	"unicode/utf8"
)

const (
	castVersion = 1
	maxCastLine = 8 * 1024 * 1024
)

// castHeader mirrors the SessionForge header. The raw bytes of the header line
// are preserved verbatim so unknown keys survive a round trip untouched.
type castHeader struct {
	Version   int      `json:"version"`
	Command   []string `json:"command"`
	StartedAt string   `json:"started_at"`
	Shell     string   `json:"shell"`
	Width     int      `json:"width"`
	Height    int      `json:"height"`
	Title     string   `json:"title"`
}

// castFooter is the trailing outcome line. It is optional: a recording that was
// killed part way through has no footer.
type castFooter struct {
	ExitCode    int     `json:"exit_code"`
	Duration    float64 `json:"duration"`
	Signal      string  `json:"signal,omitempty"`
	Interrupted bool    `json:"interrupted,omitempty"`
}

// castEvent is one chunk of output at one exact point in time.
type castEvent struct {
	T      *big.Rat // seconds, exact
	Stream string   // "o" or "e"
	Data   []byte
	Synth  bool     // true for a prelude RecordDeck generated at a cut point
	B64    bool     // the source line stored this payload base64, so we will too
	Src    *big.Rat // source-timeline time this event came from (for hold shifting)
}

type cast struct {
	Path      string
	HeaderRaw []byte
	Header    castHeader
	Events    []castEvent
	FooterRaw []byte
	Footer    *castFooter
	FooterDur *big.Rat // the footer's duration, parsed exactly from its literal text
}

// ---------------------------------------------------------------------------
// Exact rational time helpers
// ---------------------------------------------------------------------------

const microsPerSecond = 1000000

var (
	ratMicro  = big.NewRat(1, microsPerSecond)
	ratMillis = big.NewRat(1, 1000)
	ratMinute = big.NewRat(60, 1)
	ratOne    = big.NewRat(1, 1)
)

func rZero() *big.Rat             { return new(big.Rat) }
func rAdd(a, b *big.Rat) *big.Rat { return new(big.Rat).Add(a, b) }
func rSub(a, b *big.Rat) *big.Rat { return new(big.Rat).Sub(a, b) }
func rMul(a, b *big.Rat) *big.Rat { return new(big.Rat).Mul(a, b) }
func rCopy(a *big.Rat) *big.Rat   { return new(big.Rat).Set(a) }

func rFromMicros(us int64) *big.Rat { return new(big.Rat).SetFrac64(us, microsPerSecond) }

func rMin(a, b *big.Rat) *big.Rat {
	if a.Cmp(b) <= 0 {
		return rCopy(a)
	}
	return rCopy(b)
}

func rMax(a, b *big.Rat) *big.Rat {
	if a.Cmp(b) >= 0 {
		return rCopy(a)
	}
	return rCopy(b)
}

// rMicros quantises an exact time to whole microseconds, rounding half away
// from zero. It is called ONCE per emitted timestamp, always against the
// exactly-computed rational, so quantisation error can never accumulate.
func rMicros(r *big.Rat) int64 {
	scaled := new(big.Rat).Mul(r, new(big.Rat).SetInt64(microsPerSecond))
	num, den := scaled.Num(), scaled.Denom()
	q, rem := new(big.Int).QuoRem(num, den, new(big.Int))
	if rem.Sign() == 0 {
		return q.Int64()
	}
	twice := new(big.Int).Abs(rem)
	twice.Lsh(twice, 1)
	if twice.Cmp(den) >= 0 {
		if scaled.Sign() < 0 {
			q.Sub(q, big.NewInt(1))
		} else {
			q.Add(q, big.NewInt(1))
		}
	}
	return q.Int64()
}

// rIsExactMicros reports whether r is a whole number of microseconds, i.e.
// whether it can be written to a cast file with no loss at all.
func rIsExactMicros(r *big.Rat) bool {
	scaled := new(big.Rat).Mul(r, new(big.Rat).SetInt64(microsPerSecond))
	return scaled.IsInt()
}

// formatMicros renders whole microseconds exactly as SessionForge does:
// fixed point with six fractional digits.
func formatMicros(us int64) string {
	sign := ""
	if us < 0 {
		sign = "-"
		us = -us
	}
	return fmt.Sprintf("%s%d.%06d", sign, us/microsPerSecond, us%microsPerSecond)
}

func rString(r *big.Rat) string { return formatMicros(rMicros(r)) }

// rSeconds is for human display and JSON only. Never used in arithmetic.
func rSeconds(r *big.Rat) float64 {
	f, _ := r.Float64()
	return f
}

// clock renders a time as mm:ss.mmm, the way an editor thinks about a timeline.
func clock(r *big.Rat) string {
	us := rMicros(r)
	neg := ""
	if us < 0 {
		neg = "-"
		us = -us
	}
	ms := us / 1000
	return fmt.Sprintf("%s%d:%02d.%03d", neg, ms/60000, (ms/1000)%60, ms%1000)
}

// ---------------------------------------------------------------------------
// Reading
// ---------------------------------------------------------------------------

func loadCast(path string) (*cast, error) {
	f, err := os.Open(path) // read only, always
	if err != nil {
		return nil, err
	}
	defer f.Close()
	c, err := readCast(f, path)
	if err != nil {
		return nil, err
	}
	return c, nil
}

func readCast(r io.Reader, path string) (*cast, error) {
	sc := bufio.NewScanner(r)
	sc.Buffer(make([]byte, 64*1024), maxCastLine)

	c := &cast{Path: path}
	gotHeader := false
	lineNo := 0
	for sc.Scan() {
		lineNo++
		line := bytes.TrimSpace(sc.Bytes())
		if len(line) == 0 {
			continue
		}
		keep := append([]byte(nil), line...)
		if !gotHeader {
			if keep[0] != '{' {
				return nil, fmt.Errorf("%s: line %d: expected a header object, got %q", path, lineNo, preview(keep))
			}
			if err := json.Unmarshal(keep, &c.Header); err != nil {
				return nil, fmt.Errorf("%s: line %d: malformed header: %v", path, lineNo, err)
			}
			if c.Header.Version != castVersion {
				return nil, fmt.Errorf("%s: unsupported cast version %d (this build understands version %d)", path, c.Header.Version, castVersion)
			}
			c.HeaderRaw = keep
			gotHeader = true
			continue
		}
		switch keep[0] {
		case '[':
			ev, err := decodeEvent(keep)
			if err != nil {
				return nil, fmt.Errorf("%s: line %d: %v", path, lineNo, err)
			}
			if ev.T.Sign() < 0 {
				return nil, fmt.Errorf("%s: line %d: negative timestamp %s", path, lineNo, rString(ev.T))
			}
			if n := len(c.Events); n > 0 && c.Events[n-1].T.Cmp(ev.T) > 0 {
				return nil, fmt.Errorf("%s: line %d: timestamp %s goes backwards (previous event was at %s)",
					path, lineNo, rString(ev.T), rString(c.Events[n-1].T))
			}
			c.Events = append(c.Events, ev)
		case '{':
			var ft castFooter
			if err := json.Unmarshal(keep, &ft); err != nil {
				return nil, fmt.Errorf("%s: line %d: malformed footer: %v", path, lineNo, err)
			}
			c.Footer = &ft
			c.FooterRaw = keep
			var fields map[string]json.RawMessage
			if err := json.Unmarshal(keep, &fields); err == nil {
				if d, ok := fields["duration"]; ok {
					if r, ok := new(big.Rat).SetString(strings.TrimSpace(string(d))); ok {
						c.FooterDur = r
					}
				}
			}
		default:
			return nil, fmt.Errorf("%s: line %d: unrecognised record %q", path, lineNo, preview(keep))
		}
	}
	if err := sc.Err(); err != nil {
		if errors.Is(err, bufio.ErrTooLong) {
			return nil, fmt.Errorf("%s: line %d exceeds the %d byte limit", path, lineNo+1, maxCastLine)
		}
		return nil, fmt.Errorf("%s: %v", path, err)
	}
	if !gotHeader {
		return nil, fmt.Errorf("%s: no cast header found (file is empty?)", path)
	}
	return c, nil
}

func preview(line []byte) string {
	const n = 32
	s := string(line)
	if len(s) > n {
		s = s[:n] + "..."
	}
	return s
}

func decodeEvent(line []byte) (castEvent, error) {
	var raw []json.RawMessage
	if err := json.Unmarshal(line, &raw); err != nil {
		return castEvent{}, fmt.Errorf("malformed event: %v", err)
	}
	if len(raw) < 3 {
		return castEvent{}, errors.New("event needs at least 3 fields")
	}
	var ev castEvent
	// Parse the timestamp from its literal decimal text, exactly. Going through
	// float64 here would be the one place rounding could sneak in.
	t, ok := new(big.Rat).SetString(string(bytes.TrimSpace(raw[0])))
	if !ok {
		return castEvent{}, fmt.Errorf("bad timestamp %q", string(raw[0]))
	}
	ev.T = t
	ev.Src = rCopy(t)
	if err := json.Unmarshal(raw[1], &ev.Stream); err != nil {
		return castEvent{}, fmt.Errorf("bad stream tag: %v", err)
	}
	if ev.Stream != "o" && ev.Stream != "e" {
		return castEvent{}, fmt.Errorf("unknown stream tag %q", ev.Stream)
	}
	var payload string
	if err := json.Unmarshal(raw[2], &payload); err != nil {
		return castEvent{}, fmt.Errorf("bad payload: %v", err)
	}
	encoding := "utf8"
	if len(raw) >= 4 {
		if err := json.Unmarshal(raw[3], &encoding); err != nil {
			return castEvent{}, fmt.Errorf("bad encoding tag: %v", err)
		}
	}
	switch encoding {
	case "utf8", "":
		ev.Data = []byte(payload)
	case "b64":
		dec, err := base64.StdEncoding.DecodeString(payload)
		if err != nil {
			return castEvent{}, fmt.Errorf("bad base64 payload: %v", err)
		}
		ev.Data = dec
		ev.B64 = true
	default:
		return castEvent{}, fmt.Errorf("unknown payload encoding %q", encoding)
	}
	return ev, nil
}

// ---------------------------------------------------------------------------
// Writing
// ---------------------------------------------------------------------------

// encodeEvent renders one event line byte-for-byte the way SessionForge does:
// a valid UTF-8 chunk becomes a plain JSON string, anything else is base64 with
// a trailing "b64" marker. forceB64 keeps a payload base64 that arrived that
// way, so a hand-written cast round trips unchanged even where SessionForge
// itself would have chosen the plain form.
func encodeEvent(t *big.Rat, stream string, data []byte, forceB64 bool) []byte {
	var b bytes.Buffer
	b.WriteByte('[')
	b.WriteString(formatMicros(rMicros(t)))
	b.WriteString(", \"")
	b.WriteString(stream)
	b.WriteString("\", ")
	if utf8.Valid(data) && !forceB64 {
		enc, _ := json.Marshal(string(data))
		b.Write(enc)
	} else {
		enc, _ := json.Marshal(base64.StdEncoding.EncodeToString(data))
		b.Write(enc)
		b.WriteString(", \"b64\"")
	}
	b.WriteByte(']')
	return b.Bytes()
}

// writeCast serialises a cast. The header line and, unless the duration was
// rewritten, the footer line are emitted from their preserved raw bytes, so an
// unedited cast round trips byte for byte.
func writeCast(w io.Writer, c *cast) error {
	bw := bufio.NewWriter(w)
	if _, err := bw.Write(append(c.HeaderRaw, '\n')); err != nil {
		return err
	}
	for _, ev := range c.Events {
		if _, err := bw.Write(append(encodeEvent(ev.T, ev.Stream, ev.Data, ev.B64), '\n')); err != nil {
			return err
		}
	}
	if c.FooterRaw != nil {
		if _, err := bw.Write(append(c.FooterRaw, '\n')); err != nil {
			return err
		}
	}
	return bw.Flush()
}

// setFooterDuration rewrites the footer's duration in place, preserving every
// other key that was in the original footer line.
func (c *cast) setFooterDuration(d *big.Rat) error {
	if c.FooterRaw == nil {
		return nil
	}
	var m map[string]json.RawMessage
	if err := json.Unmarshal(c.FooterRaw, &m); err != nil {
		return err
	}
	m["duration"] = json.RawMessage(formatMicros(rMicros(d)))
	// Re-emit with a stable key order so the output is reproducible.
	keys := []string{"exit_code", "duration", "signal", "interrupted"}
	seen := map[string]bool{}
	var b bytes.Buffer
	b.WriteByte('{')
	first := true
	emit := func(k string) {
		v, ok := m[k]
		if !ok || seen[k] {
			return
		}
		seen[k] = true
		if !first {
			b.WriteByte(',')
		}
		first = false
		kb, _ := json.Marshal(k)
		b.Write(kb)
		b.WriteByte(':')
		b.Write(v)
	}
	for _, k := range keys {
		emit(k)
	}
	for k := range m {
		if !seen[k] {
			emit(k)
		}
	}
	b.WriteByte('}')
	c.FooterRaw = b.Bytes()
	if c.Footer != nil {
		f, _ := strconv.ParseFloat(formatMicros(rMicros(d)), 64)
		c.Footer.Duration = f
	}
	return nil
}

// ---------------------------------------------------------------------------
// Derived facts about a cast
// ---------------------------------------------------------------------------

// duration is the timestamp of the final event: when the last byte landed.
// This matches SessionForge's definition exactly.
func (c *cast) duration() *big.Rat {
	if len(c.Events) == 0 {
		return rZero()
	}
	return rCopy(c.Events[len(c.Events)-1].T)
}

// timelineEnd is where the RECORDING ended, which is not always where the last
// byte landed: a command that printed nothing for its final two seconds still
// ran for those two seconds, and the footer says so. Edits are applied to this
// full span, so trailing dead air is trimmed and scaled like any other.
func (c *cast) timelineEnd() *big.Rat {
	end := c.duration()
	if c.FooterDur != nil && c.FooterDur.Cmp(end) > 0 {
		return rCopy(c.FooterDur)
	}
	return end
}

func (c *cast) byteCounts() (out, errb int64) {
	for _, ev := range c.Events {
		if ev.Stream == "e" {
			errb += int64(len(ev.Data))
		} else {
			out += int64(len(ev.Data))
		}
	}
	return
}

// pause is a stretch of the source timeline in which nothing was printed.
type pause struct {
	From  *big.Rat
	To    *big.Rat
	Len   *big.Rat
	Index int // index of the event that ended the pause
}

// pauses lists every dead stretch, including the lead-in before the first event.
func (c *cast) pauses() []pause {
	var out []pause
	prev := rZero()
	for i, ev := range c.Events {
		g := rSub(ev.T, prev)
		if g.Sign() > 0 {
			out = append(out, pause{From: rCopy(prev), To: rCopy(ev.T), Len: g, Index: i})
		}
		prev = ev.T
	}
	return out
}

func (c *cast) width() int {
	if c.Header.Width > 0 {
		return c.Header.Width
	}
	return 80
}

func (c *cast) height() int {
	if c.Header.Height > 0 {
		return c.Header.Height
	}
	return 24
}
