// Package scheduler — executor.go
//
// v0.27.2: Headless task execution via coreToolLoop.
// v0.28.0: Action task type (no LLM), trigger payload passthrough,
//          webhook payload shape fix (D1).
//
// The Executor bridges the task scheduler with the completion pipeline.
// It resolves providers, builds tool definitions, runs the core tool loop
// with budget enforcement, persists results, and sends notifications.
package scheduler

import (
	"context"
	"encoding/json"
	"log"
	"time"

	capspkg "chat-switchboard/capabilities"
	"chat-switchboard/crypto"
	"chat-switchboard/events"
	"chat-switchboard/handlers"
	"chat-switchboard/models"
	"chat-switchboard/notifications"
	"chat-switchboard/providers"
	"chat-switchboard/sandbox"
	"chat-switchboard/store"
	"chat-switchboard/taskutil"
	"chat-switchboard/tools"
	"chat-switchboard/webhook"
)

// Executor runs task completions headlessly (no HTTP client).
type Executor struct {
	stores store.Stores
	vault  *crypto.KeyResolver
	hub    *events.Hub
	health handlers.HealthRecorder
	runner *sandbox.Runner // v0.29.0: Starlark task execution
}

// NewExecutor creates a task executor. All fields are optional except stores.
func NewExecutor(stores store.Stores, vault *crypto.KeyResolver, hub *events.Hub, health handlers.HealthRecorder) *Executor {
	return &Executor{
		stores: stores,
		vault:  vault,
		hub:    hub,
		health: health,
	}
}

// SetRunner attaches the Starlark sandbox runner for starlark task execution.
func (e *Executor) SetRunner(r *sandbox.Runner) {
	e.runner = r
}

