package automation

import (
	"bytes"
	"context"
	"crypto/sha256"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"strings"

	"github.com/nexuscrm/kernel/pkg/ports"
	"github.com/nexuscrm/outbound/externaldelivery"
	apperrors "github.com/nexuscrm/kernel/pkg/errors"
	"github.com/nexuscrm/kernel/pkg/utils"
	"github.com/nexuscrm/shared/pkg/constants"
)

// SendRenderedEmail replays a frozen rendered EmailMessage through the email
// sender without re-rendering. Used by the admin resend path so that the
// operator-triggered retry sends exactly the payload that was originally sent.
//
// When emailTransportNotDelivering is set (noop sender, SMTP_HOST unset), the
// noop sender is still called (preserving its log line), but the method returns
// errEmailTransportNotDelivering so the admin handler records a failed resend
// row and returns 502 instead of false success.
func (as *ActionService) SendRenderedEmail(ctx context.Context, msg *ports.EmailMessage) (*ports.EmailSendResult, error) {
	result, err := as.emailSender.Send(ctx, msg)
	if err != nil {
		// Same sanitization as the canonical send funnel: provider transport
		// errors can embed the rendered recipients/subject/body, and the resend
		// write-site persists this error into the attempt row (whose
		// ErrorMessage the admin list/detail API exposes) — only the
		// recipient-count shape may escape.
		return nil, canonicalSendEmailProviderError(len(msg.To) + len(msg.Cc))
	}
	if as.emailTransportNotDelivering {
		return result, errEmailTransportNotDelivering
	}
	return result, nil
}

// SendWebhookRaw re-runs allowlist.EnsureAllowed (SSRF re-validation — C5)
// then calls the webhook client with the frozen payload. Used by the admin
// resend path.
func (as *ActionService) SendWebhookRaw(ctx context.Context, rawURL, method string, headers map[string]string, body []byte) (int, error) {
	if err := as.allowlist.EnsureAllowed(rawURL); err != nil {
		return 0, apperrors.NewValidationError("url", as.allowlist.RedactError(err).Error())
	}
	var bodyReader io.Reader
	if len(body) > 0 {
		bodyReader = bytes.NewReader(body)
	}
	req, err := http.NewRequestWithContext(ctx, method, rawURL, bodyReader)
	if err != nil {
		return 0, apperrors.NewInternalError("failed to create webhook request", err)
	}
	for k, v := range headers {
		req.Header.Set(k, v)
	}
	resp, err := as.webhookClient.Do(req)
	if err != nil {
		return 0, apperrors.NewInternalError("webhook request failed", as.allowlist.RedactError(err))
	}
	defer func() { _ = resp.Body.Close() }()
	_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4<<10))
	// A non-2xx response is a delivery failure, mirroring executeCallWebhook:
	// the resend must be recorded as failed (not a false StatusSuccess) and the
	// admin API must not report HTTP 200. The status code is still returned so
	// the caller can record the real response code on the failed attempt.
	if resp.StatusCode >= 400 {
		return resp.StatusCode, apperrors.NewInternalError(fmt.Sprintf("webhook returned error status: %d", resp.StatusCode), nil)
	}
	return resp.StatusCode, nil
}

// buildWebhookHeaders builds the outbound header map from an action's config:
// Content-Type: application/json plus admin-configured constants.ConfigHeaders
// (static values, no template evaluation). Single construction path shared by
// the live send and operator-resend header re-resolution.
//
// Header names are canonicalized via http.CanonicalHeaderKey so an
// admin-configured "content-type" (any casing) deterministically overrides the
// default — matching the pre-refactor req.Header.Set semantics where Set
// canonicalized names and last-write won.
func buildWebhookHeaders(config map[string]interface{}) map[string]string {
	headers := map[string]string{"Content-Type": "application/json"}
	if configHeaders, ok := utils.GetConfigMap(config, constants.ConfigHeaders); ok {
		for headerName, headerValue := range configHeaders {
			headers[http.CanonicalHeaderKey(headerName)] = fmt.Sprintf("%v", headerValue)
		}
	}
	return headers
}

