package integration

import (
	"context"
	"encoding/json"
	"fmt"
	"hash/fnv"
	"strings"

	"github.com/google/uuid"

	"github.com/warmbly/warmbly/internal/infrastructure/pubsub"
	"github.com/warmbly/warmbly/internal/models"
	"github.com/warmbly/warmbly/internal/repository"
)

// executeAutomationGraph walks one automation's flow graph for a fired event:
// starting at the trigger node it follows edges, evaluating condition (IF) nodes
// against the event data to pick the true/false branch, and running every
// reachable action node. Best-effort and bounded (visited set + hop cap guard
// against cycles). Each action reuses runAction -> execAction unchanged.
func (s *service) executeAutomationGraph(ctx context.Context, a models.Automation, eventType string, data map[string]any) {
	// A stable per-event seed so random/chance splits + re-deliveries are
	// deterministic for a given event.
	baseSeed := stringFromMap(data, "delivery_id", "id", "booking_id", "contact_email", "invitee_email", "email")

	byID := make(map[string]models.AutomationNode, len(a.Graph.Nodes))
	for _, n := range a.Graph.Nodes {
		byID[n.ID] = n
	}
	outEdges := map[string][]models.AutomationEdge{}
	for _, e := range a.Graph.Edges {
		outEdges[e.Source] = append(outEdges[e.Source], e)
	}

	start := models.AutomationTriggerNodeID
	if _, ok := byID[start]; !ok {
		for _, n := range a.Graph.Nodes {
			if n.Type == models.AutomationNodeTrigger {
				start = n.ID
				break
			}
		}
	}

	// Best-effort run record — observability, never blocks the walk.
	run := &models.AutomationRun{AutomationID: a.ID, OrganizationID: a.OrganizationID, TriggerEvent: eventType, Status: "running"}
	_ = s.repo.CreateAutomationRun(ctx, run)
	// Carry the run id through the event data (internal, underscore-prefixed so
	// it's stripped from outbound webhooks) so an AI node can scope its credit
	// idempotency key to this run.
	data[automationRunIDKey] = run.ID.String()
	results := []models.AutomationNodeResult{}
	anyError := false

	visited := map[string]bool{}
	queue := []string{start}
	const maxHops = 128
	for hops := 0; len(queue) > 0 && hops < maxHops; hops++ {
		id := queue[0]
		queue = queue[1:]
		if visited[id] {
			continue
		}
		visited[id] = true
		n, ok := byID[id]
		if !ok {
			continue
		}
		switch n.Type {
		case models.AutomationNodeCondition:
			res := false
			var condErr error
			if n.Condition != nil {
				if n.Condition.Field == models.AutoCondAI {
					// Ask-AI branch: credit-charged model call; errors fail safe
					// down the false edge and are recorded on the run.
					res, condErr = s.evalAICondition(ctx, a, n, data, true)
				} else {
					res = evaluateAutomationCondition(*n.Condition, data, baseSeed+"|"+id)
				}
			}
			want := "false"
			status := "branch_false"
			if res {
				want = "true"
				status = "branch_true"
			}
			nr := models.AutomationNodeResult{NodeID: id, Type: "condition", Label: conditionSummary(n.Condition), Status: status}
			if condErr != nil {
				nr.Error = truncate(condErr.Error(), 300)
			}
			results = append(results, nr)
			// A condition only follows the branch matching its outcome. Edges
			// out of a condition are always labeled true/false (enforced on
			// write); an unlabeled edge here is malformed and ignored.
			for _, e := range outEdges[id] {
				if e.When == want {
					queue = append(queue, e.Target)
				}
			}
		case models.AutomationNodeAction:
			label, aerr := s.runGraphAction(ctx, a, n, eventType, data)
			nr := models.AutomationNodeResult{NodeID: id, Type: "action", Action: string(n.Action), Label: label, Status: "success"}
			// Capture what the action actually did (rendered fields + any output it
			// wrote) so run history shows the result, not just success/failure.
			nr.Preview = actionRunOutput(n, data)
			if aerr != nil {
				nr.Status = "error"
				nr.Error = truncate(aerr.Error(), 300)
				// try/catch: if the node has an "on error" branch, route the failure
				// there and treat it as handled (the run is not marked failed, the
				// normal path is skipped). With no error branch, fall back to the old
				// best-effort behavior: mark the run errored and continue downstream.
				handled := false
				for _, e := range outEdges[id] {
					if e.When == "error" {
						queue = append(queue, e.Target)
						handled = true
					}
				}
				if !handled {
					anyError = true
					for _, e := range actionSuccessEdges(n, data, outEdges[id]) {
						queue = append(queue, e.Target)
					}
				}
			} else {
				for _, e := range actionSuccessEdges(n, data, outEdges[id]) {
					queue = append(queue, e.Target)
				}
			}
			results = append(results, nr)
		default: // trigger / unknown: just follow outgoing edges
			for _, e := range outEdges[id] {
				queue = append(queue, e.Target)
			}
		}
	}

	status := "success"
	if anyError {
		status = "error"
	}
	_ = s.repo.FinishAutomationRun(ctx, run.ID, status, "", results)

	// Best-effort realtime: refresh the builder's run history live.
	if s.publisher != nil {
		s.publisher.PublishAutomationEvent(ctx, a.OrganizationID, uuid.Nil, pubsub.EventAutomationRun, a.ID.String(), a.Name)
	}
}

