//go:build epics

// Package epics provides an EPICS Channel Access data source for uopi.
//
// # Build requirements
//
// This file and all other files tagged "epics" require CGo and a working EPICS
// Base installation.  Set the following environment variables before building:
//
//	export EPICS_BASE=/path/to/epics/base
//	export CGO_CFLAGS="-I${EPICS_BASE}/include -I${EPICS_BASE}/include/os/Linux"
//	export CGO_LDFLAGS="-L${EPICS_BASE}/lib/linux-x86_64 -lca -lCom"
//
// Then build with:
//
//	CGO_ENABLED=1 go build -tags epics ./...
package epics

/*
#cgo CFLAGS: -Wall -Wno-unused-function
#include "ca_wrapper.h"
#include <stdlib.h>
*/
import "C"

import (
	"context"
	"fmt"
	"log/slog"
	"os"
	"sync"
	"sync/atomic"
	"time"
	"unsafe"

	"github.com/uopi/uopi/internal/datasource"
)

// connEntry holds the one-shot channel used during channel creation to wait for
// the initial connection callback.
type connEntry struct {
	connCh chan struct{}
}

// handleTable maps Go-side handle IDs to the subscriber's value channel.
// Protected by handleMu.
var (
	handleMu    sync.Mutex
	handleTable = make(map[uintptr]chan<- datasource.Value)
	connTable   = make(map[uintptr]*connEntry)
)

// handleSeq is an atomically-incremented counter used to generate unique handle
// IDs that are passed through CA as void* user pointers.
var handleSeq atomic.Uintptr

func nextHandle() uintptr {
	return handleSeq.Add(1)
}

// caChannel holds the CA resources for a single PV.
type caChannel struct {
	chid   C.chid
	evid   C.evid // monitor event ID; zero if no monitor installed
	handle uintptr
}

// EPICS is the Channel Access data source.
type EPICS struct {
	caAddrList           string
	archiveURL           string
	cfURL                string
	autoSyncFilter       string
	autoSyncFromArchiver bool

	// caCtx is the CA context created in Connect().  Stored as unsafe.Pointer
	// because the C type (ca_client_context *) is opaque.  Every goroutine
	// that calls CA functions must call caAttachContext(caCtx) first, because
	// Go goroutines can run on any OS thread and CA contexts are thread-local.
	caCtx unsafe.Pointer

	mu       sync.Mutex
	channels map[string]*caChannel // PV name → CA channel
	metadata map[string]datasource.Metadata
}

// New creates a new EPICS Channel Access data source.
//
// caAddrList is used to set EPICS_CA_ADDR_LIST at runtime (may be empty to
// rely on the environment).  archiveURL is the base URL of an EPICS Archive
// Appliance instance for history queries (may be empty).
// pvNames is accepted for API compatibility with the pure-Go build but ignored
// in the CGo build (the CGo implementation populates ListSignals via callbacks).
func New(caAddrList, archiveURL, cfURL, autoSyncFilter string, autoSyncFromArchiver bool, pvNames []string) datasource.DataSource {
	return &EPICS{
		caAddrList:           caAddrList,
		archiveURL:           archiveURL,
		cfURL:                cfURL,
		autoSyncFilter:       autoSyncFilter,
		autoSyncFromArchiver: autoSyncFromArchiver,
		channels:             make(map[string]*caChannel),
		metadata:             make(map[string]datasource.Metadata),
	}
}

// Available reports whether the EPICS data source is compiled in.
// Always returns true when built with the "epics" build tag.
func Available() bool { return true }

// Name implements datasource.DataSource.
func (e *EPICS) Name() string { return "epics" }