// stepConfigFingerprint returns the identity fingerprint (sha256 hex) of a
// composite step's FULL config for resend addressing: a step-addressed resend
// replays only against a step whose current config is byte-identical
// (canonical JSON — encoding/json marshals map keys in sorted order) to the
// config frozen at send time. ANY edit — including credential rotation in the
// url or header values — therefore refuses the resend (409, nothing flows).
// Steps have no durable identity (they are admin-authored config JSON whose
// local ids can be deleted and reused), so equality is the only binding that
// cannot misroute a frozen payload to a materially different endpoint;
// top-level actions keep re-resolving from current config because the action
// ROW id is a durable identity.
//
// Hashing the raw config is not a secrets-at-rest regression: the preimage
// already sits in PLAINTEXT in _System_Action.Config in the same database, so
// a one-way digest of it adds zero marginal exposure.
func (as *ActionService) stepConfigFingerprint(stepConfig map[string]interface{}) string {
	b, err := json.Marshal(stepConfig)
	if err != nil {
		return ""
	}
	sum := sha256.Sum256(b)
	return fmt.Sprintf("%x", sum)
}

// resendActionLookup returns the actionByIDer to use for operator-resend
// authority checks and webhook-target resolution. Single construction path
// shared by ResolveWebhookTarget and EnsureResendActionAuthority so the two
// methods cannot drift from each other.
func (as *ActionService) resendActionLookup() (actionByIDer, error) {
	switch {
	case as.actionLookup != nil:
		return as.actionLookup, nil
	case as.metadata != nil:
		return as.metadata, nil
	default:
		return nil, apperrors.NewInternalError("action lookup is not configured; cannot perform resend authority check", nil)
	}
}

// ensureEmailTemplateActive checks that the email template referenced by config
// still exists and is active. Used by both the top-level SendEmail branch and
// the composite step branch of EnsureResendActionAuthority.
//
// An EMPTY templateID fails closed: the live send path requires emailTemplate
// (getConfigValue errors when it is missing), so clearing the template off an
// action/step is how an admin disables its email — a cleared template must not
// keep authorizing replays of old frozen content. Nil persistence also fails
// closed: authority that cannot be verified is not granted.
func (as *ActionService) ensureEmailTemplateActive(ctx context.Context, config map[string]interface{}) error {
	templateID := strings.TrimSpace(utils.GetConfigString(config, constants.ConfigEmailTemplate))
	if templateID == "" {
		return apperrors.NewValidationError("emailTemplate", "the originating action no longer references an email template; resend refused")
	}
	if as.persistence == nil {
		return apperrors.NewInternalError("persistence is not configured; cannot verify email template for resend authority", nil)
	}
	tpl, terr := as.persistence.GetRaw(ctx, constants.TableEmailTemplate, templateID)
	if terr != nil {
		return apperrors.NewInternalError("failed to resolve email template for resend authority", terr)
	}
	if tpl == nil {
		return apperrors.NewNotFoundError("EmailTemplate", templateID)
	}
	if !emailTemplateIsActive(tpl) {
		return apperrors.NewValidationError("emailTemplate", "the email template is inactive; resend refused")
	}
	return nil
}

// EnsureResendActionAuthority verifies the automation action that authored an
// attempt still exists AND authorizes the email channel before an operator
// resend replays its frozen content.
//
// Authority = live action AND one of:
//   - top-level: Type==SendEmail AND active template (stepID and stepFingerprint
//     ignored — email targets are frozen content replayed from the BLOCKED
//     payload, never rebuilt from mutable config, so there is no misrouting
//     surface to pin)
//   - step-addressed: Type==Composite with that unique live sendEmail step AND
//     full-config fingerprint equality (ANY edit refuses) AND active template
//
// An EMPTY actionID fails closed: flow-emitted email has no authoring persisted
// action (FlowActionCall carries no stable action id), so there is no authority
// row to verify. Resend of flow-emitted email is a documented v1 non-goal.
//
// stepID addresses the sendEmail step inside a composite action's config.
// Empty for top-level sendEmail actions. Non-empty → action.Type must be
// Composite and that exact step id must resolve to a unique live sendEmail step,
// and stepFingerprint must match the storage-safe fingerprint of the current step config.
func (as *ActionService) EnsureResendActionAuthority(ctx context.Context, actionID, stepID, stepFingerprint string) error {
	if strings.TrimSpace(actionID) == "" {
		// Flow-emitted email has no authority row to check; resend fails closed —
		// there is no persisted action to verify the template is still active.
		return apperrors.NewValidationError("actionId", "delivery attempt has no authoring action; flow-emitted email is not resendable")
	}
	lookup, err := as.resendActionLookup()
	if err != nil {
		return err
	}
	action, err := lookup.GetLiveActionByID(ctx, actionID)
	if err != nil {
		return apperrors.NewInternalError("failed to resolve action for resend authority", err)
	}
	if action == nil {
		return apperrors.NewNotFoundError("Action", actionID)
	}

	if stepID == "" {
		// Top-level sendEmail: action.Type must still be SendEmail.
		switch action.Type {
		case constants.ActionTypeSendEmail:
			return as.ensureEmailTemplateActive(ctx, action.Config)
		case constants.ActionTypeComposite:
			// A composite-root id with no step address is a legacy path; refuse
			// fail-closed — the operator must use a step-addressed resend.
			return apperrors.NewValidationError("actionId", "composite-authored attempt carries no step address; resend refused")
		default:
			return apperrors.NewValidationError("actionId", "the originating action is no longer a sendEmail action; resend refused")
		}
	}

	// Step-addressed: action must be Composite; find the unique sendEmail step.
	if action.Type != constants.ActionTypeComposite {
		return apperrors.NewValidationError("actionId", "the originating action is no longer a composite action; resend refused")
	}
	stepConfig, err := findUniqueStepConfigOfType(action.Config, stepID, constants.ActionTypeSendEmail)
	if err != nil {
		return err
	}
	if strings.TrimSpace(stepFingerprint) == "" {
		return apperrors.NewValidationError("stepFingerprint", "delivery attempt carries no step fingerprint; resend refused")
	}
	if as.stepConfigFingerprint(stepConfig) != stepFingerprint {
		return apperrors.NewValidationError("stepFingerprint", "the addressed step's configuration changed since this attempt was recorded; resend refused")
	}
	return as.ensureEmailTemplateActive(ctx, stepConfig)
}