// conditionSummary is a short backend label for a condition node in run history.
func conditionSummary(c *models.AutomationCondition) string {
	if c == nil {
		return "condition"
	}
	if c.Field == models.AutoCondExpression {
		return "expression"
	}
	if c.Field == models.AutoCondAI {
		return "ask AI"
	}
	field := c.Field
	if c.Field == models.AutoCondField && c.Key != "" {
		field = c.Key
	}
	if c.Operator != "" {
		return field + " " + c.Operator
	}
	return field
}

// automationDepthKey carries the re-entrancy depth through an automation's event
// data; maxAutomationChainDepth bounds it. Today the only launcher is a campaign
// "Run automation" step (depth 0 -> 1), so the guard never trips in practice. It
// is pre-emptive: if a future action ever launches a campaign or another
// automation, that launcher MUST forward this key so a misconfigured loop
// (campaign -> automation -> campaign -> ...) is bounded instead of infinite.
const (
	automationDepthKey      = "_automation_depth"
	maxAutomationChainDepth = 5
)

// RunAutomationByID runs one automation's graph on demand (launched from a
// campaign step), ignoring the trigger-matching gate. Condition nodes still
// evaluate against `data`. Returns a descriptive error when the automation is
// missing or disabled so the caller can record it — a disabled automation is a
// logged skip, never a silent no-op (a toggled-off automation must not make
// campaign steps quietly stop doing anything).
func (s *service) RunAutomationByID(ctx context.Context, orgID, automationID uuid.UUID, data map[string]any) error {
	depth := int(toFloat(data[automationDepthKey]))
	if depth >= maxAutomationChainDepth {
		return fmt.Errorf("automation chain depth limit (%d) reached; refusing to launch %s", maxAutomationChainDepth, automationID)
	}
	a, err := s.repo.GetAutomation(ctx, orgID, automationID)
	if err != nil {
		return err
	}
	if a == nil {
		return fmt.Errorf("automation not found")
	}
	if !a.Enabled {
		return fmt.Errorf("automation %q is disabled", a.Name)
	}
	eventType := a.TriggerEvent
	if eventType == "" {
		eventType = string(models.WebhookEventCampaignAction)
	}
	// Run on a COPY of the caller's data: a graph with several "run automation"
	// actions shares one data map, so bumping depth in place would let sibling #2
	// inherit the depth that sibling #1's nested chain reached and false-trip the
	// cap. The copy gives each launch the same starting depth, +1.
	d := make(map[string]any, len(data)+2)
	for k, v := range data {
		d[k] = v
	}
	d[automationDepthKey] = float64(depth + 1)
	d["trigger"] = "campaign" // provenance: launched on demand, not event-matched
	s.executeAutomationGraph(ctx, *a, eventType, d)
	return nil
}