// Connect initialises a CA context with preemptive callbacks so that monitor
// callbacks are delivered on CA's background threads without needing a
// ca_pend_event polling loop.
func (e *EPICS) Connect(_ context.Context) error {
	// Apply CA address list overrides before creating the context so the
	// library picks them up during initialisation.
	// os.Setenv is used instead of C.putenv: putenv takes ownership of the
	// string pointer, which would race with Go's garbage collector.
	if e.caAddrList != "" {
		os.Setenv("EPICS_CA_ADDR_LIST", e.caAddrList)
		os.Setenv("EPICS_CA_AUTO_ADDR_LIST", "NO")
	}

	status := C.ca_context_create(C.ca_enable_preemptive_callback)
	if status != C.ECA_NORMAL {
		return fmt.Errorf("epics: ca_context_create failed: status %d", int(status))
	}
	// Save the context so goroutines on other OS threads can attach to it.
	e.caCtx = C.caCurrentContext()

	// Perform background sync from Channel Finder if configured.
	if e.cfURL != "" && e.autoSyncFilter != "" {
		go e.syncFromChannelFinder(context.Background())
	}

	// Perform background sync from Archive Appliance if configured.
	if e.archiveURL != "" && e.autoSyncFromArchiver {
		go e.syncFromArchiver(context.Background())
	}

	return nil
}

func (e *EPICS) syncFromChannelFinder(ctx context.Context) {
	time.Sleep(500 * time.Millisecond)
	slog.Info("epics: syncing signals from Channel Finder", "url", e.cfURL, "filter", e.autoSyncFilter)
	channels, err := queryChannelFinder(e.cfURL, e.autoSyncFilter)
	if err != nil {
		slog.Error("epics: Channel Finder sync failed", "err", err)
		return
	}

	for _, ch := range channels {
		// Convert CFS properties and tags
		props := make(map[string]string)
		for _, p := range ch.Properties {
			props[p.Name] = p.Value
		}
		tags := make([]string, len(ch.Tags))
		for i, t := range ch.Tags {
			tags[i] = t.Name
		}

		e.mu.Lock()
		if _, exists := e.metadata[ch.Name]; !exists {
			e.metadata[ch.Name] = datasource.Metadata{
				Name:       ch.Name,
				Properties: props,
				Tags:       tags,
			}
		}
		e.mu.Unlock()

		// Background pre-fetch of actual record info
		go e.prefetchPV(ctx, ch.Name)
	}
	slog.Info("epics: synced signals from Channel Finder", "count", len(channels))
}

func (e *EPICS) prefetchPV(ctx context.Context, pv string) {
	mctx, cancel := context.WithTimeout(ctx, 15*time.Second)
	defer cancel()
	_, _ = e.GetMetadata(mctx, pv)
}

// attachCAContext attaches the saved CA context to the calling OS thread.
// Must be called at the top of every goroutine that uses CA functions,
// because Go goroutines can be scheduled onto any OS thread.
// Safe to call redundantly (ECA_ISATTACHED is not an error).
func (e *EPICS) attachCAContext() {
	if e.caCtx != nil {
		C.caAttachContext(e.caCtx)
	}
}