// ResolveWebhookTarget loads the action's CURRENT config and rebuilds the
// outbound webhook target (url + headers) for an operator resend. Resend
// contract: replay the frozen CONTENT (method/body) but re-resolve
// TARGET/CREDENTIALS from current configuration — neither credential-bearing
// header values nor the raw url (whose userinfo/path/query can carry
// credentials) are ever persisted in attempt rows. A record-templated
// ({!...}) url cannot be re-evaluated without the original record context and
// fails closed.
//
// stepID addresses the webhook step inside a composite action's config. When
// non-empty the target is resolved from that step's CURRENT config rather than
// the top-level action config. Composite steps are addressed by the frozen
// stepId stored in the WebhookPayload evidence row; the resend fails closed
// when the step is gone or ambiguous.
// stepFingerprint is the full-config fingerprint (sha256 hex, canonical JSON)
// frozen at send time. At EVERY addressing level the current authoring config
// must hash byte-identical to it — ANY edit, including credential rotation,
// refuses the resend (409). Top-level compares against the action row's
// config; step-addressed compares against the unique matching step's config.
// A row id alone proves nothing: _System_Action rows are mutable, and a frozen
// historical payload must never be routed to a retargeted endpoint.
func (as *ActionService) ResolveWebhookTarget(ctx context.Context, actionID, stepID, stepFingerprint string) (*externaldelivery.ResendTarget, error) {
	if strings.TrimSpace(actionID) == "" {
		return nil, apperrors.NewValidationError("actionId", "delivery attempt has no action id; cannot re-resolve webhook target")
	}
	lookup, err := as.resendActionLookup()
	if err != nil {
		return nil, err
	}
	action, err := lookup.GetLiveActionByID(ctx, actionID)
	if err != nil {
		return nil, apperrors.NewInternalError("failed to resolve action for resend webhook target", err)
	}
	if action == nil {
		return nil, apperrors.NewNotFoundError("Action", actionID)
	}
	if stepID == "" {
		// Authority = existence + current type: a retyped action (Type no
		// longer CallWebhook) must not drive webhook resends off stale config
		// keys — _System_Action.Type is mutable metadata.
		if action.Type != constants.ActionTypeCallWebhook {
			return nil, apperrors.NewValidationError("actionId", "the originating action is no longer a callWebhook action; resend refused")
		}
		// Identity-by-equality at the top level too: the action ROW is mutable,
		// so the row id alone proves nothing about the endpoint — an admin
		// retarget (url/header edit) must refuse the resend (409), never route
		// the frozen historical payload to the new endpoint.
		if strings.TrimSpace(stepFingerprint) == "" {
			return nil, apperrors.NewValidationError("stepFingerprint", "delivery attempt carries no config fingerprint; resend refused")
		}
		if as.stepConfigFingerprint(action.Config) != stepFingerprint {
			return nil, apperrors.NewValidationError("stepFingerprint", "the action's configuration changed since this attempt was recorded; resend refused")
		}
		return as.webhookTargetFromConfig(action.Config)
	}
	// Authority = current type at every level: a root retyped away from
	// Composite (even with stale steps JSON) must not drive a step resend.
	if action.Type != constants.ActionTypeComposite {
		return nil, apperrors.NewValidationError("actionId", "the originating action is no longer a composite action; resend refused")
	}
	stepConfig, err := findUniqueWebhookStepConfig(action.Config, stepID)
	if err != nil {
		return nil, err
	}
	if strings.TrimSpace(stepFingerprint) == "" {
		return nil, apperrors.NewValidationError("stepFingerprint", "delivery attempt carries no step fingerprint; resend refused")
	}
	if as.stepConfigFingerprint(stepConfig) != stepFingerprint {
		return nil, apperrors.NewValidationError("stepFingerprint", "the addressed step's configuration changed since this attempt was recorded; resend refused")
	}
	return as.webhookTargetFromConfig(stepConfig)
}