// ListAutomationRuns returns recent run history for an automation.
func (s *service) ListAutomationRuns(ctx context.Context, orgID, id uuid.UUID, limit int) ([]models.AutomationRun, error) {
	return s.repo.ListAutomationRuns(ctx, orgID, id, limit)
}

// DryRunAutomation walks the graph against sample (or provided) data WITHOUT any
// side effects — no action execution, no sync runs, no run row, no realtime — and
// returns the path taken plus a preview of what each action would send.
func (s *service) DryRunAutomation(ctx context.Context, orgID, id uuid.UUID, req models.DryRunRequest) (*models.DryRunResponse, error) {
	a, err := s.repo.GetAutomation(ctx, orgID, id)
	if err != nil {
		return nil, err
	}
	if a == nil {
		return nil, fmt.Errorf("automation not found")
	}
	data := req.Data
	if len(data) == 0 {
		data = sampleEventData(a.TriggerEvent)
	}
	skip := make(map[string]bool, len(req.SkipNodeIDs))
	for _, id := range req.SkipNodeIDs {
		skip[id] = true
	}
	// A dry-run executes AI nodes for real (and is charged), so scope their
	// credit idempotency keys to a fresh id. "dryrun:" namespaces it so a test
	// can never collide with a live run's charge.
	data[automationRunIDKey] = "dryrun:" + uuid.NewString()
	return &models.DryRunResponse{Trace: s.dryRunGraph(ctx, *a, data, skip), Data: data}, nil
}

