package main

import (
	"bufio"
	"bytes"
	"encoding/base64"
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"sort"
	"strconv"
	"unicode/utf8"
)

// ---------------------------------------------------------------------------
// SessionForge cast format (asciicast-v2-style JSON lines).
//
// Line 1  : header object    {"version":1,"command":[...],"started_at":...}
// Lines 2+: event arrays     [elapsed_seconds, "o"|"e", "chunk"]  (+ "b64")
// Last    : optional footer  {"exit_code":0,"duration":3.004}
//
// ClipStudio only ever READS casts produced by SessionForge and WRITES
// redacted copies of them, so every byte it does not have to touch is
// reproduced verbatim: the raw header line, the raw timestamp token of every
// event, and the raw footer line. That is what makes "timing is preserved
// exactly" a fact rather than a hope.
// ---------------------------------------------------------------------------

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 {
	RawTime string // the timestamp token exactly as it appeared on disk
	Time    float64
	Stream  string // "o" or "e"
	Data    []byte
	B64     bool // payload was stored base64 in the source file
}

type cast struct {
	Header    castHeader
	HeaderRaw []byte
	Events    []castEvent
	Footer    *castFooter
	FooterRaw []byte
}

// encodeEventLine renders one event line, reusing the original timestamp token
// so no float formatting can ever perturb the timing.
func encodeEventLine(rawTime, stream string, data []byte, forceB64 bool) []byte {
	var b bytes.Buffer
	b.WriteByte('[')
	if rawTime == "" {
		rawTime = "0.000000"
	}
	b.WriteString(rawTime)
	b.WriteString(", \"")
	b.WriteString(stream)
	b.WriteString("\", ")
	if !forceB64 && utf8.Valid(data) {
		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()
}

func decodeEvent(line []byte) (castEvent, error) {
	var raw []json.RawMessage
	if err := json.Unmarshal(line, &raw); err != nil {
		return castEvent{}, errors.New("malformed event array")
	}
	if len(raw) < 3 {
		return castEvent{}, errors.New("event needs at least 3 fields")
	}
	var ev castEvent
	ev.RawTime = string(bytes.TrimSpace(raw[0]))
	t, err := strconv.ParseFloat(ev.RawTime, 64)
	if err != nil {
		return castEvent{}, errors.New("bad timestamp")
	}
	ev.Time = t
	if err := json.Unmarshal(raw[1], &ev.Stream); err != nil {
		return castEvent{}, errors.New("bad stream tag")
	}
	if ev.Stream != "o" && ev.Stream != "e" {
		return castEvent{}, errors.New("unknown stream tag")
	}
	var payload string
	if err := json.Unmarshal(raw[2], &payload); err != nil {
		return castEvent{}, errors.New("bad payload")
	}
	encoding := "utf8"
	if len(raw) >= 4 {
		if err := json.Unmarshal(raw[3], &encoding); err != nil {
			return castEvent{}, errors.New("bad encoding tag")
		}
	}
	switch encoding {
	case "utf8", "":
		ev.Data = []byte(payload)
	case "b64":
		dec, err := base64.StdEncoding.DecodeString(payload)
		if err != nil {
			return castEvent{}, errors.New("bad base64 payload")
		}
		ev.Data = dec
		ev.B64 = true
	default:
		return castEvent{}, errors.New("unknown payload encoding")
	}
	return ev, nil
}

// loadCast parses a cast file. Parse errors quote line numbers and error kinds
// only - never file content - because file content is exactly the thing that
// might be a credential.
func loadCast(path string) (*cast, error) {
	f, err := os.Open(path)
	if err != nil {
		return nil, err
	}
	defer f.Close()

	sc := bufio.NewScanner(f)
	sc.Buffer(make([]byte, 64*1024), maxCastLine)

	c := &cast{}
	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 cast header object", path, lineNo)
			}
			if err := json.Unmarshal(line, &c.Header); err != nil {
				return nil, fmt.Errorf("%s: line %d: malformed cast header", path, lineNo)
			}
			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 = append([]byte(nil), line...)
			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 cast footer", path, lineNo)
			}
			c.Footer = &ft
			c.FooterRaw = append([]byte(nil), line...)
		default:
			return nil, fmt.Errorf("%s: line %d: unrecognised record", path, lineNo)
		}
	}
	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 (empty file?)", path)
	}
	return c, nil
}

// duration is the timestamp of the final event, matching SessionForge's info.
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
}

// ---------------------------------------------------------------------------
// Reconstructed text stream
// ---------------------------------------------------------------------------

// segment maps a byte range of the reconstructed text back to the event it
// came from, which is how a finding acquires a timestamp and how a mask finds
// its way back into the right event payload.
type segment struct {
	start, end int
	eventIdx   int
	time       float64
	stream     string
}

type textStream struct {
	text       string
	segs       []segment
	lineStarts []int
}

func buildTextStream(c *cast) *textStream {
	var b bytes.Buffer
	ts := &textStream{}
	for i, ev := range c.Events {
		if len(ev.Data) == 0 {
			continue
		}
		start := b.Len()
		b.Write(ev.Data)
		ts.segs = append(ts.segs, segment{
			start:    start,
			end:      b.Len(),
			eventIdx: i,
			time:     ev.Time,
			stream:   ev.Stream,
		})
	}
	ts.text = b.String()
	ts.lineStarts = []int{0}
	for i := 0; i < len(ts.text); i++ {
		if ts.text[i] == '\n' {
			ts.lineStarts = append(ts.lineStarts, i+1)
		}
	}
	return ts
}

// segmentAt returns the segment covering offset, or -1.
func (ts *textStream) segmentAt(off int) int {
	i := sort.Search(len(ts.segs), func(i int) bool { return ts.segs[i].end > off })
	if i < len(ts.segs) && off >= ts.segs[i].start {
		return i
	}
	return -1
}

// lineOf returns the 1-based line number containing offset.
func (ts *textStream) lineOf(off int) int {
	i := sort.Search(len(ts.lineStarts), func(i int) bool { return ts.lineStarts[i] > off })
	return i
}