// webhookTargetFromConfig extracts the resend target (static url + headers)
// from a webhook action/step config map. Record-templated ({!...}) urls fail
// closed: they cannot be re-evaluated without the original record context.
func (as *ActionService) webhookTargetFromConfig(config map[string]interface{}) (*externaldelivery.ResendTarget, error) {
	rawURL, exists := config[constants.ConfigURL]
	if !exists {
		return nil, apperrors.NewValidationError("url", "action config has no webhook url")
	}
	urlStr, ok := rawURL.(string)
	if !ok || strings.TrimSpace(urlStr) == "" {
		return nil, apperrors.NewValidationError("url", "action config has no webhook url")
	}
	if isActionFormulaRef(urlStr) {
		return nil, apperrors.NewValidationError("url", "webhook url is record-templated; cannot re-resolve it for resend without the original record context")
	}
	return &externaldelivery.ResendTarget{
		URL:         urlStr,
		RedactedURL: as.allowlist.Redact(urlStr),
		Headers:     buildWebhookHeaders(config),
	}, nil
}

// findUniqueWebhookStepConfig searches the persisted composite action's Config
// steps tree (recursively, so nested composites stay addressable) for exactly
// one callWebhook step with the given id. Zero matches (step deleted/renamed/
// retyped) or multiple matches (ambiguous id) fail closed — the resend
// contract resolves the CURRENT step config or refuses.
func findUniqueWebhookStepConfig(config map[string]interface{}, stepID string) (map[string]interface{}, error) {
	return findUniqueStepConfigOfType(config, stepID, constants.ActionTypeCallWebhook)
}

// findUniqueStepConfigOfType searches the persisted composite action's Config
// steps tree (recursively) for exactly one step of the given actionType with
// the given stepID. Zero matches or multiple matches (ambiguous id) fail closed.
// Used by both webhook resend (actionType=callWebhook) and email resend
// (actionType=sendEmail).
func findUniqueStepConfigOfType(config map[string]interface{}, stepID, actionType string) (map[string]interface{}, error) {
	// Collect across the WHOLE tree first and judge uniqueness once at the top:
	// per-subtree uniqueness checks would swallow a nested ambiguity and
	// silently resolve to an outer match (fail-open).
	matches := collectStepConfigsOfType(config, stepID, actionType)
	switch len(matches) {
	case 1:
		return matches[0], nil
	case 0:
		return nil, apperrors.NewValidationError("stepId", fmt.Sprintf("%s step not found in the action's current configuration; it may have been removed or changed", actionType))
	default:
		return nil, apperrors.NewValidationError("stepId", fmt.Sprintf("%s step id is ambiguous in the action's current configuration", actionType))
	}
}

// collectStepConfigsOfType collects all steps with the given id AND actionType
// from the config steps tree (recursively, so nested composites stay addressable).
// Replaces the former webhook-only collectWebhookStepConfigs; webhook callers
// pass actionType=constants.ActionTypeCallWebhook.
func collectStepConfigsOfType(config map[string]interface{}, stepID, actionType string) []map[string]interface{} {
	stepsInterface, ok := config[constants.ConfigKeySteps]
	if !ok {
		return nil
	}
	stepsList, ok := stepsInterface.([]interface{})
	if !ok {
		return nil
	}
	var matches []map[string]interface{}
	for _, stepInterface := range stepsList {
		stepMap, ok := stepInterface.(map[string]interface{})
		if !ok {
			continue
		}
		sid, _ := stepMap[constants.FieldID].(string)
		stype, _ := stepMap[constants.FieldSysAction_Type].(string)
		if sid == stepID && stype == actionType {
			matches = append(matches, stepMap)
		}
		// Recurse into nested composite steps.
		if stype == constants.ActionTypeComposite {
			matches = append(matches, collectStepConfigsOfType(stepMap, stepID, actionType)...)
		}
	}
	return matches
}
