package channelserver

import (
	"bytes"
	"encoding/base64"
	"encoding/binary"
	"encoding/json"
	"fmt"

	"golang.org/x/text/encoding/japanese"
	"golang.org/x/text/transform"
)

// ── Constants ─────────────────────────────────────────────────────────────────

// jkrMagic is the little-endian magic number at the start of a JKR-compressed
// blob: bytes 0x4A 0x4B 0x52 0x1A ('J','K','R',0x1A).
const jkrMagic uint32 = 0x1A524B4A

// scenarioChunkSizeLimit is the maximum byte length the client accepts for any
// single chunk (chunk0, chunk1, or chunk2). Confirmed from the client's response
// handler (FUN_11525c60 in mhfo-hd.dll): chunks larger than this are silently
// discarded, so the server must never serve a chunk exceeding this limit.
const scenarioChunkSizeLimit = 0x8000

// ── JSON schema types ────────────────────────────────────────────────────────

// ScenarioJSON is the open, human-editable representation of a scenario .bin file.
// Strings are stored as UTF-8; the compiler converts to/from Shift-JIS.
//
// Container layout (big-endian sizes):
//
//	@0x00: u32 BE  chunk0_size
//	@0x04: u32 BE  chunk1_size
//	       [chunk0_data]
//	       [chunk1_data]
//	       u32 BE  chunk2_size  (only present when non-zero)
//	       [chunk2_data]
//
// Each chunk must not exceed scenarioChunkSizeLimit bytes.
type ScenarioJSON struct {
	// Chunk0 holds quest name/description data (sub-header or inline format).
	Chunk0 *ScenarioChunk0JSON `json:"chunk0,omitempty"`
	// Chunk1 holds NPC dialog data (sub-header format or raw JKR blob).
	Chunk1 *ScenarioChunk1JSON `json:"chunk1,omitempty"`
	// Chunk2 holds JKR-compressed menu/title data.
	Chunk2 *ScenarioRawChunkJSON `json:"chunk2,omitempty"`
}

// ScenarioChunk0JSON represents chunk0, which is either sub-header or inline format.
// Exactly one of Subheader/Inline is non-nil.
type ScenarioChunk0JSON struct {
	Subheader *ScenarioSubheaderJSON `json:"subheader,omitempty"`
	Inline    []ScenarioInlineEntry  `json:"inline,omitempty"`
}

// ScenarioChunk1JSON represents chunk1, which is either sub-header or raw JKR.
// Exactly one of Subheader/JKR is non-nil.
type ScenarioChunk1JSON struct {
	Subheader *ScenarioSubheaderJSON `json:"subheader,omitempty"`
	JKR       *ScenarioRawChunkJSON  `json:"jkr,omitempty"`
}