// dryRunGraph mirrors executeAutomationGraph's walk but records a preview trace
// instead of executing side-effecting actions (conditions are pure, so they
// evaluate for real). AI nodes are the exception: a dry-run runs them for real
// (and is charged) so the test shows the actual model output and downstream
// conditions branch on it, matching a live run. Action nodes in skip are
// recorded as "skipped" and not previewed, but the walk still follows their
// normal edge so downstream steps are reflected.
func (s *service) dryRunGraph(ctx context.Context, a models.Automation, data map[string]any, skip map[string]bool) []models.AutomationNodeResult {
	baseSeed := stringFromMap(data, "delivery_id", "id", "booking_id", "contact_email", "invitee_email", "email")
	byID := make(map[string]models.AutomationNode, len(a.Graph.Nodes))
	for _, n := range a.Graph.Nodes {
		byID[n.ID] = n
	}
	outEdges := map[string][]models.AutomationEdge{}
	for _, e := range a.Graph.Edges {
		outEdges[e.Source] = append(outEdges[e.Source], e)
	}
	start := models.AutomationTriggerNodeID
	if _, ok := byID[start]; !ok {
		for _, n := range a.Graph.Nodes {
			if n.Type == models.AutomationNodeTrigger {
				start = n.ID
				break
			}
		}
	}
	trace := []models.AutomationNodeResult{}
	visited := map[string]bool{}
	queue := []string{start}
	const maxHops = 128
	for hops := 0; len(queue) > 0 && hops < maxHops; hops++ {
		id := queue[0]
		queue = queue[1:]
		if visited[id] {
			continue
		}
		visited[id] = true
		n, ok := byID[id]
		if !ok {
			continue
		}
		switch n.Type {
		case models.AutomationNodeCondition:
			res := false
			var condErr error
			if n.Condition != nil {
				if n.Condition.Field == models.AutoCondAI {
					// Like AI action nodes, an Ask-AI branch runs for real in a
					// dry-run (and is charged) so the test branches like a live
					// run — but never feeds the auto-pause counter.
					res, condErr = s.evalAICondition(ctx, a, n, data, false)
				} else {
					res = evaluateAutomationCondition(*n.Condition, data, baseSeed+"|"+id)
				}
			}
			want := "false"
			status := "branch_false"
			if res {
				want = "true"
				status = "branch_true"
			}
			nr := models.AutomationNodeResult{NodeID: id, Type: "condition", Label: conditionSummary(n.Condition), Status: status}
			if condErr != nil {
				nr.Error = truncate(condErr.Error(), 300)
			}
			trace = append(trace, nr)
			for _, e := range outEdges[id] {
				if e.When == want {
					queue = append(queue, e.Target)
				}
			}
		case models.AutomationNodeAction:
			switch {
			case skip[id]:
				// Toggled off for this test: record as skipped, no preview.
				trace = append(trace, models.AutomationNodeResult{
					NodeID: id,
					Type:   "action",
					Action: string(n.Action),
					Label:  string(n.Action),
					Status: "skipped",
				})
			case models.IsAIAction(n.Action):
				// Run the AI node for real (charged) but never let a dry-run's
				// credit miss auto-pause the flow (feedPause=false).
				nr := models.AutomationNodeResult{NodeID: id, Type: "action", Action: string(n.Action), Label: aiActionLabel(n.Action), Status: "success"}
				if aerr := s.execAIAction(ctx, a, n, data, false); aerr != nil {
					nr.Status = "error"
					nr.Error = truncate(aerr.Error(), 300)
				}
				nr.Preview = actionRunOutput(n, data)
				trace = append(trace, nr)
			default:
				trace = append(trace, models.AutomationNodeResult{
					NodeID:  id,
					Type:    "action",
					Action:  string(n.Action),
					Label:   string(n.Action),
					Status:  "success",
					Preview: actionPreview(n, data),
				})
			}
			// A dry run never fails an action, so it follows the normal path and
			// not the "on error" branch (a skipped action still continues the
			// walk; a skipped AI switch has no verdict, so its label branches
			// stay unfollowed).
			for _, e := range actionSuccessEdges(n, data, outEdges[id]) {
				queue = append(queue, e.Target)
			}
		default:
			for _, e := range outEdges[id] {
				queue = append(queue, e.Target)
			}
		}
	}
	return trace
}

// actionPreview renders the templatable fields an action would send (no calls).
func actionPreview(n models.AutomationNode, data map[string]any) map[string]any {
	p := map[string]any{}
	for _, k := range []string{"channel", "url", "message_template"} {
		if v := configString(n.Config, k); v != "" {
			p[k] = renderTemplate(v, data)
		}
	}
	if models.IsNativeAction(n.Action) {
		cfg := parseNativeConfig(n.Config)
		if cfg.DealName != "" {
			p["deal_name"] = renderTemplate(cfg.DealName, data)
		}
		if cfg.TaskTitle != "" {
			p["task_title"] = renderTemplate(cfg.TaskTitle, data)
		}
		if n.Action == models.IntegrationActionFireEvent {
			p["event"] = renderTemplate(cfg.EventName, data)
			for _, f := range cfg.EventFields {
				if k := strings.TrimSpace(f.Key); k != "" {
					p[k] = renderTemplate(f.Value, data)
				}
			}
		}
	}
	return p
}

