package main

import (
	"bufio"
	"bytes"
	"encoding/base64"
	"encoding/json"
	"errors"
	"fmt"
	"os"
)

// This file reads the SessionForge cast format, byte for byte as SessionForge
// writes it: JSON Lines, one header object, then one array per event, then an
// optional footer object. See sessionforge/src/README.txt, "CAST FORMAT".
//
//	{"version":1,"command":[...],"started_at":"...","shell":"...",
//	 "width":80,"height":24,"title":"..."}
//	[0.001227, "o", "first\n"]
//	[0.008778, "o", "AAECAwQF...", "b64"]
//	{"exit_code":0,"duration":3.004391188}

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

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"`
}

type castFooter struct {
	ExitCode    int     `json:"exit_code"`
	Duration    float64 `json:"duration"`
	Signal      string  `json:"signal,omitempty"`
	Interrupted bool    `json:"interrupted,omitempty"`
}

type castEvent struct {
	Time   float64
	Stream string // "o" or "e"
	Data   []byte
}

type cast struct {
	Path   string
	Header castHeader
	Events []castEvent
	Footer *castFooter
}

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
	if err := json.Unmarshal(raw[0], &ev.Time); err != nil {
		return castEvent{}, fmt.Errorf("bad timestamp: %v", err)
	}
	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
	default:
		return castEvent{}, fmt.Errorf("unknown payload encoding %q", encoding)
	}
	return ev, nil
}

// loadCast opens the cast strictly read-only and parses it. The file is never
// written to, moved or truncated by any CaptureStudio command.
func loadCast(path string) (*cast, error) {
	f, err := os.OpenFile(path, os.O_RDONLY, 0)
	if err != nil {
		if os.IsNotExist(err) {
			return nil, fmt.Errorf("no cast file at %s", path)
		}
		return nil, err
	}
	defer f.Close()

	sc := bufio.NewScanner(f)
	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
		}
		if !gotHeader {
			if line[0] != '{' {
				return nil, fmt.Errorf("%s: line %d: expected a header object, got %q", path, lineNo, previewOf(line))
			}
			if err := json.Unmarshal(line, &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)
			}
			gotHeader = true
			continue
		}
		switch line[0] {
		case '[':
			ev, err := decodeEvent(line)
			if err != nil {
				return nil, fmt.Errorf("%s: line %d: %v", path, lineNo, err)
			}
			c.Events = append(c.Events, ev)
		case '{':
			var ft castFooter
			if err := json.Unmarshal(line, &ft); err != nil {
				return nil, fmt.Errorf("%s: line %d: malformed footer: %v", path, lineNo, err)
			}
			c.Footer = &ft
		default:
			return nil, fmt.Errorf("%s: line %d: unrecognised record %q", path, lineNo, previewOf(line))
		}
	}
	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 previewOf(line []byte) string {
	const n = 32
	s := string(line)
	if len(s) > n {
		s = s[:n] + "..."
	}
	return s
}

// duration is the timestamp of the final event: when the last byte landed.
func (c *cast) duration() float64 {
	if len(c.Events) == 0 {
		return 0
	}
	return c.Events[len(c.Events)-1].Time
}

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
}

// gridSize resolves the grid to replay into: the cast header's own width and
// height, overridden by the caller when non-zero, defaulting to 80x24 when the
// header carries nothing usable.
func (c *cast) gridSize(colsOverride, rowsOverride int) (cols, rows int) {
	cols, rows = c.Header.Width, c.Header.Height
	if cols <= 0 {
		cols = 80
	}
	if rows <= 0 {
		rows = 24
	}
	if colsOverride > 0 {
		cols = colsOverride
	}
	if rowsOverride > 0 {
		rows = rowsOverride
	}
	return cols, rows
}

// replayTo builds the exact grid state at time t by feeding every event whose
// timestamp is <= t, in file order. t < 0 means "before anything happened".
func replayTo(c *cast, cols, rows int, t float64) (*terminal, error) {
	term, err := newTerminal(cols, rows)
	if err != nil {
		return nil, err
	}
	for _, ev := range c.Events {
		if ev.Time > t {
			break
		}
		_, _ = term.Write(ev.Data)
	}
	return term, nil
}