// Subscribe creates a CA channel for signal and returns immediately.
// Connection and monitor setup happen in a background goroutine so that
// the caller is never blocked waiting for the IOC to respond.
// A QualityBad value is sent to ch if the channel cannot connect within
// 60 seconds. The returned CancelFunc tears down the subscription.
func (e *EPICS) Subscribe(ctx context.Context, signal string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
	// Attach the CA context to this OS thread before any CA calls.
	e.attachCAContext()

	handle := nextHandle()

	// Register the handle → channel mapping before creating the CA channel so
	// that any early callbacks are not lost.
	handleMu.Lock()
	handleTable[handle] = ch
	entry := &connEntry{connCh: make(chan struct{}, 1)}
	connTable[handle] = entry
	handleMu.Unlock()

	// Create the CA channel — this call returns immediately; the connection
	// callback fires asynchronously on CA's internal thread.
	pvName := C.CString(signal)
	defer C.free(unsafe.Pointer(pvName))

	var chid C.chid
	status := C.caCreateChannel(pvName, C.uintptr_t(handle), &chid)
	if status != C.ECA_NORMAL {
		handleMu.Lock()
		delete(handleTable, handle)
		delete(connTable, handle)
		handleMu.Unlock()
		return nil, fmt.Errorf("epics: ca_create_channel(%q) failed: status %d", signal, int(status))
	}
	C.ca_flush_io()

	// Track the channel immediately so Write can find the chid.
	e.mu.Lock()
	e.channels[signal] = &caChannel{chid: chid, handle: handle}
	e.mu.Unlock()

	// Use a done channel to signal the goroutine to stop.
	done := make(chan struct{})
	var closeOnce sync.Once
	stopFn := func() { closeOnce.Do(func() { close(done) }) }

	// Background goroutine: wait for connection, then install the monitor.
	go func() {
		// Attach CA context to this OS thread before making any CA calls.
		e.attachCAContext()

		var evid C.evid
		connected := false

		select {
		case <-entry.connCh:
			connected = true
		case <-time.After(60 * time.Second):
			// IOC unreachable; report bad quality so the widget shows an error state.
			select {
			case ch <- datasource.Value{Timestamp: time.Now(), Data: float64(0), Quality: datasource.QualityBad}:
			default:
			}
		case <-ctx.Done():
		case <-done:
		}

		if connected {
			// Refresh metadata now that the channel is connected so type, unit,
			// and display limits are accurate (the earlier sendMeta call may have
			// run before the channel was up, leaving an incomplete cache entry).
			meta := e.fetchMetadata(chid, signal)
			e.mu.Lock()
			e.metadata[signal] = meta
			e.mu.Unlock()

			// Signal to the broker/dispatcher that metadata has been refreshed.
			select {
			case ch <- datasource.Value{MetaUpdate: true}:
			default:
			}

			dbrType := nativeTimeType(chid)
			st := C.caAddMonitor(chid, C.short(dbrType), C.uintptr_t(handle), &evid)
			if st != C.ECA_NORMAL {
				select {
				case ch <- datasource.Value{Timestamp: time.Now(), Data: float64(0), Quality: datasource.QualityBad}:
				default:
				}
				connected = false
			} else {
				C.ca_flush_io()
				e.mu.Lock()
				e.channels[signal] = &caChannel{chid: chid, evid: evid, handle: handle}
				e.mu.Unlock()
			}
		}

		// Wait until the subscription is cancelled.
		select {
		case <-done:
		case <-ctx.Done():
		}

		// Cleanup.
		if connected && evid != nil {
			C.ca_clear_event(evid)
		}
		C.ca_clear_channel(chid)
		C.ca_flush_io()

		handleMu.Lock()
		delete(handleTable, handle)
		delete(connTable, handle)
		handleMu.Unlock()

		e.mu.Lock()
		delete(e.channels, signal)
		e.mu.Unlock()
	}()

	return datasource.CancelFunc(stopFn), nil
}

