package ca

import (
	"context"
	"encoding/binary"
	"fmt"
	"net"
	"sync"
	"sync/atomic"
	"time"

	"github.com/uopi/goca/proto"
)

// searchResult is returned to a waiter when a search succeeds.
type searchResult struct {
	addr string // "ip:port" TCP address of the IOC
}

// searchWaiter tracks an outstanding PV search request.
type searchWaiter struct {
	pvName string
	ch     chan searchResult // closed or sent to exactly once
}

// searchEngine performs UDP-based PV name → IOC address resolution.
// It sends CA_PROTO_SEARCH datagrams to all configured server addresses and
// listens for replies, matching them to pending waiters via a searchID.
type searchEngine struct {
	addrs []string // "ip:port" strings to search

	mu      sync.Mutex
	waiters map[uint32]*searchWaiter // searchID → waiter

	idSeq atomic.Uint32
	conn  *net.UDPConn // shared UDP socket (nil if not started)
}

func newSearchEngine(addrs []string) *searchEngine {
	return &searchEngine{
		addrs:   addrs,
		waiters: make(map[uint32]*searchWaiter),
	}
}

// start opens the shared UDP socket and launches the receive goroutine.
// ctx cancellation stops the receive loop and closes the socket.
func (se *searchEngine) start(ctx context.Context) error {
	conn, err := net.ListenUDP("udp4", &net.UDPAddr{})
	if err != nil {
		return fmt.Errorf("ca search: open UDP socket: %w", err)
	}
	se.conn = conn

	go se.recvLoop(ctx)
	return nil
}

// lookup resolves pvName to an IOC TCP address.
// It retries with exponential back-off until ctx is cancelled or done.
func (se *searchEngine) lookup(ctx context.Context, pvName string) (string, error) {
	id := se.idSeq.Add(1)
	w := &searchWaiter{
		pvName: pvName,
		ch:     make(chan searchResult, 1),
	}

	se.mu.Lock()
	se.waiters[id] = w
	se.mu.Unlock()

	defer func() {
		se.mu.Lock()
		delete(se.waiters, id)
		se.mu.Unlock()
	}()

	delay := 100 * time.Millisecond
	const maxDelay = 30 * time.Second

	for {
		if err := se.sendSearch(id, pvName); err != nil {
			return "", err
		}

		select {
		case res := <-w.ch:
			return res.addr, nil
		case <-time.After(delay):
			delay *= 2
			if delay > maxDelay {
				delay = maxDelay
			}
		case <-ctx.Done():
			return "", fmt.Errorf("ca search %q: %w", pvName, ctx.Err())
		}
	}
}

// sendSearch sends a CA_PROTO_VERSION + CA_PROTO_SEARCH burst to all addresses.
// The CA protocol requires both messages to be in the same UDP datagram.
func (se *searchEngine) sendSearch(id uint32, pvName string) error {
	payload := proto.BuildStringPayload(pvName)

	// VERSION message (no payload).
	verHdr := proto.Header{
		Command:   proto.CmdVersion,
		DataCount: proto.MinorVersion,
	}

	// SEARCH message.
	searchHdr := proto.Header{
		Command:    proto.CmdSearch,
		DataType:   proto.SearchReply,
		DataCount:  proto.MinorVersion,
		Parameter1: id,
		Parameter2: id,
	}
	searchMsg := proto.BuildMessage(searchHdr, payload)

	// Bundle into a single datagram — softIoc (and the CA Repeater) require
	// VERSION and SEARCH to arrive together in one UDP packet.
	pkt := make([]byte, 0, proto.HeaderSize+len(searchMsg))
	pkt = append(pkt, verHdr.Bytes()...)
	pkt = append(pkt, searchMsg...)

	for _, addr := range se.addrs {
		udpAddr, err := net.ResolveUDPAddr("udp4", addr)
		if err != nil {
			continue
		}
		_, _ = se.conn.WriteTo(pkt, udpAddr)
		dbg("CA search sent", "pv", pvName, "id", id, "dst", udpAddr)
	}
	return nil
}

