// A deliberately minimal terminal state model.
//
// RecordDeck does not emulate a terminal. It tracks exactly two things across a
// stream of bytes: where the cursor ended up, and which SGR (colour/attribute)
// state is in force. That is the whole set of state a cut can silently destroy.
//
// When a cut removes output, the removed bytes are replayed through this model
// and the difference between the state before the cut and the state after it is
// emitted as a synthetic "prelude" escape sequence at the cut point, so the
// surviving recording renders the way it did before the edit.
//
// The exact set of sequences understood is documented in README.txt under
// "TERMINAL STATE MODEL" and is asserted by the tests.
package main

import (
	"bytes"
	"fmt"
	"strconv"
	"strings"
	"unicode/utf8"
)

// colourKind distinguishes the four ways a colour can be specified.
type colourKind uint8

const (
	colDefault colourKind = iota // 39 / 49
	colBasic                     // 30-37, 90-97, 40-47, 100-107
	col256                       // 38;5;n / 48;5;n
	colRGB                       // 38;2;r;g;b / 48;2;r;g;b
)

type colour struct {
	Kind colourKind
	N    int // the literal SGR code for colBasic, the palette index for col256
	R    int
	G    int
	B    int
}

// sgrState is comparable with ==, which is how prelude synthesis decides
// whether anything needs restoring.
type sgrState struct {
	Bold      bool
	Dim       bool
	Italic    bool
	Underline bool
	Blink     bool
	Reverse   bool
	Hidden    bool
	Strike    bool
	FG        colour
	BG        colour
}

type termState struct {
	Row    int // 0-based
	Col    int // 0-based
	Width  int
	Height int
	SGR    sgrState

	savedRow, savedCol int
	savedSGR           sgrState
	hasSaved           bool
}

func newTermState(width, height int) *termState {
	if width <= 0 {
		width = 80
	}
	if height <= 0 {
		height = 24
	}
	return &termState{Width: width, Height: height}
}

func (t *termState) clone() *termState {
	c := *t
	return &c
}

func (t *termState) clampRow() {
	if t.Row < 0 {
		t.Row = 0
	}
	if t.Row > t.Height-1 {
		t.Row = t.Height - 1 // a real terminal scrolls; the cursor stays on the last row
	}
}

func (t *termState) clampCol() {
	if t.Col < 0 {
		t.Col = 0
	}
	if t.Col > t.Width-1 {
		t.Col = t.Width - 1
	}
}

// feed advances the model over one chunk of recorded output.
func (t *termState) feed(b []byte) {
	i := 0
	for i < len(b) {
		c := b[i]
		switch {
		case c == 0x1b:
			i += t.escape(b[i:])
		case c == '\n':
			// LF is modelled as CR+LF. A cast records the bytes a program
			// WROTE, and the tty driver's ONLCR (on by default) turns each of
			// those into carriage-return + line-feed on the way to the screen.
			// A cast recorded from a PTY that already contains CRLF lands in
			// the same place.
			t.Row++
			t.Col = 0
			t.clampRow()
			i++
		case c == '\r':
			t.Col = 0
			i++
		case c == '\b':
			if t.Col > 0 {
				t.Col--
			}
			i++
		case c == '\t':
			t.Col = (t.Col/8 + 1) * 8
			if t.Col > t.Width-1 {
				t.Col = t.Width - 1
			}
			i++
		case c < 0x20 || c == 0x7f:
			i++ // remaining C0 controls: recognised, no modelled effect
		default:
			_, n := utf8.DecodeRune(b[i:])
			if n == 0 {
				n = 1
			}
			t.advance(1)
			i += n
		}
	}
}

// advance moves the cursor n printable cells, wrapping at the right margin.
// Every rune counts as one cell: there is no East-Asian-width or combining-mark
// handling. See README.txt.
func (t *termState) advance(n int) {
	for k := 0; k < n; k++ {
		t.Col++
		if t.Col > t.Width-1 {
			t.Col = 0
			t.Row++
			t.clampRow()
		}
	}
}

// escape consumes one escape sequence starting at b[0] == ESC and returns how
// many bytes it swallowed. Unknown sequences are consumed but ignored, so an
// unrecognised sequence can never desynchronise the parser.
func (t *termState) escape(b []byte) int {
	if len(b) < 2 {
		return len(b)
	}
	switch b[1] {
	case '[':
		return t.csi(b)
	case ']':
		// OSC: consume to BEL or ST (ESC \).
		for i := 2; i < len(b); i++ {
			if b[i] == 0x07 {
				return i + 1
			}
			if b[i] == 0x1b && i+1 < len(b) && b[i+1] == '\\' {
				return i + 2
			}
		}
		return len(b)
	case 'P', '^', '_', 'X':
		// DCS/PM/APC/SOS: consume to ST.
		for i := 2; i < len(b); i++ {
			if b[i] == 0x1b && i+1 < len(b) && b[i+1] == '\\' {
				return i + 2
			}
		}
		return len(b)
	case '7': // DECSC
		t.save()
		return 2
	case '8': // DECRC
		t.restore()
		return 2
	case 'D': // IND
		t.Row++
		t.clampRow()
		return 2
	case 'M': // RI
		t.Row--
		t.clampRow()
		return 2
	case 'E': // NEL
		t.Row++
		t.Col = 0
		t.clampRow()
		return 2
	case 'c': // RIS
		w, h := t.Width, t.Height
		*t = *newTermState(w, h)
		return 2
	case '(', ')', '*', '+', '#', '%':
		if len(b) >= 3 {
			return 3 // charset designation: recognised, ignored
		}
		return len(b)
	default:
		return 2
	}
}

