package http

import (
	"context"
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"errors"
	"io"
	stdhttp "net/http"
	"strings"

	"github.com/nomadcode/nomadcode-core/internal/storage"
	"github.com/nomadcode/nomadcode-core/internal/workflow"
	"github.com/nomadcode/nomadcode-core/internal/workitem"
	"github.com/nomadcode/nomadcode-core/internal/workitempipeline"
)

const maxPlaneWebhookBodyBytes = 1 << 20

// planeProvider is the provider-neutral id used for Plane-originated work items.
const planeProvider = workitem.ProviderID("plane")

type planeWebhookPayload struct {
	Event       string          `json:"event"`
	Action      string          `json:"action"`
	WebhookID   string          `json:"webhook_id"`
	WorkspaceID string          `json:"workspace_id"`
	Data        json.RawMessage `json:"data"`
	Activity    struct {
		Field string            `json:"field"`
		Actor planeWebhookActor `json:"actor"`
	} `json:"activity"`
}

type planeWebhookActor struct {
	ID string `json:"id"`
}

// planeWebhookData holds only the raw identity fields needed to locate the work
// item later. Current state, assignees, labels, and body from the webhook payload
// are intentionally ignored: the payload is a wakeup hint, and the source of
// truth is a Plane API re-fetch in a later stage.
type planeWebhookData struct {
	ID        string `json:"id"`
	Project   string `json:"project"`
	Workspace string `json:"workspace"`
}

// planeWebhookWorkItemEvent is the provider-neutral wakeup hint extracted from a
// Plane webhook. It preserves the raw Plane identity (workspace id, project id,
// work item id) plus actor/change/action hints for later self-guard and trigger
// decisions. It never carries Plane payload state (state/assignees/labels/body)
// as truth.
//
// WorkspaceID is the raw Plane workspace UUID from the payload. It is NOT a
// dispatch-ready tenant: the downstream Plane adapter (planeWorkItemRef) and
// project-sync target consume a workspace slug as workitem.Ref.Tenant, and the
// webhook payload has no slug. Building the workitem.Ref is therefore deferred
// to dispatch (02+01_trigger_dispatch), which resolves the workspace id to a
// slug and calls WorkItemRef.
type planeWebhookWorkItemEvent struct {
	WorkspaceID string
	ProjectID   string
	WorkItemID  string
	ActorID     string
	ChangeField string
	Action      string
}

// WorkItemRef builds a provider-neutral workitem.Ref once the caller supplies a
// dispatch-ready workspace slug. The webhook payload only carries a workspace
// UUID, so the slug must be resolved by the dispatch stage before a ref that the
// Plane adapter / project-sync can consume exists. Blank slug/project/id are
// rejected via workitem.NormalizeRef and the explicit check below.
func (e planeWebhookWorkItemEvent) WorkItemRef(workspaceSlug string) (workitem.Ref, error) {
	slug := strings.TrimSpace(workspaceSlug)
	project := strings.TrimSpace(e.ProjectID)
	id := strings.TrimSpace(e.WorkItemID)
	if slug == "" || project == "" || id == "" {
		return workitem.Ref{}, workitem.ErrInvalidRef
	}
	return workitem.NormalizeRef(workitem.Ref{
		Provider: planeProvider,
		Tenant:   slug,
		Project:  project,
		ID:       id,
	})
}

// planeWorkItemEvents are the Plane webhook event types that map to a work item.
// Other events (e.g. project, cycle, module) are ignored without side effects.
func isPlaneWorkItemEvent(event string) bool {
	switch strings.TrimSpace(strings.ToLower(event)) {
	case "issue", "work_item", "work-item", "workitem":
		return true
	default:
		return false
	}
}