// ScenarioSubheaderJSON represents a chunk in sub-header format.
//
// Sub-header binary layout (8 bytes, little-endian where applicable):
//
//	@0: u8   Type     (usually 0x01; the client treats this as a compound-container tag)
//	@1: u8   0x00     (pad; must be 0x00 — used by the server to detect this format vs inline)
//	@2: u16  Size     (total chunk size including this header, LE)
//	@4: u8   Count    (number of string entries)
//	@5: u8   Unknown1 (purpose unconfirmed; preserved round-trip)
//	@6: u8   MetaSize (byte length of the metadata block; 0x14 for chunk0, 0x2C for chunk1)
//	@7: u8   Unknown2 (purpose unconfirmed; preserved round-trip)
//	[MetaSize bytes: opaque metadata — see docs/scenario-format.md for field breakdown]
//	[null-terminated Shift-JIS strings, one per entry]
//	[0xFF end-of-strings sentinel]
//
// Chunk0 metadata (MetaSize=0x14, 10×u16 LE):
//
//	m[0]=CategoryID  m[1]=MainID  m[2]=0  m[3]=0  m[4]=0
//	m[5]=str0_len    m[6]=SceneRef (MainID when cat=0, 0xFFFF otherwise)
//	m[7..9]: not read by the client parser (FUN_1080d310 in mhfo-hd.dll)
//
// Chunk1 metadata (MetaSize=0x2C, 22×u16 LE):
//
//	m[8..17] are interpreted as signed offsets by the client (FUN_1080d3b0):
//	  negative → (~value) + dialog_base  (into post-0xFF dialog script)
//	  non-negative → value + strings_base (into strings section)
//	m[18..19] are read as individual bytes, not u16 pairs.
type ScenarioSubheaderJSON struct {
	// Type is the chunk type byte (almost always 0x01).
	Type uint8 `json:"type"`
	// Unknown1 is the byte at sub-header offset 5. Purpose not confirmed;
	// always 0x00 in observed files.
	Unknown1 uint8 `json:"unknown1"`
	// Unknown2 is the byte at sub-header offset 7. Purpose not confirmed;
	// always 0x00 in observed files.
	Unknown2 uint8 `json:"unknown2"`
	// Metadata is the opaque metadata block, base64-encoded.
	// It is preserved verbatim so the client receives correct values for all
	// fields, including those the server does not need to interpret.
	// For chunk0, the client only reads m[0]–m[6]; m[7]–m[9] are ignored.
	Metadata string `json:"metadata"`
	// Strings contains the human-editable text. Each entry accepts either a
	// plain UTF-8 string (backwards compatible, single-language) or a
	// language-keyed object; see LocalizedString. The compiler converts the
	// resolved value for the current session's language to null-terminated
	// Shift-JIS on the wire.
	Strings []LocalizedString `json:"strings"`
}

// ScenarioInlineEntry is one entry in an inline-format chunk0.
// Format on wire: {u8 index}{Shift-JIS string}{0x00}.
//
// Text accepts either a plain string or a language-keyed object; see
// LocalizedString.
type ScenarioInlineEntry struct {
	Index uint8           `json:"index"`
	Text  LocalizedString `json:"text"`
}

// ScenarioRawChunkJSON stores a JKR-compressed chunk as its raw compressed bytes.
// The data is served to the client as-is; the format of the decompressed content
// is not yet fully documented.
type ScenarioRawChunkJSON struct {
	// Data is the raw JKR-compressed bytes, base64-encoded.
	Data string `json:"data"`
}

// ── Parse: binary → JSON ─────────────────────────────────────────────────────

// ParseScenarioBinary reads a scenario .bin file and returns a ScenarioJSON
// suitable for editing and re-compilation with CompileScenarioJSON.
func ParseScenarioBinary(data []byte) (*ScenarioJSON, error) {
	if len(data) < 8 {
		return nil, fmt.Errorf("scenario data too short: %d bytes", len(data))
	}

	c0Size := int(binary.BigEndian.Uint32(data[0:4]))
	c1Size := int(binary.BigEndian.Uint32(data[4:8]))

	result := &ScenarioJSON{}

	// Chunk0
	c0Off := 8
	if c0Size > 0 {
		if c0Off+c0Size > len(data) {
			return nil, fmt.Errorf("chunk0 size %d overruns data at offset %d", c0Size, c0Off)
		}
		chunk0, err := parseScenarioChunk0(data[c0Off : c0Off+c0Size])
		if err != nil {
			return nil, fmt.Errorf("chunk0: %w", err)
		}
		result.Chunk0 = chunk0
	}

	// Chunk1
	c1Off := c0Off + c0Size
	if c1Size > 0 {
		if c1Off+c1Size > len(data) {
			return nil, fmt.Errorf("chunk1 size %d overruns data at offset %d", c1Size, c1Off)
		}
		chunk1, err := parseScenarioChunk1(data[c1Off : c1Off+c1Size])
		if err != nil {
			return nil, fmt.Errorf("chunk1: %w", err)
		}
		result.Chunk1 = chunk1
	}

	// Chunk2 (preceded by its own 4-byte size field)
	c2HdrOff := c1Off + c1Size
	if c2HdrOff+4 <= len(data) {
		c2Size := int(binary.BigEndian.Uint32(data[c2HdrOff : c2HdrOff+4]))
		if c2Size > 0 {
			c2DataOff := c2HdrOff + 4
			if c2DataOff+c2Size > len(data) {
				return nil, fmt.Errorf("chunk2 size %d overruns data at offset %d", c2Size, c2DataOff)
			}
			result.Chunk2 = &ScenarioRawChunkJSON{
				Data: base64.StdEncoding.EncodeToString(data[c2DataOff : c2DataOff+c2Size]),
			}
		}
	}

	return result, nil
}