// Execute runs a single task to completion.
// Called from the scheduler's execute() goroutine.
func (e *Executor) Execute(ctx context.Context, task models.Task, run *models.TaskRun, channelID string) {
	startTime := time.Now()

	// v0.28.6: System tasks run a built-in Go function. No LLM, no provider, no channel.
	if task.TaskType == "system" {
		e.executeSystem(ctx, task, run, startTime)
		return
	}

	// v0.28.0: Action tasks skip the LLM pipeline entirely.
	if task.TaskType == "action" {
		e.executeAction(ctx, task, run, channelID, startTime)
		return
	}

	// v0.29.0: Starlark tasks run a sandboxed extension script.
	if task.TaskType == "starlark" {
		e.executeStarlark(ctx, task, run, channelID, startTime)
		return
	}

	// ── 1. Resolve provider ────────────────────
	providerConfigID := ""
	if task.ProviderConfigID != nil {
		providerConfigID = *task.ProviderConfigID
	}
	res, err := handlers.ResolveProviderConfig(e.stores, e.vault, task.OwnerID, channelID, providerConfigID, task.ModelID)
	if err != nil {
		e.failRun(ctx, task, run, "provider resolution failed: "+err.Error())
		return
	}

	// v0.27.4: Enforce personal_require_byok — personal tasks must use BYOK provider
	if task.Scope == "personal" && res.ProviderScope != "personal" {
		cfg := taskutil.LoadTaskConfig(ctx, e.stores.GlobalConfig)
		if cfg.PersonalRequireBYOK {
			e.failRun(ctx, task, run, "personal tasks require a BYOK provider — add an API key in Settings → Providers")
			return
		}
	}

	provider, err := providers.Get(res.ProviderID)
	if err != nil {
		e.failRun(ctx, task, run, "provider unavailable: "+err.Error())
		return
	}

	// ── 2. Build messages ──────────────────────
	messages := make([]providers.Message, 0, 3)

	// System prompt: task-level > persona > empty
	systemPrompt := task.SystemPrompt
	if systemPrompt == "" && task.PersonaID != nil && e.stores.Personas != nil {
		if persona, err := e.stores.Personas.GetByID(ctx, *task.PersonaID); err == nil {
			systemPrompt = persona.SystemPrompt
		}
	}
	if systemPrompt != "" {
		messages = append(messages, providers.Message{
			Role:    "system",
			Content: systemPrompt,
		})
	}

	// User prompt — with optional trigger payload prepended
	userContent := task.UserPrompt
	if run.TriggerPayload != "" && userContent != "" {
		userContent = "[Webhook trigger data]\n```json\n" + run.TriggerPayload + "\n```\n\n[Task instructions]\n" + userContent
	} else if run.TriggerPayload != "" {
		userContent = run.TriggerPayload
	}
	if userContent != "" {
		messages = append(messages, providers.Message{
			Role:    "user",
			Content: userContent,
		})
	}

	// ── 3. Build tool definitions ──────────────
	caps := capspkg.InferCapabilities(res.Model)
	caps.MaxOutputTokens = capspkg.ResolveMaxOutput(res.Model, caps)

	var toolDefs []providers.ToolDef
	if caps.ToolCalling {
		personaID := ""
		if task.PersonaID != nil {
			personaID = *task.PersonaID
		}
		tctx := tools.ToolContext{
			ChannelType: "service",
			PersonaID:   personaID,
		}
		// No browser tools for headless execution
		toolDefs = handlers.BuildToolDefs(ctx, e.stores, task.OwnerID, false, nil, tctx, personaID)

		// Apply task-level tool grants
		if len(task.ToolGrants) > 0 {
			var grants []string
			if json.Unmarshal(task.ToolGrants, &grants) == nil && len(grants) > 0 {
				toolDefs = handlers.FilterToolDefsByGrants(toolDefs, grants)
			}
		}
	}

	// ── 4. Build completion request ────────────
	req := providers.CompletionRequest{
		Model:    res.Model,
		Messages: messages,
		Tools:    toolDefs,
	}
	if task.MaxTokens > 0 {
		req.MaxTokens = task.MaxTokens
	} else if caps.MaxOutputTokens > 0 {
		req.MaxTokens = caps.MaxOutputTokens
	}

	// Apply provider-specific request hooks
	if hooks := providers.GetHooks(res.ProviderID); hooks != nil {
		hooks.PreRequest(res.Config, &req)
	}

	// ── 5. Execute via core tool loop ──────────
	personaID := ""
	if task.PersonaID != nil {
		personaID = *task.PersonaID
	}

	extTools := handlers.BuildExtToolMap(ctx, e.stores, task.OwnerID)
	sink := handlers.NewHeadlessSink(task.ID)
	result := handlers.CoreToolLoop(ctx, handlers.LoopConfig{
		Provider:     provider,
		Cfg:          res.Config,
		Req:          &req,
		Model:        res.Model,
		ProviderType: res.ProviderID,
		ExecCtx: tools.ExecutionContext{
			UserID:    task.OwnerID,
			ChannelID: channelID,
			PersonaID: personaID,
		},
		Hub:      e.hub,
		Health:   e.health,
		ConfigID: res.ConfigID,
		Budget: handlers.LoopBudget{
			MaxRounds:    0, // use default
			MaxToolCalls: task.MaxToolCalls,
			MaxTokens:    task.MaxTokens,
		},
		Streaming: false, // headless — use ChatCompletion
		Runner:    e.runner,
		ExtTools:  extTools,
	}, sink)

	// ── 6. Persist output based on output_mode ──
	wallClock := int(time.Since(startTime).Seconds())

	if result.Content != "" {
		switch task.OutputMode {
		case "note":
			// v0.27.4: Save output as a note
			if e.stores.Notes != nil {
				noteTitle := task.Name + " — " + time.Now().Format("2006-01-02 15:04")
				_ = e.stores.Notes.Create(ctx, &models.Note{
					UserID:          task.OwnerID,
					Title:           noteTitle,
					Content:         result.Content,
					SourceChannelID: &channelID,
					TeamID:          task.TeamID,
					Tags:            []string{"task-output"},
				})
			}
		case "webhook":
			// Webhook delivery handled in step 10 below
		default: // "channel"
			if e.stores.Messages != nil {
				_ = e.stores.Messages.Create(ctx, &models.Message{
					ChannelID: channelID,
					Role:      "assistant",
					Content:   result.Content,
					Model:     res.Model,
				})
			}
		}
	}

	// ── 7. Determine terminal status ───────────
	status := "completed"
	errMsg := ""

	if result.Error != nil {
		status = "failed"
		errMsg = result.Error.Error()
	} else if result.BudgetExceeded != "" {
		status = "budget_exceeded"
		errMsg = "budget exceeded: " + result.BudgetExceeded
	}

	tokensUsed := result.InputTokens + result.OutputTokens

	// ── 8. Update run record ───────────────────
	_ = e.stores.Tasks.UpdateRun(ctx, run.ID, status,
		tokensUsed, result.ToolCallCount, wallClock, errMsg)
	_ = e.stores.Tasks.IncrementRunCount(ctx, task.ID)

	log.Printf("[executor] Task %s (%s) → %s (tokens=%d, tools=%d, wall=%ds)",
		task.ID, task.Name, status, tokensUsed, result.ToolCallCount, wallClock)

	// ── 9. Owner notification ──────────────────
	e.notifyOwner(ctx, task, status, errMsg)

	// ── 10. Webhook delivery (v0.27.3) ─────────
	if task.WebhookURL != "" {
		go webhook.Deliver(task.WebhookURL, task.WebhookSecret, webhook.Payload{
			TaskID:      task.ID,
			RunID:       run.ID,
			TaskName:    task.Name,
			ChannelID:   channelID,
			Status:      status,
			CompletedAt: time.Now().UTC(),
			Output:      result.Content,
			TokensUsed:  tokensUsed,
			Error:       errMsg,
		})
	}
}