// recvLoop reads UDP datagrams and dispatches search replies.
func (se *searchEngine) recvLoop(ctx context.Context) {
	buf := make([]byte, 65536)
	for {
		// Use a short read deadline so we can check ctx.Done() periodically.
		_ = se.conn.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
		n, srcAddr, err := se.conn.ReadFromUDP(buf)
		if err != nil {
			if ctx.Err() != nil {
				return
			}
			if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
				continue
			}
			continue
		}

		dbg("CA UDP datagram received", "src", srcAddr, "bytes", n)
		se.parseReply(buf[:n], srcAddr)
	}
}

// parseReply scans a UDP datagram for CA_PROTO_SEARCH response messages and
// wakes the matching waiter.
func (se *searchEngine) parseReply(data []byte, src *net.UDPAddr) {
	for len(data) >= proto.HeaderSize {
		h, n, err := proto.DecodeHeader(newBytesReader(data))
		if err != nil {
			return
		}
		payloadEnd := n + int(h.PayloadSize)
		if payloadEnd > len(data) {
			return
		}
		payload := data[n:payloadEnd]
		data = data[payloadEnd:]

		if h.Command != proto.CmdSearch {
			continue
		}

		// data_type field = TCP port of the CA server.
		tcpPort := int(h.DataType)
		searchID := h.Parameter2

		// Resolve server IP.
		//
		// Two payload formats exist in the wild:
		//
		//   New (≥ CA 4.9):  [IP(4)][pad(4)]
		//   Old (< CA 4.9):  [minor_version(2)][pad(2)][IP(4)]
		//
		// The old format is identified by payload[0] == 0x00 (the high byte of a
		// uint16 minor version is always zero for CA versions ≤ 255, whereas a
		// real IPv4 address never starts with 0x00).
		//
		// In both formats, 0xFFFFFFFF in the IP position means "use sender IP".
		var serverIP net.IP
		if len(payload) >= 8 && payload[0] == 0x00 {
			// Old format: minor version at [0:2], IP at [4:8].
			ipSlice := payload[4:8]
			if useSenderIP(ipSlice) {
				serverIP = src.IP
			} else {
				ip := make(net.IP, 4)
				copy(ip, ipSlice)
				serverIP = ip
			}
		} else if len(payload) >= 4 {
			// New format: IP at [0:4].
			if useSenderIP(payload[:4]) {
				serverIP = src.IP
			} else {
				ip := make(net.IP, 4)
				copy(ip, payload[:4])
				serverIP = ip
			}
		} else {
			serverIP = src.IP
		}

		tcpAddr := fmt.Sprintf("%s:%d", serverIP.String(), tcpPort)
		dbg("CA search reply", "searchID", searchID, "tcpAddr", tcpAddr, "src", src)

		se.mu.Lock()
		w, ok := se.waiters[searchID]
		if ok {
			delete(se.waiters, searchID)
		}
		se.mu.Unlock()

		if ok {
			dbg("CA search matched", "pv", w.pvName, "ioc", tcpAddr)
			select {
			case w.ch <- searchResult{addr: tcpAddr}:
			default:
			}
		} else {
			dbg("CA search reply unmatched", "searchID", searchID, "known_waiters", func() int {
				se.mu.Lock()
				defer se.mu.Unlock()
				return len(se.waiters)
			}())
		}
	}
}

// useSenderIP reports whether an IP field in a CA search reply means
// "use the sender's address".  Both 0.0.0.0 (all-zeros) and 255.255.255.255
// (all-ones / 0xFFFFFFFF) are used by different EPICS Base versions.
func useSenderIP(b []byte) bool {
	allFF := true
	allZero := true
	for _, v := range b {
		if v != 0xFF {
			allFF = false
		}
		if v != 0x00 {
			allZero = false
		}
	}
	return allFF || allZero
}

func isAllFF(b []byte) bool {
	for _, v := range b {
		if v != 0xFF {
			return false
		}
	}
	return true
}