func (t *termState) save() {
	t.savedRow, t.savedCol, t.savedSGR, t.hasSaved = t.Row, t.Col, t.SGR, true
}

func (t *termState) restore() {
	if !t.hasSaved {
		t.Row, t.Col, t.SGR = 0, 0, sgrState{}
		return
	}
	t.Row, t.Col, t.SGR = t.savedRow, t.savedCol, t.savedSGR
}

// csi consumes a CSI sequence and applies the ones the model understands.
func (t *termState) csi(b []byte) int {
	i := 2
	for i < len(b) && b[i] >= 0x30 && b[i] <= 0x3f { // parameter bytes
		i++
	}
	for i < len(b) && b[i] >= 0x20 && b[i] <= 0x2f { // intermediate bytes
		i++
	}
	if i >= len(b) {
		return len(b) // truncated sequence at the end of a chunk
	}
	final := b[i]
	params := string(b[2:i])
	consumed := i + 1

	if strings.HasPrefix(params, "?") || strings.HasPrefix(params, ">") || strings.HasPrefix(params, "<") || strings.HasPrefix(params, "=") {
		return consumed // private/DEC modes: recognised, not modelled
	}

	nums := parseParams(params)
	// A missing or zero parameter means "1" for every cursor movement CSI.
	arg := func(idx, def int) int {
		if idx < len(nums) && nums[idx] > 0 {
			return nums[idx]
		}
		return def
	}

	switch final {
	case 'm':
		t.applySGR(nums, params)
	case 'A':
		t.Row -= arg(0, 1)
		t.clampRow()
	case 'B':
		t.Row += arg(0, 1)
		t.clampRow()
	case 'C':
		t.Col += arg(0, 1)
		t.clampCol()
	case 'D':
		t.Col -= arg(0, 1)
		t.clampCol()
	case 'E':
		t.Row += arg(0, 1)
		t.Col = 0
		t.clampRow()
	case 'F':
		t.Row -= arg(0, 1)
		t.Col = 0
		t.clampRow()
	case 'G', '`': // CHA
		t.Col = arg(0, 1) - 1
		t.clampCol()
	case 'd': // VPA
		t.Row = arg(0, 1) - 1
		t.clampRow()
	case 'H', 'f': // CUP
		t.Row = arg(0, 1) - 1
		t.Col = arg(1, 1) - 1
		t.clampRow()
		t.clampCol()
	case 's':
		t.save()
	case 'u':
		t.restore()
		// J, K, L, M, P, X, S, T, @, r: recognised, consumed, no cursor effect
		// modelled (RecordDeck keeps no screen contents).
	}
	return consumed
}

func parseParams(s string) []int {
	if s == "" {
		return nil
	}
	parts := strings.Split(s, ";")
	out := make([]int, 0, len(parts))
	for _, p := range parts {
		if p == "" {
			out = append(out, 0)
			continue
		}
		n, err := strconv.Atoi(p)
		if err != nil {
			n = 0
		}
		out = append(out, n)
	}
	return out
}

func (t *termState) applySGR(nums []int, raw string) {
	if raw == "" || len(nums) == 0 {
		t.SGR = sgrState{}
		return
	}
	for i := 0; i < len(nums); i++ {
		n := nums[i]
		switch {
		case n == 0:
			t.SGR = sgrState{}
		case n == 1:
			t.SGR.Bold = true
		case n == 2:
			t.SGR.Dim = true
		case n == 3:
			t.SGR.Italic = true
		case n == 4:
			t.SGR.Underline = true
		case n == 5 || n == 6:
			t.SGR.Blink = true
		case n == 7:
			t.SGR.Reverse = true
		case n == 8:
			t.SGR.Hidden = true
		case n == 9:
			t.SGR.Strike = true
		case n == 21 || n == 22:
			t.SGR.Bold, t.SGR.Dim = false, false
		case n == 23:
			t.SGR.Italic = false
		case n == 24:
			t.SGR.Underline = false
		case n == 25:
			t.SGR.Blink = false
		case n == 27:
			t.SGR.Reverse = false
		case n == 28:
			t.SGR.Hidden = false
		case n == 29:
			t.SGR.Strike = false
		case n >= 30 && n <= 37:
			t.SGR.FG = colour{Kind: colBasic, N: n}
		case n == 39:
			t.SGR.FG = colour{}
		case n >= 40 && n <= 47:
			t.SGR.BG = colour{Kind: colBasic, N: n}
		case n == 49:
			t.SGR.BG = colour{}
		case n >= 90 && n <= 97:
			t.SGR.FG = colour{Kind: colBasic, N: n}
		case n >= 100 && n <= 107:
			t.SGR.BG = colour{Kind: colBasic, N: n}
		case n == 38 || n == 48:
			c, used := parseExtended(nums[i:])
			if used == 0 {
				i = len(nums)
				break
			}
			if n == 38 {
				t.SGR.FG = c
			} else {
				t.SGR.BG = c
			}
			i += used - 1
		}
	}
}

