package main

import (
	"fmt"
	"strconv"
	"strings"
	"unicode/utf16"
)

// ---------------------------------------------------------------------------
// 8.3 short-name generation and VFAT long-file-name encoding (writer side).
//
// This is the real generation algorithm, not a hash: the basis name is derived
// by uppercasing, substituting invalid characters, stripping embedded dots and
// spaces, and truncating; a ~N numeric tail is appended when the basis is lossy
// or collides with a name already used in the same directory.
// ---------------------------------------------------------------------------

const (
	attrReadOnly  = 0x01
	attrHidden    = 0x02
	attrSystem    = 0x04
	attrVolumeID  = 0x08
	attrDirectory = 0x10
	attrArchive   = 0x20
	attrLongName  = attrReadOnly | attrHidden | attrSystem | attrVolumeID // 0x0F

	// A long name occupies at most 255 UTF-16 code units, 13 per LFN slot.
	maxLongNameUnits = 255
	lfnCharsPerSlot  = 13
	dirEntryBytes    = 32
)

// shortNameInvalid reports whether c may not appear in an 8.3 short name.
// The permitted set is from fatgen103: A-Z, 0-9 and $%'-_@~`!(){}^#&, plus
// space (which we still treat as needing removal because a name containing one
// cannot round-trip). Everything else, including every byte >= 0x80, is
// substituted with '_' because this writer does not do OEM code-page
// conversion.
func shortNameInvalid(c byte) bool {
	if c < 0x20 {
		return true
	}
	switch c {
	case '"', '*', '+', ',', '.', '/', ':', ';', '<', '=', '>', '?', '[', '\\', ']', '|':
		return true
	}
	if c >= 0x80 {
		return true
	}
	return false
}

// shortBasis derives the un-tailed 8.3 basis for a long name.
//
// lossy is true when the basis cannot reproduce the original name for any
// reason other than letter case: characters substituted or dropped, the base
// or extension truncated, more than one dot, or an empty base. A lossy basis
// always gets a ~N numeric tail, per the specification.
func shortBasis(long string) (base, ext string, lossy bool) {
	s := long

	// Leading and trailing spaces are removed; leading periods are removed.
	trimmed := strings.Trim(s, " ")
	if trimmed != s {
		lossy = true
	}
	s = trimmed
	for strings.HasPrefix(s, ".") {
		s = s[1:]
		lossy = true
	}
	for strings.HasSuffix(s, ".") {
		s = s[:len(s)-1]
		lossy = true
	}

	rawBase, rawExt := s, ""
	if i := strings.LastIndex(s, "."); i > 0 {
		rawBase, rawExt = s[:i], s[i+1:]
	}
	if strings.Contains(rawBase, ".") {
		lossy = true // more than one dot: the extra ones are dropped below
	}

	clean := func(in string, limit int) string {
		var out []byte
		for i := 0; i < len(in); i++ {
			c := in[i]
			switch {
			case c == ' ':
				lossy = true // spaces are skipped, not substituted
				continue
			case c == '.':
				lossy = true // embedded dots are skipped
				continue
			case shortNameInvalid(c):
				lossy = true
				out = append(out, '_')
			default:
				if c >= 'a' && c <= 'z' {
					c -= 'a' - 'A'
				}
				out = append(out, c)
			}
		}
		if len(out) > limit {
			out = out[:limit]
			lossy = true
		}
		return string(out)
	}

	base = clean(rawBase, 8)
	ext = clean(rawExt, 3)
	if base == "" {
		base = "_"
		lossy = true
	}
	return base, ext, lossy
}

// packShortName lays a base and extension into the 11-byte, space-padded,
// dot-less on-disk form.
func packShortName(base, ext string) [11]byte {
	var out [11]byte
	for i := range out {
		out[i] = ' '
	}
	copy(out[0:8], base)
	copy(out[8:11], ext)
	// 0xE5 as the first byte means "deleted"; the spec substitutes 0x05.
	if out[0] == 0xE5 {
		out[0] = 0x05
	}
	return out
}