// executeStarlark runs a sandboxed Starlark extension script.
// The task's system_function field holds the package ID.
// The script's on_run(ctx) entry point is called with task context.
func (e *Executor) executeStarlark(ctx context.Context, task models.Task, run *models.TaskRun, channelID string, startTime time.Time) {
	if e.runner == nil {
		e.failRun(ctx, task, run, "starlark runner not configured")
		return
	}

	packageID := task.SystemFunction
	if packageID == "" {
		e.failRun(ctx, task, run, "starlark task missing package_id (system_function field)")
		return
	}

	// Load the package
	pkg, err := e.stores.Packages.Get(ctx, packageID)
	if err != nil || pkg == nil {
		e.failRun(ctx, task, run, "package not found: "+packageID)
		return
	}

	// Run the script and call on_run entry point
	// Build a context dict with task info
	rc := &sandbox.RunContext{UserID: task.OwnerID, ChannelID: channelID}
	val, output, err := e.runner.CallEntryPoint(ctx, pkg, "on_run", nil, nil, rc)

	wallClock := int(time.Since(startTime).Seconds())
	status := "completed"
	errMsg := ""
	result := ""

	if err != nil {
		status = "failed"
		errMsg = err.Error()
	} else if val != nil {
		result = val.String()
	}

	// Append print output to result
	if output != "" {
		if result != "" {
			result = result + "\n---\n" + output
		} else {
			result = output
		}
	}

	// Persist to channel if output_mode == "channel"
	if status == "completed" && task.OutputMode == "channel" && e.stores.Messages != nil && result != "" {
		_ = e.stores.Messages.Create(ctx, &models.Message{
			ChannelID: channelID,
			Role:      "system",
			Content:   "Starlark task output:\n```\n" + result + "\n```",
		})
	}

	_ = e.stores.Tasks.UpdateRun(ctx, run.ID, status, 0, 0, wallClock, errMsg)
	_ = e.stores.Tasks.IncrementRunCount(ctx, task.ID)

	log.Printf("[executor] Starlark task %s (%s → %s) → %s (wall=%ds)", task.ID, task.Name, packageID, status, wallClock)

	e.notifyOwner(ctx, task, status, errMsg)

	if task.WebhookURL != "" {
		go webhook.Deliver(task.WebhookURL, task.WebhookSecret, webhook.Payload{
			TaskID:      task.ID,
			RunID:       run.ID,
			TaskName:    task.Name,
			ChannelID:   channelID,
			Status:      status,
			CompletedAt: time.Now().UTC(),
			Output:      result,
			Error:       errMsg,
		})
	}
}

// executeSystem runs a built-in Go function from the system registry.
// No LLM, no provider resolution, no channel needed.
func (e *Executor) executeSystem(ctx context.Context, task models.Task, run *models.TaskRun, startTime time.Time) {
	fn, ok := taskutil.GetSystemFunc(task.SystemFunction)
	if !ok {
		e.failRun(ctx, task, run, "unknown system function: "+task.SystemFunction)
		return
	}

	result, err := fn(ctx, e.stores)

	wallClock := int(time.Since(startTime).Seconds())
	status := "completed"
	errMsg := ""

	if err != nil {
		status = "failed"
		errMsg = err.Error()
	}

	// Store result as the run's output (tokens=0, tools=0 for system tasks)
	_ = e.stores.Tasks.UpdateRun(ctx, run.ID, status, 0, 0, wallClock, errMsg)
	_ = e.stores.Tasks.IncrementRunCount(ctx, task.ID)

	log.Printf("[executor] System task %s (%s → %s) → %s (wall=%ds, result=%s)",
		task.ID, task.Name, task.SystemFunction, status, wallClock, truncate(result, 200))

	e.notifyOwner(ctx, task, status, errMsg)

	// Outbound webhook with result
	if task.WebhookURL != "" {
		go webhook.Deliver(task.WebhookURL, task.WebhookSecret, webhook.Payload{
			TaskID:      task.ID,
			RunID:       run.ID,
			TaskName:    task.Name,
			Status:      status,
			CompletedAt: time.Now().UTC(),
			Output:      result,
			Error:       errMsg,
		})
	}
}

