package knotfeed

import (
	"bytes"
	"fmt"
	"io"
	"slices"
	"strings"
	"unicode/utf8"

	"github.com/bluesky-social/indigo/atproto/syntax"
	"github.com/samber/lo"
	cbg "github.com/whyrusleeping/cbor-gen"
)

const maxPushOptions = 64
const GitRefCollection syntax.NSID = "sh.tangled.git.ref"

const mstKeyBudget = 256

const MaxRefRkeyBytes = mstKeyBudget - len(GitRefCollection) - 1

const hexDigits = "0123456789abcdef"

func plainByte(b byte) bool {
	switch {
	case b >= 'A' && b <= 'Z', b >= 'a' && b <= 'z', b >= '0' && b <= '9':
		return true
	case b == '-', b == '_':
		return true
	default:
		return false
	}
}

func allPlain(s string) bool {
	for i := range len(s) {
		if !plainByte(s[i]) {
			return false
		}
	}
	return true
}

func EscapeRefname(refname string) (syntax.RecordKey, bool) {
	out := make([]byte, 0, len(refname))
	for i := range len(refname) {
		b := refname[i]
		if plainByte(b) {
			out = append(out, b)
			continue
		}
		out = append(out, '~', hexDigits[b>>4], hexDigits[b&0x0f])
	}
	if len(out) > MaxRefRkeyBytes {
		return "", false
	}
	return syntax.RecordKey(out), true
}

func UnescapeRkey(rkey syntax.RecordKey) (string, bool) {
	escaped := rkey.String()
	if !strings.Contains(escaped, "~") {
		return escaped, allPlain(escaped)
	}
	var out []byte
	rest := escaped
	for {
		idx := strings.IndexByte(rest, '~')
		if idx < 0 {
			if !allPlain(rest) {
				return "", false
			}
			if !utf8.Valid(out) {
				return "", false
			}
			return string(append(out, rest...)), true
		}
		head, chunk := rest[:idx], rest[idx+1:]
		if !allPlain(head) || len(chunk) < 2 {
			return "", false
		}
		hi, ok := nibble(chunk[0])
		if !ok {
			return "", false
		}
		lo, ok := nibble(chunk[1])
		if !ok {
			return "", false
		}
		out = append(out, head...)
		out = append(out, hi<<4|lo)
		rest = chunk[2:]
	}
}

func nibble(b byte) (byte, bool) {
	switch {
	case b >= '0' && b <= '9':
		return b - '0', true
	case b >= 'a' && b <= 'f':
		return b - 'a' + 10, true
	default:
		return 0, false
	}
}

type RefRecord struct {
	Sha         ObjectID
	Editor      syntax.DID
	PushOptions []string
}

func DecodeRefRecord(data []byte) (RefRecord, error) {
	var rec RefRecord
	var rawSha, rawEditor string
	r := bytes.NewReader(data)
	maj, count, err := cbg.CborReadHeader(r)
	if err != nil {
		return rec, err
	}
	if maj != cbg.MajMap {
		return rec, fmt.Errorf("expected a cbor map, got major type %d", maj)
	}
	for range count {
		key, err := cbg.ReadString(r)
		if err != nil {
			return rec, err
		}
		switch key {
		case "sha":
			if rawSha, err = cbg.ReadString(r); err != nil {
				return rec, err
			}
		case "x-tngl-editor":
			if rawEditor, err = cbg.ReadString(r); err != nil {
				return rec, err
			}
		case "x-tngl-push-options":
			if rec.PushOptions, err = readStringArray(r); err != nil {
				return rec, err
			}
		default:
			if err := skipValue(r, 0); err != nil {
				return rec, err
			}
		}
	}
	if r.Len() != 0 {
		return rec, fmt.Errorf("ref record has %d trailing bytes after its map", r.Len())
	}
	sha, ok := ParseObjectID(rawSha)
	if !ok || sha.IsZero() {
		return rec, fmt.Errorf("sha %q isn't 40 or 64 hex digits naming an object", rawSha)
	}
	rec.Sha = sha
	if rawEditor == "" {
		return rec, nil
	}
	editor, err := syntax.ParseDID(rawEditor)
	if err != nil {
		return rec, fmt.Errorf("ref record editor: %w", err)
	}
	rec.Editor = editor
	return rec, nil
}