// parseScenarioChunk0 auto-detects sub-header vs inline format.
// The second byte being 0x00 is the pad byte in sub-headers; non-zero means inline.
func parseScenarioChunk0(data []byte) (*ScenarioChunk0JSON, error) {
	if len(data) < 2 {
		return &ScenarioChunk0JSON{}, nil
	}
	if data[1] == 0x00 {
		sh, err := parseScenarioSubheader(data)
		if err != nil {
			return nil, err
		}
		return &ScenarioChunk0JSON{Subheader: sh}, nil
	}
	entries, err := parseScenarioInline(data)
	if err != nil {
		return nil, err
	}
	return &ScenarioChunk0JSON{Inline: entries}, nil
}

// parseScenarioChunk1 parses chunk1 as JKR or sub-header depending on magic bytes.
// JKR-compressed chunks start with the magic 'J','K','R',0x1A (LE u32 = jkrMagic).
func parseScenarioChunk1(data []byte) (*ScenarioChunk1JSON, error) {
	if len(data) >= 4 && binary.LittleEndian.Uint32(data[0:4]) == jkrMagic {
		return &ScenarioChunk1JSON{
			JKR: &ScenarioRawChunkJSON{
				Data: base64.StdEncoding.EncodeToString(data),
			},
		}, nil
	}
	sh, err := parseScenarioSubheader(data)
	if err != nil {
		return nil, err
	}
	return &ScenarioChunk1JSON{Subheader: sh}, nil
}

// parseScenarioSubheader parses the 8-byte sub-header + metadata + strings.
func parseScenarioSubheader(data []byte) (*ScenarioSubheaderJSON, error) {
	if len(data) < 8 {
		return nil, fmt.Errorf("sub-header chunk too short: %d bytes", len(data))
	}

	// 8-byte sub-header fields:
	chunkType := data[0] // @0: chunk type (0x01 = compound container)
	// data[1]                 // @1: pad 0x00 (format detector; not stored)
	// data[2:4]               // @2: u16 LE total size (recomputed on compile)
	entryCount := int(data[4]) // @4: number of string entries
	unknown1 := data[5]        // @5: purpose unknown; always 0x00 in observed files
	metaSize := int(data[6])   // @6: byte length of metadata block (0x14=C0, 0x2C=C1)
	unknown2 := data[7]        // @7: purpose unknown; always 0x00 in observed files

	metaEnd := 8 + metaSize
	if metaEnd > len(data) {
		return nil, fmt.Errorf("metadata block (size %d) overruns chunk (len %d)", metaSize, len(data))
	}

	metadata := base64.StdEncoding.EncodeToString(data[8:metaEnd])

	plainStrings, err := scenarioReadStrings(data, metaEnd, entryCount)
	if err != nil {
		return nil, err
	}
	// Binary carries a single language — wrap as plain LocalizedStrings so
	// round-tripping emits the same bytes.
	localized := make([]LocalizedString, len(plainStrings))
	for i, s := range plainStrings {
		localized[i] = NewLocalizedPlain(s)
	}

	return &ScenarioSubheaderJSON{
		Type:     chunkType,
		Unknown1: unknown1,
		Unknown2: unknown2,
		Metadata: metadata,
		Strings:  localized,
	}, nil
}

