package main

import (
	"crypto/sha256"
	"encoding/binary"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"os"
	"sort"
)

// The .tsb container
// ==================
//
// A .tsb file is one archive holding a chosen set of Techlosoft programs.
// It exists so a customer with a working machine can carry the programs to a
// machine that has no internet at all. There is no compression and no
// cleverness: the point is that it is trivial to verify and impossible to
// misread.
//
// Layout, in order, no padding anywhere:
//
//	offset  size  meaning
//	0       8     magic, the ASCII bytes "TSBUNDLE"
//	8       2     format version, uint16 big-endian. Currently 1.
//	10      2     reserved, uint16 big-endian, must be 0
//	12      8     length of the table of contents in bytes, uint64 big-endian
//	20      32    SHA-256 of the table of contents bytes
//	52      N     the table of contents: one UTF-8 JSON object, N bytes
//	52+N    ...   the payloads, concatenated in table-of-contents order
//
// The table of contents is JSON so that a person can read it with `dd` and
// `python -m json.tool` when everything else has gone wrong:
//
//	{
//	  "created": "2026-08-17T02:20:00Z",
//	  "generator": "techlosoft-suite 1.0.0",
//	  "entries": [
//	    {"slug":"drivepulse","name":"DrivePulse","group":"Storage Health Center",
//	     "version":"1.0.0","filename":"drivepulse","size":1937592,
//	     "sha256":"77b4...","offset":0}
//	  ]
//	}
//
// `offset` is measured from the first byte of the payload area, not from the
// start of the file, so a table of contents can be regenerated without
// knowing its own encoded length.
//
// Every payload is verified against its SHA-256 twice: once when the bundle
// is opened (the table of contents' own hash) and once after extraction, on
// the bytes that actually landed on disk. A mismatch at either point is a
// hard refusal -- nothing is installed, and nothing is left behind.
const (
	bundleMagic     = "TSBUNDLE"
	bundleVersion   = 1
	bundleHeaderLen = 52
	// maxTOCLen bounds how much a malformed or hostile header can make us
	// allocate before any of it has been verified.
	maxTOCLen = 16 << 20
)

// BundleEntry describes one program inside a .tsb file.
type BundleEntry struct {
	Slug     string `json:"slug"`
	Name     string `json:"name"`
	Group    string `json:"group"`
	Version  string `json:"version"`
	Filename string `json:"filename"`
	Size     int64  `json:"size"`
	SHA256   string `json:"sha256"`
	Offset   int64  `json:"offset"`
}

// BundleTOC is the table of contents of a .tsb file.
type BundleTOC struct {
	Created   string        `json:"created"`
	Generator string        `json:"generator"`
	Entries   []BundleEntry `json:"entries"`
}

// Bundle is an open .tsb file ready to read from.
type Bundle struct {
	TOC         BundleTOC
	f           *os.File
	payloadBase int64
}

var errBadMagic = errors.New("not a .tsb bundle (bad magic number)")

// writeBundle produces a .tsb file from a set of resolved source files.
//
// It writes to out+".part" and renames on success, so an interrupted bundle
// never appears under the name the customer asked for.
func writeBundle(out string, items []SourceFile, created string) (err error) {
	sort.Slice(items, func(i, j int) bool {
		return catalogRank(items[i].Slug) < catalogRank(items[j].Slug)
	})

	toc := BundleTOC{Created: created, Generator: "techlosoft-suite " + suiteVersion}
	var offset int64
	for _, it := range items {
		toc.Entries = append(toc.Entries, BundleEntry{
			Slug:     it.Slug,
			Name:     it.Name,
			Group:    it.Group,
			Version:  it.Version,
			Filename: it.Filename,
			Size:     it.Size,
			SHA256:   it.SHA256,
			Offset:   offset,
		})
		offset += it.Size
	}
	tocBytes, err := json.Marshal(toc)
	if err != nil {
		return err
	}
	if len(tocBytes) > maxTOCLen {
		return fmt.Errorf("table of contents is %d bytes, over the %d byte limit", len(tocBytes), maxTOCLen)
	}
	tocHash := sha256.Sum256(tocBytes)

	tmp := out + partSuffix
	f, err := os.Create(tmp)
	if err != nil {
		return err
	}
	defer func() {
		cerr := f.Close()
		if err == nil {
			err = cerr
		}
		if err != nil {
			// Our own half-written temporary file, never a customer's file.
			os.Remove(tmp)
			return
		}
		err = os.Rename(tmp, out)
	}()

	hdr := make([]byte, bundleHeaderLen)
	copy(hdr[0:8], bundleMagic)
	binary.BigEndian.PutUint16(hdr[8:10], bundleVersion)
	binary.BigEndian.PutUint16(hdr[10:12], 0)
	binary.BigEndian.PutUint64(hdr[12:20], uint64(len(tocBytes)))
	copy(hdr[20:52], tocHash[:])
	if _, err := f.Write(hdr); err != nil {
		return err
	}
	if _, err := f.Write(tocBytes); err != nil {
		return err
	}
	for i, it := range items {
		src, err := os.Open(it.Path)
		if err != nil {
			return err
		}
		n, err := io.Copy(f, src)
		src.Close()
		if err != nil {
			return err
		}
		if n != toc.Entries[i].Size {
			return fmt.Errorf("%s changed size while being bundled (%d bytes, expected %d)", it.Path, n, toc.Entries[i].Size)
		}
	}
	return nil
}