func truncate(s string, n int) string {
	if len(s) <= n {
		return s
	}
	return s[:n] + "…"
}

// executeAction handles non-LLM action tasks.
// Skips provider resolution and completion entirely.
func (e *Executor) executeAction(ctx context.Context, task models.Task, run *models.TaskRun, channelID string, startTime time.Time) {
	wallClock := int(time.Since(startTime).Seconds())
	status := "completed"

	// v0.28.0: Action tasks relay trigger payload to outbound webhook.
	// No LLM execution — the value is in the automation plumbing.
	output := run.TriggerPayload
	if output == "" {
		output = "{}"
	}

	// Persist to channel if output_mode == "channel"
	if task.OutputMode == "channel" && e.stores.Messages != nil {
		_ = e.stores.Messages.Create(ctx, &models.Message{
			ChannelID: channelID,
			Role:      "system",
			Content:   "Action task executed. Trigger payload:\n```json\n" + output + "\n```",
		})
	}

	_ = e.stores.Tasks.UpdateRun(ctx, run.ID, status, 0, 0, wallClock, "")
	_ = e.stores.Tasks.IncrementRunCount(ctx, task.ID)

	log.Printf("[executor] Action task %s (%s) → %s (wall=%ds)", task.ID, task.Name, status, wallClock)

	e.notifyOwner(ctx, task, status, "")

	// Fire outbound webhook with trigger payload
	if task.WebhookURL != "" {
		go webhook.Deliver(task.WebhookURL, task.WebhookSecret, webhook.Payload{
			TaskID:      task.ID,
			RunID:       run.ID,
			TaskName:    task.Name,
			ChannelID:   channelID,
			Status:      status,
			CompletedAt: time.Now().UTC(),
			Output:      output,
		})
	}
}

// failRun marks a run as failed before completion was attempted.
func (e *Executor) failRun(ctx context.Context, task models.Task, run *models.TaskRun, errMsg string) {
	log.Printf("[executor] Task %s (%s) pre-execution failure: %s", task.ID, task.Name, errMsg)
	_ = e.stores.Tasks.UpdateRun(ctx, run.ID, "failed", 0, 0, 0, errMsg)
	e.notifyOwner(ctx, task, "failed", errMsg)
	if task.WebhookURL != "" {
		go webhook.Deliver(task.WebhookURL, task.WebhookSecret, webhook.Payload{
			TaskID:      task.ID,
			RunID:       run.ID,
			TaskName:    task.Name,
			Status:      "failed",
			CompletedAt: time.Now().UTC(),
			Error:       errMsg,
		})
	}
}

// notifyOwner sends a notification based on task outcome and preferences.
func (e *Executor) notifyOwner(ctx context.Context, task models.Task, status, errMsg string) {
	notifSvc := notifications.Default()
	if notifSvc == nil {
		return
	}

	switch status {
	case "completed":
		if !task.NotifyOnComplete {
			return
		}
		_ = notifSvc.Notify(ctx, &models.Notification{
			UserID:       task.OwnerID,
			Type:         "task.completed",
			Title:        "Task completed: " + task.Name,
			Body:         "Scheduled task finished successfully.",
			ResourceType: "channel",
			ResourceID:   stringVal(task.OutputChannelID),
		})

	case "failed":
		if !task.NotifyOnFailure {
			return
		}
		body := "Scheduled task failed."
		if errMsg != "" {
			body = errMsg
			if len(body) > 200 {
				body = body[:200] + "…"
			}
		}
		_ = notifSvc.Notify(ctx, &models.Notification{
			UserID:       task.OwnerID,
			Type:         "task.failed",
			Title:        "Task failed: " + task.Name,
			Body:         body,
			ResourceType: "channel",
			ResourceID:   stringVal(task.OutputChannelID),
		})

	case "budget_exceeded":
		// Always notify on budget breach regardless of preference
		_ = notifSvc.Notify(ctx, &models.Notification{
			UserID:       task.OwnerID,
			Type:         "task.budget_exceeded",
			Title:        "Task budget exceeded: " + task.Name,
			Body:         errMsg,
			ResourceType: "channel",
			ResourceID:   stringVal(task.OutputChannelID),
		})
	}
}

func stringVal(s *string) string {
	if s == nil {
		return ""
	}
	return *s
}