// GetMetadata performs a synchronous ca_get for DBR_CTRL_DOUBLE (or the
// appropriate control type) to retrieve units, display limits, enum strings,
// and writability information for the named signal.
func (e *EPICS) GetMetadata(ctx context.Context, signal string) (datasource.Metadata, error) {
	// Attach the CA context to this OS thread before any CA calls.
	e.attachCAContext()

	// Check cache first.
	e.mu.Lock()
	if m, ok := e.metadata[signal]; ok {
		e.mu.Unlock()
		return m, nil
	}
	e.mu.Unlock()

	// We need a connected channel to issue a ca_get.  Reuse an existing one or
	// create a temporary channel.
	e.mu.Lock()
	existingCh, exists := e.channels[signal]
	e.mu.Unlock()

	var chid C.chid
	tempChannel := false

	if exists {
		chid = existingCh.chid
	} else {
		// Create a temporary channel for metadata retrieval.
		handle := nextHandle()
		entry := &connEntry{connCh: make(chan struct{}, 1)}
		handleMu.Lock()
		connTable[handle] = entry
		handleMu.Unlock()

		pvName := C.CString(signal)
		defer C.free(unsafe.Pointer(pvName))

		status := C.caCreateChannel(pvName, C.uintptr_t(handle), &chid)
		if status != C.ECA_NORMAL {
			handleMu.Lock()
			delete(connTable, handle)
			handleMu.Unlock()
			return datasource.Metadata{}, fmt.Errorf("epics: ca_create_channel(%q) failed: status %d", signal, int(status))
		}
		C.ca_flush_io()

		select {
		case <-entry.connCh:
		case <-time.After(5 * time.Second):
			C.ca_clear_channel(chid)
			handleMu.Lock()
			delete(connTable, handle)
			handleMu.Unlock()
			return datasource.Metadata{}, fmt.Errorf("epics: timeout connecting to %q for metadata", signal)
		case <-ctx.Done():
			C.ca_clear_channel(chid)
			handleMu.Lock()
			delete(connTable, handle)
			handleMu.Unlock()
			return datasource.Metadata{}, ctx.Err()
		}

		handleMu.Lock()
		delete(connTable, handle)
		handleMu.Unlock()
		tempChannel = true
	}

	meta := e.fetchMetadata(chid, signal)

	if tempChannel {
		C.ca_clear_channel(chid)
		C.ca_flush_io()
	}

	e.mu.Lock()
	e.metadata[signal] = meta
	e.mu.Unlock()

	return meta, nil
}

// fetchMetadata retrieves metadata from a connected chid using ca_get.
func (e *EPICS) fetchMetadata(chid C.chid, name string) datasource.Metadata {
	// ca_write_access returns 1 if CA security permits puts from this client.
	// This reflects the IOC's actual security policy (host/user ACLs), so we
	// use it directly rather than defaulting to true and relying on put failures.
	meta := datasource.Metadata{Name: name, Writable: C.ca_write_access(chid) != 0}

	fieldType := C.ca_field_type(chid)
	count := C.ca_element_count(chid)

	// Determine the Go DataType and the DBR_CTRL type to request.
	var ctrlType C.chtype
	switch int(fieldType) {
	case int(C.DBF_DOUBLE), int(C.DBF_FLOAT):
		if count > 1 {
			meta.Type = datasource.TypeFloat64Array
		} else {
			meta.Type = datasource.TypeFloat64
		}
		ctrlType = C.DBR_CTRL_DOUBLE

	case int(C.DBF_LONG), int(C.DBF_SHORT), int(C.DBF_CHAR):
		meta.Type = datasource.TypeInt64
		ctrlType = C.DBR_CTRL_LONG

	case int(C.DBF_ENUM):
		meta.Type = datasource.TypeEnum
		ctrlType = C.DBR_CTRL_ENUM

	case int(C.DBF_STRING):
		meta.Type = datasource.TypeString
		ctrlType = C.DBR_CTRL_STRING

	default:
		meta.Type = datasource.TypeFloat64
		ctrlType = C.DBR_CTRL_DOUBLE
	}

	// Perform a synchronous ca_get with a 3-second timeout.
	switch ctrlType {
	case C.DBR_CTRL_DOUBLE:
		var buf C.struct_dbr_ctrl_double
		status := C.caGet(C.DBR_CTRL_DOUBLE, chid, unsafe.Pointer(&buf))
		if status == C.ECA_NORMAL {
			C.ca_pend_io(3.0)
			meta.Unit = C.GoString((*C.char)(unsafe.Pointer(&buf.units[0])))
			meta.DisplayLow = float64(buf.lower_disp_limit)
			meta.DisplayHigh = float64(buf.upper_disp_limit)
			meta.DriveLow = float64(buf.lower_ctrl_limit)
			meta.DriveHigh = float64(buf.upper_ctrl_limit)
		}

	case C.DBR_CTRL_LONG:
		var buf C.struct_dbr_ctrl_long
		status := C.caGet(C.DBR_CTRL_LONG, chid, unsafe.Pointer(&buf))
		if status == C.ECA_NORMAL {
			C.ca_pend_io(3.0)
			meta.Unit = C.GoString((*C.char)(unsafe.Pointer(&buf.units[0])))
			meta.DisplayLow = float64(buf.lower_disp_limit)
			meta.DisplayHigh = float64(buf.upper_disp_limit)
			meta.DriveLow = float64(buf.lower_ctrl_limit)
			meta.DriveHigh = float64(buf.upper_ctrl_limit)
		}

	case C.DBR_CTRL_ENUM:
		var buf C.struct_dbr_ctrl_enum
		status := C.caGet(C.DBR_CTRL_ENUM, chid, unsafe.Pointer(&buf))
		if status == C.ECA_NORMAL {
			C.ca_pend_io(3.0)
			n := int(buf.no_str)
			strs := make([]string, n)
			for i := 0; i < n; i++ {
				strs[i] = C.GoString((*C.char)(unsafe.Pointer(&buf.strs[i][0])))
			}
			meta.EnumStrings = strs
		}
	}

	return meta
}