// normalizePlaneWebhookPayload extracts a provider-neutral wakeup hint from a
// Plane webhook payload. The second return value reports whether the event is a
// relevant work item event; irrelevant events return ok=false with no error so
// the caller can ignore them without side effects. An error is returned only
// when a relevant event is missing the ref tuple required by dispatch.
func normalizePlaneWebhookPayload(payload planeWebhookPayload) (planeWebhookWorkItemEvent, bool, error) {
	if !isPlaneWorkItemEvent(payload.Event) {
		return planeWebhookWorkItemEvent{}, false, nil
	}

	var data planeWebhookData
	if len(payload.Data) > 0 && string(payload.Data) != "null" {
		if err := json.Unmarshal(payload.Data, &data); err != nil {
			return planeWebhookWorkItemEvent{}, true, err
		}
	}

	// A relevant work item event must carry the raw identity tuple that dispatch
	// needs to build a ref after resolving the workspace slug. Reject missing
	// workspace id / project / work item id here as a relevant-but-invalid event
	// (ok=true, err!=nil) so dispatch never sees an event with empty identity.
	workspaceID := firstNonEmptyTrimmed(data.Workspace, payload.WorkspaceID)
	projectID := strings.TrimSpace(data.Project)
	workItemID := strings.TrimSpace(data.ID)
	if workspaceID == "" || projectID == "" || workItemID == "" {
		return planeWebhookWorkItemEvent{}, true, workitem.ErrInvalidRef
	}

	return planeWebhookWorkItemEvent{
		WorkspaceID: workspaceID,
		ProjectID:   projectID,
		WorkItemID:  workItemID,
		ActorID:     strings.TrimSpace(payload.Activity.Actor.ID),
		ChangeField: strings.TrimSpace(payload.Activity.Field),
		Action:      strings.TrimSpace(payload.Action),
	}, true, nil
}

func firstNonEmptyTrimmed(vals ...string) string {
	for _, v := range vals {
		if trimmed := strings.TrimSpace(v); trimmed != "" {
			return trimmed
		}
	}
	return ""
}

func (h *Handler) SetPlaneWebhookSecret(secret string) {
	h.planeWebhookSecret = strings.TrimSpace(secret)
}

// SetPlaneWebhookDispatchConfig wires the non-secret trigger gate configuration
// used to dispatch normalized Plane webhook events into work item task creation.
// Values are trimmed; an incomplete config leaves dispatch disabled (see
// planeWebhookDispatch.ready) so the receiver still answers 202 without retries.
func (h *Handler) SetPlaneWebhookDispatchConfig(cfg PlaneWebhookDispatchConfig) {
	h.planeDispatch = planeWebhookDispatch{
		workspaceID:     strings.TrimSpace(cfg.WorkspaceID),
		workspaceSlug:   strings.TrimSpace(cfg.WorkspaceSlug),
		backlogStateID:  strings.TrimSpace(cfg.BacklogStateID),
		agentAssigneeID: strings.TrimSpace(cfg.AgentAssigneeID),
		selfActorID:     strings.TrimSpace(cfg.SelfActorID),
	}
}

// dispatchPlaneWebhookWorkItem turns a normalized Plane webhook event into a work
// item task creation through the registered "plane" provider. It returns
// dispatched=true with the created task only when the event was actually handed
// to the creator. When the receiver is reachable but dispatch is not configured,
// the event targets a different workspace, or the ref cannot be built, it returns
// dispatched=false with no error so the caller answers 202 ignored instead of
// triggering a provider retry storm. The strict trigger gate (Backlog state +
// AGENT assignee + self-actor guard) lives in workitempipeline, so a non-matching
// event surfaces as ErrCreationTriggerIgnored, which the caller maps to 202.
func (h *Handler) dispatchPlaneWebhookWorkItem(ctx context.Context, event planeWebhookWorkItemEvent) (storage.Task, bool, error) {
	creator, ok := h.workItemProviders[planeProvider]
	if !ok || !h.planeDispatch.ready() {
		return storage.Task{}, false, nil
	}
	if h.planeDispatch.workspaceID != "" && event.WorkspaceID != h.planeDispatch.workspaceID {
		return storage.Task{}, false, nil
	}
	if h.planeDispatch.selfActorID != "" && event.ActorID == h.planeDispatch.selfActorID {
		return storage.Task{}, false, workitempipeline.ErrCreationTriggerIgnored
	}

	ref, err := event.WorkItemRef(h.planeDispatch.workspaceSlug)
	if err != nil {
		return storage.Task{}, false, nil
	}

	task, err := creator.CreateTaskFromWorkItem(ctx, workitempipeline.CreateTaskInput{
		Ref: ref,
		Trigger: workitempipeline.CreationTrigger{
			RequiredStateID:    h.planeDispatch.backlogStateID,
			RequiredAssigneeID: h.planeDispatch.agentAssigneeID,
			Actor:              event.ActorID,
			SelfActor:          h.planeDispatch.selfActorID,
		},
	})
	if err != nil {
		return storage.Task{}, false, err
	}
	if h.webhookEnqueuer != nil && planeWebhookShouldEnqueue(task.Status) {
		queued, err := h.webhookEnqueuer.EnqueueTask(ctx, task.ID)
		if err != nil {
			if errors.Is(err, workflow.ErrTaskCannotBeEnqueued) {
				return task, true, nil
			}
			return storage.Task{}, false, err
		}
		task = queued
	}
	return task, true, nil
}

