package protocol

import (
	"encoding/hex"
	"fmt"
	"hash/fnv"
	"strconv"
	"strings"
)

// Relay indices: each MediaMeta.Relays[N] flags whether the file is
// reachable via that relay. Order is fixed so the wire format is positional.
// Future relays append to this list; older clients ignore unknown trailing
// flags.
const (
	RelayDNS    = 0 // slow path — bytes assembled from DNS blocks
	RelayGitHub = 1 // fast path — bytes pulled from a GitHub repo
)

// MediaMeta describes a downloadable media blob attached to a feed message.
//
// Wire format (immediately after the media tag, before any caption):
//
//	[IMAGE]<size>:<f0>,<f1>,...:<dnsCh>:<dnsBlk>:<crc32hex>[:<filename>]
//
// where each <fN> is "1" or "0" indicating availability via relay N.
// <dnsCh>:<dnsBlk> are only meaningful when f0 (RelayDNS) is set.
type MediaMeta struct {
	Tag      string // e.g. MediaImage, MediaVideo, MediaFile
	Size     int64
	Relays   []bool // index = relay constant, value = availability
	Channel  uint16 // DNS channel (when Relays[RelayDNS])
	Blocks   uint16 // DNS block count (when Relays[RelayDNS])
	CRC32    uint32
	Filename string
}

// HasRelay reports whether the relay at idx is available. Out-of-range and
// nil-relay-list both return false.
func (m MediaMeta) HasRelay(idx int) bool {
	if idx < 0 || idx >= len(m.Relays) {
		return false
	}
	return m.Relays[idx]
}

// HasAnyRelay reports whether at least one relay can serve this file.
func (m MediaMeta) HasAnyRelay() bool {
	for _, on := range m.Relays {
		if on {
			return true
		}
	}
	return false
}

// String renders the metadata in the wire format documented above.
func (m MediaMeta) String() string {
	flags := encodeRelayFlags(m.Relays)
	if fn := SanitiseMediaFilename(m.Filename); fn != "" {
		return fmt.Sprintf("%s%d:%s:%d:%d:%08x:%s\n",
			m.Tag, m.Size, flags, m.Channel, m.Blocks, m.CRC32, fn)
	}
	return fmt.Sprintf("%s%d:%s:%d:%d:%08x\n",
		m.Tag, m.Size, flags, m.Channel, m.Blocks, m.CRC32)
}

// encodeRelayFlags serialises a relay list as "1,0,1". An empty list is
// "0,0" (DNS off, GitHub off) so older clients always see at least the two
// known relay slots.
func encodeRelayFlags(relays []bool) string {
	n := len(relays)
	if n < 2 {
		n = 2
	}
	parts := make([]string, n)
	for i := 0; i < n; i++ {
		on := i < len(relays) && relays[i]
		if on {
			parts[i] = "1"
		} else {
			parts[i] = "0"
		}
	}
	return strings.Join(parts, ",")
}

// parseRelayFlags decodes "1,0,1" into a relay slice sized to the input.
// Caller-side accessors guard against out-of-range reads, so future relays
// can be added without breaking older clients.
func parseRelayFlags(s string) ([]bool, bool) {
	if s == "" {
		return nil, false
	}
	parts := strings.Split(s, ",")
	out := make([]bool, len(parts))
	for i, p := range parts {
		p = strings.TrimSpace(p)
		if p != "0" && p != "1" {
			return nil, false
		}
		out[i] = p == "1"
	}
	return out, true
}

