package failiorsdk

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"os"
	"strings"
	"sync"
	"time"
)

// Status is the compatibility status used by Inform helpers.
type Status string

const (
	// StatusUp marks a node stage as successful.
	StatusUp Status = "ok"
	// StatusError marks a node stage as failed.
	StatusError Status = "error"
)

var (
	// ErrGraphIDRequired indicates no graph ID was configured.
	ErrGraphIDRequired = errors.New("failiorsdk: graph_id required")
	// ErrNodeIDRequired indicates a node ID was missing.
	ErrNodeIDRequired = errors.New("failiorsdk: node_id required")
	// ErrBaseURLRequired indicates the ingress base URL is empty.
	ErrBaseURLRequired = errors.New("failiorsdk: base URL required")
	// ErrInvalidStatus indicates a status value other than ok/error was used.
	ErrInvalidStatus = errors.New("failiorsdk: status must be ok or error")
	// ErrEmptyNodeList indicates packet node_id_list is empty.
	ErrEmptyNodeList = errors.New("failiorsdk: node_id_list cannot be empty")
	// ErrEmptyNodeInList indicates at least one node ID in node_id_list is empty.
	ErrEmptyNodeInList = errors.New("failiorsdk: node_id_list contains an empty node_id")
	// ErrInvalidTimestamp indicates packet timestamp is zero.
	ErrInvalidTimestamp = errors.New("failiorsdk: timestamp is required")
	// ErrPacketMessageRequired indicates did_error packet is missing packet_msg.
	ErrPacketMessageRequired = errors.New("failiorsdk: packet_msg required when did_error is true")
)

// Graph is a configured SDK client bound to one graph ID.
type Graph struct {
	// graphID is used as packet.graph_id for queue ingress payloads.
	graphID string
	// baseURL is the queue ingress base URL without trailing slash.
	baseURL string
	// ingressKey is sent as X-Ingress-Key when configured.
	ingressKey string
	// client is the HTTP transport used for outbound requests.
	client *http.Client
}

// Tracker collects node IDs in sequence and flushes them on End.
type Tracker struct {
	// mu protects internal state for concurrent safety.
	mu sync.Mutex
	// graph is the target graph client used for sends.
	graph *Graph
	// ctx is used for outbound request cancellation/deadlines.
	ctx context.Context
	// nodes is the ordered list of visited nodes.
	nodes []string
	// ended prevents double flush.
	ended bool
	// sendErr stores the first outbound send error.
	sendErr error
}

// Packet is the queue-ingress payload accepted by /ingest.
type Packet struct {
	// DidError marks whether the last node in NodeIDList failed.
	DidError bool `json:"did_error"`
	// PacketMessage carries error context when DidError is true.
	PacketMessage string `json:"packet_msg,omitempty"`
	// GraphID identifies which graph this packet belongs to.
	GraphID string `json:"graph_id"`
	// NodeIDList is the sequential list of traversed nodes.
	NodeIDList []string `json:"node_id_list"`
	// Timestamp is when the packet was emitted by the client.
	Timestamp time.Time `json:"timestamp"`
}

// HTTPStatusError is returned when ingress responds with non-2xx status.
type HTTPStatusError struct {
	// StatusCode is the ingress HTTP status code.
	StatusCode int
	// Body is the ingress response body (trimmed by caller as needed).
	Body string
}

// PacketBatchResult summarizes SendPacketBatch outcomes.
type PacketBatchResult struct {
	// Accepted is count of packets ingested successfully.
	Accepted int
	// Failed is count of packets rejected or failed to send.
	Failed int
	// ErrorSamples stores up to 10 representative send errors.
	ErrorSamples []error
}

// Option mutates Graph client configuration during Load.
type Option func(*Graph)

// Error returns a human-readable error string.
func (e HTTPStatusError) Error() string {
	return fmt.Sprintf("failiorsdk: request rejected (%d): %s", e.StatusCode, strings.TrimSpace(e.Body))
}

// Load constructs a graph client with optional overrides.
func Load(graphID string, opts ...Option) *Graph {
	g := &Graph{
		graphID: strings.TrimSpace(graphID),
		baseURL: defaultIngressBaseURL(),
		client: &http.Client{
			Timeout: 8 * time.Second,
		},
	}
	for _, opt := range opts {
		if opt != nil {
			opt(g)
		}
	}
	return g
}