// actionRunOutput captures, after an action node ran, a small summary of what it
// did for run history: the rendered templatable fields (channel/url/message/deal/
// task), plus node-specific output such as the values a set-variables node wrote.
// Returns nil when there is nothing to show. Kept scalar + bounded so it stays
// cheap to store in the run's node_results jsonb.
func actionRunOutput(n models.AutomationNode, data map[string]any) map[string]any {
	out := actionPreview(n, data)
	switch {
	case n.Action == models.IntegrationActionSetVariables:
		cfg := parseNativeConfig(n.Config)
		for _, v := range cfg.SetVars {
			if k := strings.TrimSpace(v.Key); k != "" {
				out[k] = truncate(valueString(data[k]), 200)
			}
		}
	case models.IsAIAction(n.Action):
		cfg := parseAIConfig(n.Config)
		for _, k := range aiOutputKeys(n.Action, cfg) {
			out[k] = truncate(valueString(data[k]), 200)
		}
	}
	if len(out) == 0 {
		return nil
	}
	return out
}

// sampleEventData builds a placeholder event payload for a trigger so a dry-run
// has something to render/evaluate against when the caller supplies none.
func sampleEventData(triggerEvent string) map[string]any {
	base := map[string]any{
		"contact_email": "jane@example.com",
		"contact_id":    "00000000-0000-0000-0000-000000000001",
		"campaign_id":   "00000000-0000-0000-0000-000000000002",
		"campaign_name": "Q3 Outbound",
		"first_name":    "Jane",
		"last_name":     "Doe",
		"company":       "Example Inc",
	}
	switch triggerEvent {
	case "campaign.reply_received":
		base["intent"] = "positive"
		base["confidence"] = 0.92
		base["subject"] = "Re: quick question"
		base["snippet"] = "Sure, let's talk next week."
	case "meeting.booked", "meeting.rescheduled", "meeting.canceled":
		base["source"] = "calendly"
		base["invitee_email"] = "jane@example.com"
		base["invitee_name"] = "Jane Doe"
		base["event_name"] = "Intro call"
		base["scheduled_for"] = "2026-07-01T15:00:00Z"
		base["join_url"] = "https://example.com/join/abc"
	case "campaign.email_bounced", "deliverability.bounce", "deliverability.complaint":
		base["event_type"] = "bounce"
		base["provider"] = "ses"
		base["reason"] = "mailbox full"
	case "warmup.health_changed":
		base["email"] = "sender@example.com"
		base["new_state"] = "watch"
		base["previous_state"] = "healthy"
		base["reason"] = "spam placement rising"
	}
	return base
}

// runGraphAction executes one action node by building a synthetic dispatch
// target and reusing runAction (which applies filters, logs a sync run, and
// updates connection health) -> execAction. The automation-wide filter is
// merged into the node config so any intent/confidence gate still applies.
// runGraphAction executes one action node and returns a label + the action error
// (for run-history recording). Native (Warmbly-internal) actions run without a
// connection; everything else builds a synthetic dispatch target and reuses
// runAction -> execAction.
func (s *service) runGraphAction(ctx context.Context, a models.Automation, n models.AutomationNode, eventType string, data map[string]any) (string, error) {
	if models.IsAIAction(n.Action) {
		return aiActionLabel(n.Action), s.execAIAction(ctx, a, n, data, true)
	}
	if models.IsNativeAction(n.Action) {
		return string(n.Action), s.execNativeAction(ctx, a, n, data)
	}
	if n.ConnectionID == nil {
		return string(n.Action), nil
	}
	// Re-verify the connection belongs to this automation's org at run time
	// (defense in depth — write-time validation already checks this, but never
	// trust a stored connection id to fetch another org's secrets).
	owned, oerr := s.repo.GetConnectionByID(ctx, a.OrganizationID, *n.ConnectionID)
	if oerr != nil {
		return string(n.Action), oerr
	}
	if owned == nil {
		return string(n.Action), fmt.Errorf("connection not found")
	}
	sec, err := s.repo.GetConnectionSecrets(ctx, *n.ConnectionID)
	if err != nil {
		return string(n.Action), err
	}
	if sec == nil {
		return string(n.Action), fmt.Errorf("connection secrets unavailable")
	}
	sub := models.IntegrationEventSubscription{
		ID:             uuid.New(),
		ConnectionID:   *n.ConnectionID,
		OrganizationID: a.OrganizationID,
		EventType:      eventType,
		Action:         n.Action,
		Config:         mergeFilterIntoConfig(a.Filter, n.Config),
		Enabled:        true,
		UseCase:        "automation",
	}
	return string(n.Action), s.runAction(ctx, repository.DispatchTarget{Subscription: sub, Secrets: *sec}, data)
}