// ListSignals returns metadata for all currently tracked channels.
func (e *EPICS) ListSignals(_ context.Context) ([]datasource.Metadata, error) {
	e.mu.Lock()
	defer e.mu.Unlock()

	slog.Debug("epics: listing signals", "count", len(e.metadata))

	// Start with all fully-fetched metadata entries.
	out := make([]datasource.Metadata, 0, len(e.channels))
	seen := make(map[string]bool, len(e.metadata))
	for name, m := range e.metadata {
		out = append(out, m)
		seen[name] = true
	}
	// Add stub entries for channels that are tracked but not yet fetched.
	for name := range e.channels {
		if !seen[name] {
			out = append(out, datasource.Metadata{Name: name, Type: datasource.TypeFloat64})
		}
	}
	return out, nil
}

// Write puts a new value onto a CA channel.
// If the signal is not currently subscribed (e.g. a button in oneshot mode),
// a temporary CA channel is created, used for the put, then torn down.
//
// NOTE: per-session end-user identity (datasource.WithUser) is NOT honoured in
// the CGo/libca build: libca uses a single process-wide CA context whose client
// name is fixed at startup. Writes therefore use the server identity here. Use
// the default pure-Go build for per-user write attribution.
func (e *EPICS) Write(ctx context.Context, signal string, value any) error {
	e.attachCAContext()

	// Resolve the chid: use an existing subscribed channel if available,
	// otherwise create a temporary one (mirrors GetMetadata's approach).
	e.mu.Lock()
	caCh, ok := e.channels[signal]
	e.mu.Unlock()

	var chid C.chid
	tempChannel := false

	if ok {
		chid = caCh.chid
	} else {
		// Create a temporary channel for the put.
		handle := nextHandle()
		entry := &connEntry{connCh: make(chan struct{}, 1)}
		handleMu.Lock()
		connTable[handle] = entry
		handleMu.Unlock()

		pvName := C.CString(signal)
		defer C.free(unsafe.Pointer(pvName))

		status := C.caCreateChannel(pvName, C.uintptr_t(handle), &chid)
		if status != C.ECA_NORMAL {
			handleMu.Lock()
			delete(connTable, handle)
			handleMu.Unlock()
			return fmt.Errorf("epics: ca_create_channel(%q) failed: status %d", signal, int(status))
		}
		C.ca_flush_io()

		select {
		case <-entry.connCh:
		case <-time.After(5 * time.Second):
			C.ca_clear_channel(chid)
			handleMu.Lock()
			delete(connTable, handle)
			handleMu.Unlock()
			return fmt.Errorf("epics: timeout connecting to %q for write", signal)
		case <-ctx.Done():
			C.ca_clear_channel(chid)
			handleMu.Lock()
			delete(connTable, handle)
			handleMu.Unlock()
			return ctx.Err()
		}

		handleMu.Lock()
		delete(connTable, handle)
		handleMu.Unlock()
		tempChannel = true
	}

	var status C.int
	switch v := value.(type) {
	case float64:
		cv := C.double(v)
		status = C.caPut(C.DBR_DOUBLE, chid, unsafe.Pointer(&cv))
	case int64:
		cv := C.long(v)
		status = C.caPut(C.DBR_LONG, chid, unsafe.Pointer(&cv))
	case string:
		cs := C.CString(v)
		defer C.free(unsafe.Pointer(cs))
		status = C.caPut(C.DBR_STRING, chid, unsafe.Pointer(cs))
	case bool:
		var iv C.short
		if v {
			iv = 1
		}
		status = C.caPut(C.DBR_SHORT, chid, unsafe.Pointer(&iv))
	default:
		if tempChannel {
			C.ca_clear_channel(chid)
			C.ca_flush_io()
		}
		return fmt.Errorf("epics: unsupported value type %T for Write", value)
	}

	if status != C.ECA_NORMAL {
		if tempChannel {
			C.ca_clear_channel(chid)
			C.ca_flush_io()
		}
		return fmt.Errorf("epics: ca_put(%q) failed: status %d", signal, int(status))
	}

	if tempChannel {
		C.ca_pend_io(3.0) // flush + wait for put completion before tearing down
		C.ca_clear_channel(chid)
		C.ca_flush_io()
	} else {
		C.ca_flush_io()
	}
	return nil
}