// Track is a convenience that creates a Graph and returns its Tracker.
func Track(ctx context.Context, graphID string, opts ...Option) *Tracker {
	return Load(graphID, opts...).Track(ctx)
}

// Track starts a new deferred tracker for a graph execution.
func (g *Graph) Track(ctx context.Context) *Tracker {
	if ctx == nil {
		ctx = context.Background()
	}
	return &Tracker{
		graph: g,
		ctx:   ctx,
		nodes: make([]string, 0, 8),
	}
}

// Node appends a node ID to the tracker sequence.
func (t *Tracker) Node(nodeID string) {
	if t == nil {
		return
	}
	nodeID = strings.TrimSpace(nodeID)
	if nodeID == "" {
		return
	}
	t.mu.Lock()
	defer t.mu.Unlock()
	if t.ended {
		return
	}
	t.nodes = append(t.nodes, nodeID)
}

// End flushes a single queue packet for all tracked nodes.
func (t *Tracker) End(errRef *error) {
	if t == nil {
		return
	}
	t.mu.Lock()
	if t.ended {
		t.mu.Unlock()
		return
	}
	t.ended = true
	nodes := append([]string(nil), t.nodes...)
	graph := t.graph
	ctx := t.ctx
	t.mu.Unlock()

	if graph == nil || len(nodes) == 0 {
		return
	}

	packet := Packet{
		GraphID:    graph.graphID,
		NodeIDList: nodes,
		Timestamp:  time.Now().UTC(),
	}
	if errRef != nil && *errRef != nil {
		packet.DidError = true
		packet.PacketMessage = strings.TrimSpace((*errRef).Error())
	}

	if err := graph.SendPacket(ctx, packet); err != nil {
		t.mu.Lock()
		if t.sendErr == nil {
			t.sendErr = err
		}
		t.mu.Unlock()
	}
}

// Err returns the first outbound send error captured by End.
func (t *Tracker) Err() error {
	if t == nil {
		return nil
	}
	t.mu.Lock()
	defer t.mu.Unlock()
	return t.sendErr
}

// WithBaseURL overrides the ingress base URL used by the client.
func WithBaseURL(baseURL string) Option {
	return func(g *Graph) {
		if g == nil {
			return
		}
		g.baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
	}
}

// WithIngressKey sets X-Ingress-Key for queue ingress requests.
func WithIngressKey(key string) Option {
	return func(g *Graph) {
		if g == nil {
			return
		}
		g.ingressKey = strings.TrimSpace(key)
	}
}

// WithHTTPClient overrides the HTTP client used for requests.
func WithHTTPClient(client *http.Client) Option {
	return func(g *Graph) {
		if g == nil || client == nil {
			return
		}
		g.client = client
	}
}

// WithTimeout updates the HTTP client timeout.
func WithTimeout(timeout time.Duration) Option {
	return func(g *Graph) {
		if g == nil {
			return
		}
		if g.client == nil {
			g.client = &http.Client{Timeout: timeout}
			return
		}
		g.client.Timeout = timeout
	}
}

// InformUp sends one "ok" update for a single node through queue ingress.
func (g *Graph) InformUp(nodeID string) error {
	return g.Inform(context.Background(), nodeID, StatusUp, "")
}

// InformError sends one "error" update for a single node through queue ingress.
func (g *Graph) InformError(nodeID string) error {
	return g.Inform(context.Background(), nodeID, StatusError, "")
}

// Inform sends one compatibility node update through queue ingress.
func (g *Graph) Inform(ctx context.Context, nodeID string, status Status, message string) error {
	if g == nil || strings.TrimSpace(g.graphID) == "" {
		return ErrGraphIDRequired
	}
	nodeID = strings.TrimSpace(nodeID)
	if nodeID == "" {
		return ErrNodeIDRequired
	}
	if status != StatusUp && status != StatusError {
		return ErrInvalidStatus
	}
	packet := Packet{
		DidError:      status == StatusError,
		PacketMessage: strings.TrimSpace(message),
		GraphID:       g.graphID,
		NodeIDList:    []string{nodeID},
		Timestamp:     time.Now().UTC(),
	}
	return g.SendPacket(ctx, packet)
}