// evaluateAutomationCondition resolves an IF node against the event payload. The
// generic "field" type tests data[c.Key] with the operator; the rest are legacy
// semantic shortcuts (kept working for older saved automations).
func evaluateAutomationCondition(c models.AutomationCondition, data map[string]any, seed string) bool {
	switch c.Field {
	case models.AutoCondExpression:
		return EvalExpression(c.Expression, data)
	case models.AutoCondField:
		raw, present := data[c.Key]
		switch c.Operator {
		case models.AutoOpExists:
			return present && valueString(raw) != ""
		case models.AutoOpIsTrue:
			return toBool(raw)
		case models.AutoOpNotEquals:
			return !strings.EqualFold(valueString(raw), valueString(c.Value))
		case models.AutoOpContains:
			return strings.Contains(strings.ToLower(valueString(raw)), strings.ToLower(valueString(c.Value)))
		case models.AutoOpGte:
			return toFloat(raw) >= toFloat(c.Value)
		case models.AutoOpLte:
			return toFloat(raw) <= toFloat(c.Value)
		case models.AutoOpChance:
			return chanceHit(seed, toFloat(c.Value))
		default: // equals
			return strings.EqualFold(valueString(raw), valueString(c.Value))
		}
	case models.AutoCondIntent:
		return strings.EqualFold(stringFromMap(data, "intent"), valueString(c.Value))
	case models.AutoCondSource:
		return strings.EqualFold(stringFromMap(data, "source", "campaign", "provider"), valueString(c.Value))
	case models.AutoCondConfidence:
		return toFloat(data["confidence"]) >= toFloat(c.Value)
	case models.AutoCondHasContact:
		return contactEmail(data) != ""
	case models.AutoCondRandom:
		return chanceHit(seed, toFloat(c.Value))
	default:
		return false
	}
}

// toBool coerces an event-data value to a boolean (handles bool, "true"/"yes"/"1"
// strings, and non-zero numbers).
func toBool(raw any) bool {
	switch t := raw.(type) {
	case bool:
		return t
	case string:
		s := strings.ToLower(strings.TrimSpace(t))
		return s == "true" || s == "yes" || s == "1"
	case float64:
		return t != 0
	case int:
		return t != 0
	default:
		return raw != nil && valueString(raw) != ""
	}
}

// mergeFilterIntoConfig overlays the automation-wide filter (intents,
// min_confidence) onto an action node's config without clobbering node keys.
func mergeFilterIntoConfig(filter, config json.RawMessage) json.RawMessage {
	m := map[string]any{}
	if len(config) > 0 {
		_ = json.Unmarshal(config, &m)
	}
	if len(filter) > 0 {
		f := map[string]any{}
		if json.Unmarshal(filter, &f) == nil {
			for k, v := range f {
				if _, exists := m[k]; !exists {
					m[k] = v
				}
			}
		}
	}
	out, _ := json.Marshal(m)
	return out
}

// chanceHit returns true for ~pct% of distinct seeds, deterministically.
func chanceHit(seed string, pct float64) bool {
	if pct <= 0 {
		return false
	}
	if pct >= 100 {
		return true
	}
	h := fnv.New32a()
	_, _ = h.Write([]byte(seed))
	return float64(h.Sum32()%100) < pct
}

// valueString renders a condition value (from jsonb, so string/number/bool) as
// a comparable string.
func valueString(v any) string {
	switch t := v.(type) {
	case nil:
		return ""
	case string:
		return t
	default:
		return strings.TrimSpace(fmt.Sprint(t))
	}
}