// openBundle opens a .tsb file and verifies its header and table of contents
// before returning. The payloads are verified individually on extraction.
func openBundle(path string) (*Bundle, error) {
	f, err := os.Open(path)
	if err != nil {
		return nil, err
	}
	ok := false
	defer func() {
		if !ok {
			f.Close()
		}
	}()

	hdr := make([]byte, bundleHeaderLen)
	if _, err := io.ReadFull(f, hdr); err != nil {
		if errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.EOF) {
			return nil, fmt.Errorf("%s: %w", path, errBadMagic)
		}
		return nil, err
	}
	if string(hdr[0:8]) != bundleMagic {
		return nil, fmt.Errorf("%s: %w", path, errBadMagic)
	}
	if v := binary.BigEndian.Uint16(hdr[8:10]); v != bundleVersion {
		return nil, fmt.Errorf("%s: bundle format version %d, this build understands version %d", path, v, bundleVersion)
	}
	if r := binary.BigEndian.Uint16(hdr[10:12]); r != 0 {
		return nil, fmt.Errorf("%s: reserved header field is %d, expected 0", path, r)
	}
	tocLen := binary.BigEndian.Uint64(hdr[12:20])
	if tocLen == 0 || tocLen > maxTOCLen {
		return nil, fmt.Errorf("%s: table of contents length %d is not believable", path, tocLen)
	}
	tocBytes := make([]byte, tocLen)
	if _, err := io.ReadFull(f, tocBytes); err != nil {
		return nil, fmt.Errorf("%s: table of contents is truncated", path)
	}
	got := sha256.Sum256(tocBytes)
	if hex.EncodeToString(got[:]) != hex.EncodeToString(hdr[20:52]) {
		return nil, fmt.Errorf("%s: table of contents does not match its own SHA-256 -- the file is damaged", path)
	}
	var toc BundleTOC
	if err := json.Unmarshal(tocBytes, &toc); err != nil {
		return nil, fmt.Errorf("%s: table of contents is not valid JSON: %w", path, err)
	}
	// The offsets must be exactly consecutive; anything else means a payload
	// area we would be reading blind.
	var want int64
	for _, e := range toc.Entries {
		if e.Offset != want {
			return nil, fmt.Errorf("%s: entry %q is at offset %d, expected %d", path, e.Slug, e.Offset, want)
		}
		if e.Size < 0 {
			return nil, fmt.Errorf("%s: entry %q has a negative size", path, e.Slug)
		}
		if len(e.SHA256) != 64 {
			return nil, fmt.Errorf("%s: entry %q has no usable SHA-256", path, e.Slug)
		}
		want += e.Size
	}
	info, err := f.Stat()
	if err != nil {
		return nil, err
	}
	base := int64(bundleHeaderLen) + int64(tocLen)
	if info.Size() < base+want {
		return nil, fmt.Errorf("%s: file is %d bytes, the table of contents describes %d -- truncated", path, info.Size(), base+want)
	}

	ok = true
	return &Bundle{TOC: toc, f: f, payloadBase: base}, nil
}

// Close releases the underlying file.
func (b *Bundle) Close() error { return b.f.Close() }

// entry finds one program in the bundle.
func (b *Bundle) entry(slug string) (BundleEntry, bool) {
	for _, e := range b.TOC.Entries {
		if e.Slug == slug {
			return e, true
		}
	}
	return BundleEntry{}, false
}

// reader returns a reader over one payload.
func (b *Bundle) reader(e BundleEntry) io.Reader {
	return io.NewSectionReader(b.f, b.payloadBase+e.Offset, e.Size)
}