// History delegates to the Archive Appliance client, or returns
// ErrHistoryUnavailable if no archive URL is configured.
func (e *EPICS) History(ctx context.Context, signal string, start, end time.Time, maxPoints int) ([]datasource.Value, error) {
	if e.archiveURL == "" {
		return nil, datasource.ErrHistoryUnavailable
	}
	return fetchArchiveHistory(ctx, e.archiveURL, signal, start, end, maxPoints)
}

// nativeTimeType returns the DBR_TIME_* type corresponding to the native field
// type of the channel.
func nativeTimeType(chid C.chid) C.chtype {
	ft := C.ca_field_type(chid)
	count := C.ca_element_count(chid)

	switch int(ft) {
	case int(C.DBF_DOUBLE):
		if count > 1 {
			return C.DBR_TIME_DOUBLE // waveform; caller assembles []float64 separately
		}
		return C.DBR_TIME_DOUBLE
	case int(C.DBF_FLOAT):
		return C.DBR_TIME_FLOAT
	case int(C.DBF_LONG):
		return C.DBR_TIME_LONG
	case int(C.DBF_SHORT), int(C.DBF_CHAR):
		return C.DBR_TIME_SHORT
	case int(C.DBF_ENUM):
		return C.DBR_TIME_ENUM
	case int(C.DBF_STRING):
		return C.DBR_TIME_STRING
	default:
		return C.DBR_TIME_DOUBLE
	}
}

func (e *EPICS) syncFromArchiver(ctx context.Context) {
	time.Sleep(1 * time.Second) // wait for network
	slog.Info("epics: syncing signals from Archiver", "url", e.archiveURL)
	pvs, err := searchArchivePVs(ctx, e.archiveURL, "*")
	if err != nil {
		slog.Error("epics: Archiver sync failed", "err", err)
		return
	}

	for _, name := range pvs {
		if ctx.Err() != nil {
			return
		}
		e.mu.Lock()
		if _, exists := e.metadata[name]; !exists {
			e.metadata[name] = datasource.Metadata{Name: name}
		}
		e.mu.Unlock()
		// Background pre-fetch of record info
		go e.prefetchPV(ctx, name)
	}
	slog.Info("epics: synced signals from Archiver", "count", len(pvs))
}