// SanitiseMediaFilename returns a filename safe to embed in the wire
// metadata line. The output uses a restricted alphabet ([A-Za-z0-9._-]) so
// no path separator, colon, newline, or control char can ever survive.
// When the input is too long the base name is replaced with a short
// hash-derived id but the extension is preserved so other OSes still
// recognise the file type.
func SanitiseMediaFilename(s string) string {
	s = strings.TrimSpace(s)
	if s == "" {
		return ""
	}
	if i := strings.LastIndexAny(s, `/\`); i >= 0 {
		s = s[i+1:]
	}
	cleaned := filterFilenameRunes(s)
	if cleaned == "" || cleaned == "." || cleaned == ".." {
		return ""
	}

	const maxBase = 64
	const maxExt = 8

	base, ext := splitFilenameExt(cleaned)
	if len(ext) > maxExt {
		ext = ext[:maxExt]
	}
	if len(base) > maxBase {
		h := fnv.New64a()
		_, _ = h.Write([]byte(cleaned))
		base = "media-" + hex.EncodeToString(h.Sum(nil))[:8]
	}
	if base == "" || base == "." {
		base = "media"
	}
	if ext != "" {
		return base + "." + ext
	}
	return base
}

func filterFilenameRunes(s string) string {
	var b strings.Builder
	for _, r := range s {
		switch {
		case r >= '0' && r <= '9',
			r >= 'A' && r <= 'Z',
			r >= 'a' && r <= 'z',
			r == '.', r == '_', r == '-':
			b.WriteRune(r)
		}
	}
	return b.String()
}

func splitFilenameExt(s string) (base, ext string) {
	if i := strings.LastIndexByte(s, '.'); i >= 0 && i < len(s)-1 {
		return s[:i], s[i+1:]
	}
	return s, ""
}

// EncodeMediaText prepends the metadata line to an optional caption and
// returns the combined message text. A nil/empty caption yields just the tag
// + metadata + trailing newline-less string (the caption split is by the
// metadata line's trailing \n, so an empty caption simply has no extra body).
func EncodeMediaText(meta MediaMeta, caption string) string {
	header := meta.String()
	if caption == "" {
		// Drop the trailing newline so the message text doesn't end with a
		// blank line for caption-less media.
		return strings.TrimSuffix(header, "\n")
	}
	return header + caption
}

// ParseMediaText parses a message body that begins with a known media tag.
// Returns metadata + remaining caption. Legacy "[TAG]\ncaption" bodies parse
// with empty Relays (HasAnyRelay()==false). Unknown tags return ok=false.
func ParseMediaText(body string) (meta MediaMeta, caption string, ok bool) {
	tag, rest, found := splitKnownMediaTag(body)
	if !found {
		return MediaMeta{}, body, false
	}
	meta.Tag = tag

	// The bit between the tag and the first newline is the metadata payload.
	nl := strings.IndexByte(rest, '\n')
	var metaLine string
	if nl < 0 {
		metaLine = rest
		caption = ""
	} else {
		metaLine = rest[:nl]
		caption = rest[nl+1:]
	}
	metaLine = strings.TrimSpace(metaLine)

	if metaLine == "" {
		// Legacy [TAG]\ncaption — no per-file metadata. Treat as not-downloadable.
		return MediaMeta{Tag: tag}, caption, true
	}

	parts := strings.Split(metaLine, ":")
	if len(parts) < 5 {
		return MediaMeta{Tag: tag}, rest, true
	}

	size, err := strconv.ParseInt(parts[0], 10, 64)
	if err != nil || size < 0 {
		return MediaMeta{Tag: tag}, rest, true
	}
	relays, ok := parseRelayFlags(parts[1])
	if !ok {
		return MediaMeta{Tag: tag}, rest, true
	}
	ch, err := strconv.ParseUint(parts[2], 10, 16)
	if err != nil {
		return MediaMeta{Tag: tag}, rest, true
	}
	blk, err := strconv.ParseUint(parts[3], 10, 16)
	if err != nil {
		return MediaMeta{Tag: tag}, rest, true
	}
	crc, err := strconv.ParseUint(parts[4], 16, 32)
	if err != nil {
		return MediaMeta{Tag: tag}, rest, true
	}
	// Reject DNS availability if the channel/block range is malformed —
	// other relays stay as-claimed.
	channel := uint16(ch)
	if len(relays) > RelayDNS && relays[RelayDNS] && (!IsMediaChannel(channel) || blk == 0) {
		relays[RelayDNS] = false
	}

	meta.Size = size
	meta.Relays = relays
	meta.Channel = channel
	meta.Blocks = uint16(blk)
	meta.CRC32 = uint32(crc)
	if len(parts) >= 6 {
		meta.Filename = SanitiseMediaFilename(parts[5])
	}
	return meta, caption, true
}

// knownMediaTags are the message text prefixes that mark a downloadable media
// attachment. Order matters only for prefix matching; longer/more-specific
// tags are not currently aliased so the order is alphabetical for clarity.
var knownMediaTags = []string{
	MediaAudio,
	MediaFile,
	MediaGIF,
	MediaImage,
	MediaSticker,
	MediaVideo,
}

// splitKnownMediaTag returns the matched tag and the remainder of the body
// when body starts with one of knownMediaTags.
func splitKnownMediaTag(body string) (tag, rest string, ok bool) {
	for _, t := range knownMediaTags {
		if strings.HasPrefix(body, t) {
			return t, body[len(t):], true
		}
	}
	return "", body, false
}