// SendPacket validates and sends one queue ingress packet to /ingest.
func (g *Graph) SendPacket(ctx context.Context, packet Packet) error {
	if g == nil || strings.TrimSpace(g.graphID) == "" {
		return ErrGraphIDRequired
	}
	if strings.TrimSpace(packet.GraphID) == "" {
		packet.GraphID = g.graphID
	}
	if err := validatePacket(packet); err != nil {
		return err
	}
	statusCode, body, err := g.sendPacketRaw(ctx, packet)
	if err != nil {
		return err
	}
	if statusCode < http.StatusOK || statusCode >= http.StatusMultipleChoices {
		return HTTPStatusError{StatusCode: statusCode, Body: body}
	}
	return nil
}

// SendPacketBatch sends many packets and returns aggregated results.
func (g *Graph) SendPacketBatch(ctx context.Context, packets []Packet, workers int) PacketBatchResult {
	result := PacketBatchResult{
		ErrorSamples: make([]error, 0, 10),
	}
	if len(packets) == 0 {
		return result
	}
	if workers <= 0 {
		workers = 1
	}

	type job struct {
		packet Packet
	}
	jobs := make(chan job, len(packets))
	var wg sync.WaitGroup
	var mu sync.Mutex

	appendErr := func(err error) {
		if err == nil {
			return
		}
		mu.Lock()
		defer mu.Unlock()
		if len(result.ErrorSamples) < 10 {
			result.ErrorSamples = append(result.ErrorSamples, err)
		}
	}

	for i := 0; i < workers; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for item := range jobs {
				err := g.SendPacket(ctx, item.packet)
				mu.Lock()
				if err != nil {
					result.Failed++
					mu.Unlock()
					appendErr(err)
					continue
				}
				result.Accepted++
				mu.Unlock()
			}
		}()
	}

	for _, packet := range packets {
		jobs <- job{packet: packet}
	}
	close(jobs)
	wg.Wait()

	return result
}

// sendPacketRaw posts a packet to ingress and returns raw status/body.
func (g *Graph) sendPacketRaw(ctx context.Context, packet Packet) (int, string, error) {
	if g == nil || strings.TrimSpace(g.graphID) == "" {
		return 0, "", ErrGraphIDRequired
	}
	baseURL := strings.TrimRight(strings.TrimSpace(g.baseURL), "/")
	if baseURL == "" {
		return 0, "", ErrBaseURLRequired
	}
	if ctx == nil {
		ctx = context.Background()
	}
	client := g.client
	if client == nil {
		client = &http.Client{Timeout: 8 * time.Second}
	}

	body, err := json.Marshal(packet)
	if err != nil {
		return 0, "", err
	}
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/ingest", bytes.NewReader(body))
	if err != nil {
		return 0, "", err
	}
	req.Header.Set("Content-Type", "application/json")
	if g.ingressKey != "" {
		req.Header.Set("X-Ingress-Key", g.ingressKey)
	}

	resp, err := client.Do(req)
	if err != nil {
		return 0, "", err
	}
	defer resp.Body.Close()

	respBody, err := io.ReadAll(resp.Body)
	if err != nil {
		return resp.StatusCode, "", nil
	}
	return resp.StatusCode, string(respBody), nil
}

// validatePacket checks required packet fields before send.
func validatePacket(packet Packet) error {
	if strings.TrimSpace(packet.GraphID) == "" {
		return ErrGraphIDRequired
	}
	if packet.Timestamp.IsZero() {
		return ErrInvalidTimestamp
	}
	if len(packet.NodeIDList) == 0 {
		return ErrEmptyNodeList
	}
	for _, nodeID := range packet.NodeIDList {
		if strings.TrimSpace(nodeID) == "" {
			return ErrEmptyNodeInList
		}
	}
	if packet.DidError && strings.TrimSpace(packet.PacketMessage) == "" {
		return ErrPacketMessageRequired
	}
	return nil
}

// defaultIngressBaseURL resolves FAILIOR_GRAPH_INGRESS_BASE, then FAILIOR_API_BASE.
func defaultIngressBaseURL() string {
	if v := strings.TrimSpace(os.Getenv("FAILIOR_GRAPH_INGRESS_BASE")); v != "" {
		return strings.TrimRight(v, "/")
	}
	if v := strings.TrimSpace(os.Getenv("FAILIOR_API_BASE")); v != "" {
		return strings.TrimRight(v, "/")
	}
	return "https://api.failior.com"
}