// -------------------------------------------------------------------------- //
// Address list helpers                                                        //
// -------------------------------------------------------------------------- //

// resolveAddrs converts a list of "host" or "host:port" strings to
// "host:port" strings using defaultPort when no port is specified.
func resolveAddrs(addrs []string, defaultPort int) []string {
	out := make([]string, 0, len(addrs))
	for _, a := range addrs {
		if a == "" {
			continue
		}
		_, _, err := net.SplitHostPort(a)
		if err != nil {
			// No port specified; append default.
			a = fmt.Sprintf("%s:%d", a, defaultPort)
		}
		out = append(out, a)
	}
	return out
}

// localBroadcastAddrs returns broadcast addresses for all local network
// interfaces, used when AutoAddrList is true.
func localBroadcastAddrs(port int) []string {
	ifaces, err := net.Interfaces()
	if err != nil {
		return nil
	}
	var out []string
	for _, iface := range ifaces {
		if iface.Flags&net.FlagBroadcast == 0 {
			continue
		}
		addrs, err := iface.Addrs()
		if err != nil {
			continue
		}
		for _, a := range addrs {
			ipNet, ok := a.(*net.IPNet)
			if !ok {
				continue
			}
			ip4 := ipNet.IP.To4()
			if ip4 == nil {
				continue
			}
			// Compute broadcast: IP | ^mask
			mask := ipNet.Mask
			bcast := make(net.IP, 4)
			for i := range bcast {
				bcast[i] = ip4[i] | ^mask[i]
			}
			out = append(out, fmt.Sprintf("%s:%d", bcast.String(), port))
		}
	}
	return out
}

// -------------------------------------------------------------------------- //
// Minimal bytes.Reader-like for proto.DecodeHeader                           //
// -------------------------------------------------------------------------- //

type bytesReader struct {
	b []byte
	i int
}

func newBytesReader(b []byte) *bytesReader { return &bytesReader{b: b} }

func (r *bytesReader) Read(p []byte) (int, error) {
	if r.i >= len(r.b) {
		return 0, fmt.Errorf("EOF")
	}
	n := copy(p, r.b[r.i:])
	r.i += n
	return n, nil
}

// -------------------------------------------------------------------------- //
// searchAddrFromReply extracts server IP from a CA_PROTO_SEARCH response.   //
// Exported for testability.                                                   //
// -------------------------------------------------------------------------- //

// EncodedSearchPair builds the VERSION + SEARCH UDP datagram pair for pvName
// with the given searchID. Exported for use in tests.
func EncodedSearchPair(pvName string, searchID uint32) []byte {
	verHdr := proto.Header{
		Command:   proto.CmdVersion,
		DataCount: proto.MinorVersion,
	}
	payload := proto.BuildStringPayload(pvName)
	searchHdr := proto.Header{
		Command:    proto.CmdSearch,
		DataType:   proto.SearchReply,
		DataCount:  proto.MinorVersion,
		Parameter1: searchID,
		Parameter2: searchID,
	}
	searchMsg := proto.BuildMessage(searchHdr, payload)
	out := make([]byte, 0, proto.HeaderSize+len(searchMsg))
	out = append(out, verHdr.Bytes()...)
	out = append(out, searchMsg...)
	return out
}

// EncodeSearchReply builds a CA_PROTO_SEARCH UDP reply.
// serverIP may be nil to use 0xFFFFFFFF (sender IP).  port is the CA TCP port.
func EncodeSearchReply(searchID uint32, serverIP net.IP, port int) []byte {
	var payload []byte
	if serverIP != nil {
		payload = make([]byte, 8)
		copy(payload[:4], serverIP.To4())
		binary.BigEndian.PutUint32(payload[4:], 0)
	} else {
		payload = []byte{0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00}
	}
	h := proto.Header{
		Command:    proto.CmdSearch,
		DataType:   uint16(port),
		Parameter1: proto.MinorVersion,
		Parameter2: searchID,
	}
	return proto.BuildMessage(h, payload)
}