// parseExtended reads 38/48;5;n or 38/48;2;r;g;b. It returns how many
// parameters it consumed, or 0 if the sequence is malformed.
func parseExtended(nums []int) (colour, int) {
	if len(nums) < 2 {
		return colour{}, 0
	}
	switch nums[1] {
	case 5:
		if len(nums) < 3 {
			return colour{}, 0
		}
		return colour{Kind: col256, N: nums[2]}, 3
	case 2:
		if len(nums) < 5 {
			return colour{}, 0
		}
		return colour{Kind: colRGB, R: nums[2], G: nums[3], B: nums[4]}, 5
	}
	return colour{}, 0
}

// ---------------------------------------------------------------------------
// Prelude synthesis
// ---------------------------------------------------------------------------

// sgrSequence renders an absolute SGR state: a reset followed by every attribute
// that is on. It is absolute rather than differential on purpose, so it is
// correct no matter what the player's state was.
func sgrSequence(s sgrState) string {
	params := []string{"0"}
	if s.Bold {
		params = append(params, "1")
	}
	if s.Dim {
		params = append(params, "2")
	}
	if s.Italic {
		params = append(params, "3")
	}
	if s.Underline {
		params = append(params, "4")
	}
	if s.Blink {
		params = append(params, "5")
	}
	if s.Reverse {
		params = append(params, "7")
	}
	if s.Hidden {
		params = append(params, "8")
	}
	if s.Strike {
		params = append(params, "9")
	}
	params = append(params, colourParams(s.FG, false)...)
	params = append(params, colourParams(s.BG, true)...)
	return "\x1b[" + strings.Join(params, ";") + "m"
}

func colourParams(c colour, background bool) []string {
	base := 38
	if background {
		base = 48
	}
	switch c.Kind {
	case colDefault:
		return nil
	case colBasic:
		return []string{strconv.Itoa(c.N)}
	case col256:
		return []string{strconv.Itoa(base), "5", strconv.Itoa(c.N)}
	case colRGB:
		return []string{strconv.Itoa(base), "2", strconv.Itoa(c.R), strconv.Itoa(c.G), strconv.Itoa(c.B)}
	}
	return nil
}

// prelude returns the escape sequence that transforms the terminal from the
// state a player will be in at the cut point (before) into the state the
// surviving recording assumes (after). It returns nil when nothing changed.
func prelude(before, after *termState) []byte {
	var b bytes.Buffer
	if before.Row != after.Row || before.Col != after.Col {
		fmt.Fprintf(&b, "\x1b[%d;%dH", after.Row+1, after.Col+1)
	}
	if before.SGR != after.SGR {
		b.WriteString(sgrSequence(after.SGR))
	}
	if b.Len() == 0 {
		return nil
	}
	return b.Bytes()
}

// describeState is used by --json reporting so the prelude is auditable.
func describeState(t *termState) map[string]any {
	attrs := []string{}
	add := func(on bool, name string) {
		if on {
			attrs = append(attrs, name)
		}
	}
	add(t.SGR.Bold, "bold")
	add(t.SGR.Dim, "dim")
	add(t.SGR.Italic, "italic")
	add(t.SGR.Underline, "underline")
	add(t.SGR.Blink, "blink")
	add(t.SGR.Reverse, "reverse")
	add(t.SGR.Hidden, "hidden")
	add(t.SGR.Strike, "strike")
	return map[string]any{
		"row":        t.Row + 1,
		"col":        t.Col + 1,
		"attributes": attrs,
		"fg":         colourName(t.SGR.FG, false),
		"bg":         colourName(t.SGR.BG, true),
	}
}

func colourName(c colour, background bool) string {
	switch c.Kind {
	case colDefault:
		return "default"
	case colBasic:
		return "sgr" + strconv.Itoa(c.N)
	case col256:
		return "256:" + strconv.Itoa(c.N)
	case colRGB:
		return fmt.Sprintf("rgb:%d,%d,%d", c.R, c.G, c.B)
	}
	return "default"
}