func readStringArray(r *bytes.Reader) ([]string, error) {
	maj, count, err := cbg.CborReadHeader(r)
	if err != nil {
		return nil, err
	}
	if maj != cbg.MajArray {
		return nil, fmt.Errorf("expected a cbor array, got major type %d", maj)
	}
	if count > maxPushOptions {
		return nil, fmt.Errorf("push options array claims %d entries", count)
	}
	out := make([]string, 0, count)
	for range count {
		s, err := cbg.ReadString(r)
		if err != nil {
			return nil, err
		}
		out = append(out, s)
	}
	return out, nil
}

func DecodeRefsSnapshot(data []byte) (defaultBranch string, packedRefs []byte, err error) {
	r := bytes.NewReader(data)
	maj, count, err := cbg.CborReadHeader(r)
	if err != nil {
		return "", nil, err
	}
	if maj != cbg.MajMap {
		return "", nil, fmt.Errorf("expected a cbor map, got major type %d", maj)
	}
	var sawBranch, sawRefs bool
	for range count {
		key, err := cbg.ReadString(r)
		if err != nil {
			return "", nil, err
		}
		switch key {
		case "defaultBranch":
			if defaultBranch, err = cbg.ReadString(r); err != nil {
				return "", nil, err
			}
			sawBranch = true
		case "refs":
			maj, n, err := cbg.CborReadHeader(r)
			if err != nil {
				return "", nil, err
			}
			if maj != cbg.MajByteString {
				return "", nil, fmt.Errorf("refs should be bytes, got major type %d", maj)
			}
			packedRefs = make([]byte, n)
			if _, err := io.ReadFull(r, packedRefs); err != nil {
				return "", nil, err
			}
			sawRefs = true
		default:
			var skipped cbg.Deferred
			if err := skipped.UnmarshalCBOR(r); err != nil {
				return "", nil, err
			}
		}
	}
	if !sawBranch {
		return "", nil, fmt.Errorf("refs snapshot is missing defaultBranch")
	}
	if !sawRefs {
		return "", nil, fmt.Errorf("refs snapshot is missing refs")
	}
	return defaultBranch, packedRefs, nil
}

type cborBuf struct{ bytes.Buffer }

func (b *cborBuf) header(major byte, count uint64) {
	_ = cbg.CborWriteHeader(&b.Buffer, major, count)
}

func (b *cborBuf) str(s string) {
	b.header(cbg.MajTextString, uint64(len(s)))
	b.WriteString(s)
}

func (b *cborBuf) pair(key, value string) {
	b.str(key)
	b.str(value)
}

func encodeRefRecord(rec RefRecord) ([]byte, error) {
	past := func(s string) bool { return len(s) > cbg.MaxLength }
	switch {
	case rec.Sha.IsZero():
		return nil, fmt.Errorf("won't encode a ref record without a sha")
	case len(rec.PushOptions) > maxPushOptions:
		return nil, fmt.Errorf("won't encode %d push options, past the %d limit", len(rec.PushOptions), maxPushOptions)
	case past(rec.Editor.String()) || slices.ContainsFunc(rec.PushOptions, past):
		return nil, fmt.Errorf("ref record holds a string past the %d byte cbor limit", cbg.MaxLength)
	}

	pairs := uint64(2)
	if rec.Editor != "" {
		pairs++
	}
	if len(rec.PushOptions) > 0 {
		pairs++
	}

	var out cborBuf
	out.header(cbg.MajMap, pairs)
	out.pair("sha", rec.Sha.String())
	out.pair("$type", GitRefCollection.String())
	if rec.Editor != "" {
		out.pair("x-tngl-editor", rec.Editor.String())
	}
	if len(rec.PushOptions) > 0 {
		out.str("x-tngl-push-options")
		out.header(cbg.MajArray, uint64(len(rec.PushOptions)))
		lo.ForEach(rec.PushOptions, func(option string, _ int) { out.str(option) })
	}
	return out.Bytes(), nil
}