// parseScenarioInline parses chunk0 inline format: {u8 index}{Shift-JIS string}{0x00}.
func parseScenarioInline(data []byte) ([]ScenarioInlineEntry, error) {
	var result []ScenarioInlineEntry
	pos := 0
	for pos < len(data) {
		if data[pos] == 0x00 {
			pos++
			continue
		}
		idx := data[pos]
		pos++
		if pos >= len(data) {
			break
		}
		end := pos
		for end < len(data) && data[end] != 0x00 {
			end++
		}
		if end > pos {
			text, err := scenarioDecodeShiftJIS(data[pos:end])
			if err != nil {
				return nil, fmt.Errorf("inline entry at 0x%x: %w", pos, err)
			}
			result = append(result, ScenarioInlineEntry{Index: idx, Text: NewLocalizedPlain(text)})
		}
		pos = end + 1 // skip null terminator
	}
	return result, nil
}

// scenarioReadStrings scans for null-terminated Shift-JIS strings starting at
// offset start, reading at most maxCount strings (0 = unlimited). Stops on 0xFF.
func scenarioReadStrings(data []byte, start, maxCount int) ([]string, error) {
	var result []string
	pos := start
	for pos < len(data) {
		if maxCount > 0 && len(result) >= maxCount {
			break
		}
		if data[pos] == 0x00 {
			pos++
			continue
		}
		if data[pos] == 0xFF {
			break
		}
		end := pos
		for end < len(data) && data[end] != 0x00 {
			end++
		}
		if end > pos {
			text, err := scenarioDecodeShiftJIS(data[pos:end])
			if err != nil {
				return nil, fmt.Errorf("string at 0x%x: %w", pos, err)
			}
			result = append(result, text)
		}
		pos = end + 1
	}
	return result, nil
}

// ── Compile: JSON → binary ───────────────────────────────────────────────────

// CompileScenarioJSON parses jsonData and compiles it to MHF scenario binary
// format for the given language. Phase B of #188 added the lang parameter so
// per-session scenarios can ship localized text without affecting callers
// holding single-language JSON files.
func CompileScenarioJSON(jsonData []byte, lang string) ([]byte, error) {
	var s ScenarioJSON
	if err := json.Unmarshal(jsonData, &s); err != nil {
		return nil, fmt.Errorf("unmarshal scenario JSON: %w", err)
	}
	return compileScenario(&s, lang)
}

func compileScenario(s *ScenarioJSON, lang string) ([]byte, error) {
	var chunk0, chunk1, chunk2 []byte
	var err error

	if s.Chunk0 != nil {
		chunk0, err = compileScenarioChunk0(s.Chunk0, lang)
		if err != nil {
			return nil, fmt.Errorf("chunk0: %w", err)
		}
	}
	if s.Chunk1 != nil {
		chunk1, err = compileScenarioChunk1(s.Chunk1, lang)
		if err != nil {
			return nil, fmt.Errorf("chunk1: %w", err)
		}
	}
	if s.Chunk2 != nil {
		chunk2, err = compileScenarioRawChunk(s.Chunk2)
		if err != nil {
			return nil, fmt.Errorf("chunk2: %w", err)
		}
	}

	for i, chunk := range [][]byte{chunk0, chunk1, chunk2} {
		if len(chunk) > scenarioChunkSizeLimit {
			return nil, fmt.Errorf("chunk%d size %d exceeds client limit of %d bytes", i, len(chunk), scenarioChunkSizeLimit)
		}
	}

	var buf bytes.Buffer
	// Container header: c0_size, c1_size (big-endian u32)
	_ = binary.Write(&buf, binary.BigEndian, uint32(len(chunk0)))
	_ = binary.Write(&buf, binary.BigEndian, uint32(len(chunk1)))
	buf.Write(chunk0)
	buf.Write(chunk1)
	// Chunk2 preceded by its own size field
	if len(chunk2) > 0 {
		_ = binary.Write(&buf, binary.BigEndian, uint32(len(chunk2)))
		buf.Write(chunk2)
	}

	return buf.Bytes(), nil
}