// unpackShortName renders an 11-byte on-disk short name back as "BASE.EXT".
func unpackShortName(raw [11]byte) string {
	b := strings.TrimRight(string(raw[0:8]), " ")
	e := strings.TrimRight(string(raw[8:11]), " ")
	if len(b) > 0 && raw[0] == 0x05 {
		b = "\xE5" + b[1:]
	}
	if e == "" {
		return b
	}
	return b + "." + e
}

// GenerateShortName produces the 8.3 entry for long inside a directory whose
// already-assigned short names are in taken (keyed by the 11-byte packed form).
// It records the chosen name in taken and reports whether LFN slots are needed.
func GenerateShortName(long string, taken map[string]bool) ([11]byte, bool, error) {
	if long == "" {
		return [11]byte{}, false, fmt.Errorf("empty file name")
	}
	if n := len(utf16.Encode([]rune(long))); n > maxLongNameUnits {
		return [11]byte{}, false, fmt.Errorf("name %q is %d UTF-16 units long; FAT allows at most %d", long, n, maxLongNameUnits)
	}

	base, ext, lossy := shortBasis(long)

	if !lossy {
		cand := packShortName(base, ext)
		if !taken[string(cand[:])] {
			taken[string(cand[:])] = true
			// LFN slots are still needed unless the short name reproduces the
			// original byte for byte (i.e. the original was already uppercase).
			return cand, unpackShortName(cand) != long, nil
		}
	}

	// Numeric tail. n runs 1..999999; the base is truncated to make room.
	for n := 1; n <= 999999; n++ {
		tail := "~" + strconv.Itoa(n)
		keep := 8 - len(tail)
		b := base
		if len(b) > keep {
			b = b[:keep]
		}
		cand := packShortName(b+tail, ext)
		if !taken[string(cand[:])] {
			taken[string(cand[:])] = true
			return cand, true, nil
		}
	}
	return [11]byte{}, false, fmt.Errorf("cannot generate a unique short name for %q", long)
}

// LFNChecksum is the OSTA checksum carried in every long-name slot, computed
// over the 11 bytes of the short name it belongs to. The rotation is a right
// rotate through 8 bits, per fatgen103's ChkSum().
func LFNChecksum(short [11]byte) byte {
	var sum byte
	for i := 0; i < 11; i++ {
		sum = ((sum & 1) << 7) + (sum >> 1) + short[i]
	}
	return sum
}

// BuildLFNSlots returns the long-name directory entries for long, in the order
// they must appear ON DISK: last slot first (bearing the 0x40 LAST_LONG_ENTRY
// flag), counting down to slot 1, immediately followed by the short entry.
func BuildLFNSlots(long string, short [11]byte) []byte {
	units := utf16.Encode([]rune(long))
	slots := (len(units) + lfnCharsPerSlot - 1) / lfnCharsPerSlot
	if slots == 0 {
		slots = 1
	}
	sum := LFNChecksum(short)

	out := make([]byte, 0, slots*dirEntryBytes)
	for s := slots; s >= 1; s-- {
		var e [dirEntryBytes]byte
		ord := byte(s)
		if s == slots {
			ord |= 0x40 // LAST_LONG_ENTRY
		}
		e[0] = ord
		e[11] = attrLongName
		e[12] = 0 // type, always 0
		e[13] = sum
		e[26], e[27] = 0, 0 // first cluster, always 0 in an LFN slot

		// Character positions within the entry: 5 at 1..10, 6 at 14..25,
		// 2 at 28..31.
		offsets := []int{1, 3, 5, 7, 9, 14, 16, 18, 20, 22, 24, 28, 30}
		start := (s - 1) * lfnCharsPerSlot
		for i := 0; i < lfnCharsPerSlot; i++ {
			var v uint16
			idx := start + i
			switch {
			case idx < len(units):
				v = units[idx]
			case idx == len(units):
				v = 0x0000 // NUL terminator
			default:
				v = 0xFFFF // pad
			}
			o := offsets[i]
			e[o] = byte(v)
			e[o+1] = byte(v >> 8)
		}
		out = append(out, e[:]...)
	}
	return out
}