func planeWebhookShouldEnqueue(status string) bool {
	switch workflow.TaskStatus(strings.TrimSpace(status)) {
	case workflow.StatusPending, workflow.StatusFailed:
		return true
	default:
		return false
	}
}

func (h *Handler) ReceivePlaneWebhook(w stdhttp.ResponseWriter, r *stdhttp.Request) {
	secret := strings.TrimSpace(h.planeWebhookSecret)
	if secret == "" {
		writeError(w, stdhttp.StatusServiceUnavailable, "plane webhook secret is not configured")
		return
	}

	body, err := io.ReadAll(stdhttp.MaxBytesReader(w, r.Body, maxPlaneWebhookBodyBytes))
	if err != nil {
		if errors.As(err, new(*stdhttp.MaxBytesError)) {
			writeError(w, stdhttp.StatusRequestEntityTooLarge, "webhook body is too large")
			return
		}
		writeError(w, stdhttp.StatusBadRequest, "invalid webhook body")
		return
	}

	signature := strings.TrimSpace(r.Header.Get("X-Plane-Signature"))
	if signature == "" || !validPlaneWebhookSignature(secret, body, signature) {
		if h.logger != nil {
			h.logger.Warn("plane webhook signature rejected",
				"delivery", r.Header.Get("X-Plane-Delivery"),
				"event", r.Header.Get("X-Plane-Event"),
			)
		}
		writeError(w, stdhttp.StatusUnauthorized, "invalid plane webhook signature")
		return
	}

	var payload planeWebhookPayload
	if err := json.Unmarshal(body, &payload); err != nil {
		writeError(w, stdhttp.StatusBadRequest, "invalid JSON body")
		return
	}

	event, relevant, normErr := normalizePlaneWebhookPayload(payload)

	if h.logger != nil {
		attrs := []any{
			"delivery", r.Header.Get("X-Plane-Delivery"),
			"header_event", r.Header.Get("X-Plane-Event"),
			"event", payload.Event,
			"action", payload.Action,
			"webhook_id", payload.WebhookID,
			"workspace_id", payload.WorkspaceID,
		}
		switch {
		case !relevant:
			attrs = append(attrs, "normalized", false, "reason", "irrelevant_event")
		case normErr != nil:
			attrs = append(attrs, "normalized", false, "reason", "missing_work_item_ref")
		default:
			attrs = append(attrs,
				"normalized", true,
				"work_item_id", event.WorkItemID,
				"project_id", event.ProjectID,
				"workspace_id_normalized", event.WorkspaceID,
			)
			if event.ChangeField != "" {
				attrs = append(attrs, "change_field", event.ChangeField)
			}
			if event.ActorID != "" {
				attrs = append(attrs, "actor_id", event.ActorID)
			}
		}
		h.logger.Info("plane webhook received", attrs...)
	}

	// Irrelevant events (non work item) and relevant-but-malformed events (missing
	// ref) are wakeup hints with nothing to dispatch: ack 202 ignored without side
	// effects so Plane does not retry.
	if !relevant || normErr != nil {
		writeJSON(w, stdhttp.StatusAccepted, map[string]string{"status": "ignored"})
		return
	}

	task, dispatched, err := h.dispatchPlaneWebhookWorkItem(r.Context(), event)
	if err != nil {
		// writeServiceError maps ErrCreationTriggerIgnored to 202 ignored, keeping
		// trigger-gate rejections from becoming provider retries; other errors map
		// to their service status.
		h.writeServiceError(w, err)
		return
	}
	if !dispatched {
		writeJSON(w, stdhttp.StatusAccepted, map[string]string{"status": "ignored"})
		return
	}
	writeJSON(w, stdhttp.StatusAccepted, map[string]string{"status": "accepted", "task_id": task.ID})
}

func validPlaneWebhookSignature(secret string, body []byte, signature string) bool {
	expectedMAC := hmac.New(sha256.New, []byte(secret))
	_, _ = expectedMAC.Write(body)
	expected := expectedMAC.Sum(nil)

	actual, err := hex.DecodeString(signature)
	if err != nil {
		return false
	}
	return hmac.Equal(actual, expected)
}