func compileScenarioChunk0(c *ScenarioChunk0JSON, lang string) ([]byte, error) {
	if c.Subheader != nil {
		return compileScenarioSubheader(c.Subheader, lang)
	}
	return compileScenarioInline(c.Inline, lang)
}

func compileScenarioChunk1(c *ScenarioChunk1JSON, lang string) ([]byte, error) {
	if c.JKR != nil {
		return compileScenarioRawChunk(c.JKR)
	}
	if c.Subheader != nil {
		return compileScenarioSubheader(c.Subheader, lang)
	}
	return nil, nil
}

// compileScenarioSubheader builds the binary sub-header chunk:
// [8-byte header][metadata][null-terminated Shift-JIS strings][0xFF]
func compileScenarioSubheader(sh *ScenarioSubheaderJSON, lang string) ([]byte, error) {
	meta, err := base64.StdEncoding.DecodeString(sh.Metadata)
	if err != nil {
		return nil, fmt.Errorf("decode metadata base64: %w", err)
	}

	var strBuf bytes.Buffer
	for _, s := range sh.Strings {
		sjis, err := scenarioEncodeShiftJIS(s.Resolve(lang))
		if err != nil {
			return nil, err
		}
		strBuf.Write(sjis) // sjis already has null terminator from helper
	}
	strBuf.WriteByte(0xFF) // end-of-strings sentinel

	// Total size = 8-byte header + metadata + strings
	totalSize := 8 + len(meta) + strBuf.Len()

	var buf bytes.Buffer
	buf.WriteByte(sh.Type)
	buf.WriteByte(0x00) // pad (format detector)
	// u16 LE total size
	buf.WriteByte(byte(totalSize))
	buf.WriteByte(byte(totalSize >> 8))
	buf.WriteByte(byte(len(sh.Strings))) // entry count
	buf.WriteByte(sh.Unknown1)
	buf.WriteByte(byte(len(meta))) // metadata total size
	buf.WriteByte(sh.Unknown2)
	buf.Write(meta)
	buf.Write(strBuf.Bytes())

	return buf.Bytes(), nil
}

// compileScenarioInline builds the inline-format chunk0 bytes.
func compileScenarioInline(entries []ScenarioInlineEntry, lang string) ([]byte, error) {
	var buf bytes.Buffer
	for _, e := range entries {
		buf.WriteByte(e.Index)
		sjis, err := scenarioEncodeShiftJIS(e.Text.Resolve(lang))
		if err != nil {
			return nil, err
		}
		buf.Write(sjis) // includes null terminator
	}
	return buf.Bytes(), nil
}

// compileScenarioRawChunk decodes the base64 raw chunk bytes.
// These are served to the client as-is (no re-compression).
func compileScenarioRawChunk(rc *ScenarioRawChunkJSON) ([]byte, error) {
	data, err := base64.StdEncoding.DecodeString(rc.Data)
	if err != nil {
		return nil, fmt.Errorf("decode raw chunk base64: %w", err)
	}
	return data, nil
}

// ── String helpers ───────────────────────────────────────────────────────────

// scenarioDecodeShiftJIS converts a raw Shift-JIS byte slice to UTF-8 string.
func scenarioDecodeShiftJIS(b []byte) (string, error) {
	dec := japanese.ShiftJIS.NewDecoder()
	out, _, err := transform.Bytes(dec, b)
	if err != nil {
		return "", fmt.Errorf("shift-jis decode: %w", err)
	}
	return string(out), nil
}

// scenarioEncodeShiftJIS converts a UTF-8 string to a null-terminated Shift-JIS byte slice.
func scenarioEncodeShiftJIS(s string) ([]byte, error) {
	enc := japanese.ShiftJIS.NewEncoder()
	out, _, err := transform.Bytes(enc, []byte(s))
	if err != nil {
		return nil, fmt.Errorf("shift-jis encode %q: %w", s, err)
	}
	return append(out, 0x00), nil
}
