package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"reflect"
	"regexp"
	"strings"
)

// FlowRecord is the minimal slice of a Flow + FlowDefinition row needed for
// migration. It is intentionally loosely typed: the stored Metadata JSON is
// kept as a raw map so fields unknown to NexusCRM (Salesforce adds new
// fields frequently) roundtrip without data loss.
type FlowRecord struct {
	DeveloperName      string
	FlowDefinitionID   string
	FlowVersionID      string
	ProcessType        string
	Metadata           map[string]any
	MetadataRaw        string // canonical source string (DB Metadata column)
}

// MigrationResult is the output of Migrate.
type MigrationResult struct {
	DeveloperName       string
	OriginalPretty      []byte
	MigratedPretty      []byte
	MigratedCompact     []byte
	NewProcessType      string
	NewTriggerType      string // FlowDefinition.TriggerType (RecordBeforeSave/RecordAfterSave/Scheduled/"")
	NewTriggerObject    string // FlowDefinition.TriggerObjectOrEvent
	RewriteCount        int    // number of myVariable_* token rewrites applied
	AlreadyMigrated     bool
}

// Migrate produces a migrated copy of rec.Metadata:
//   - synthesises a native FlowStart element (unless the flow is InvocableProcess)
//   - rewrites myVariable_current / myVariable_old -> $Record / $Record__Old
//     inside every string-valued leaf of the metadata tree
//   - adjusts top-level processType to AutoLaunchedFlow
//
// The function never mutates rec.Metadata — it works on a deep copy. The
// returned result includes both pretty and compact JSON serialisations and
// has passed a roundtrip check (unmarshal -> marshal -> unmarshal -> deep
// equal).
func Migrate(rec *FlowRecord) (*MigrationResult, error) {
	if rec == nil {
		return nil, fmt.Errorf("nil record")
	}

	// Pretty-print the ORIGINAL first so --dry-run output captures the
	// incoming state regardless of how the upstream JSON is formatted.
	origPretty, err := prettyMarshal(rec.Metadata)
	if err != nil {
		return nil, fmt.Errorf("marshal original: %w", err)
	}

	// Deep-copy via JSON roundtrip. map[string]any needs it: reflect-based
	// copy is overkill for a flow metadata blob we just deserialised.
	working, err := deepCopy(rec.Metadata)
	if err != nil {
		return nil, fmt.Errorf("deep copy: %w", err)
	}

	// Unlike a pre-check + short-circuit, we always run the full
	// migration pipeline below. AlreadyMigrated is decided at the end
	// by comparing the migrated compact JSON against the input
	// MetadataRaw — this catches cases where a previous script left
	// partially-correct metadata (e.g. callee with an orphan `start`
	// element) that a presence-based check would whitelist.

	// Parse the root-level processMetadataValues — these carry the trigger
	// semantics we need to synthesise FlowStart.
	pmv := parseProcessMetadataValues(working)

	// Determine the migration profile.
	profile, err := classify(rec.DeveloperName, rec.ProcessType, pmv)
	if err != nil {
		return nil, err
	}

	// Build the FlowStart (nil for InvocableProcess callees).
	start, triggerType, triggerObject, startErr := buildStart(working, profile, pmv)
	if startErr != nil {
		return nil, startErr
	}

	// Install the new start + processType. Drop legacy startElementReference
	// to avoid ambiguity — the native engine uses `start.connector`.
	if start != nil {
		working["start"] = start
		// Remove the legacy startElementReference — its value has been transferred
		// to start.connector.targetReference. Keeping both could confuse the
		// native engine which only reads start.connector.
		delete(working, "startElementReference")
	} else {
		// InvocableProcess callee: drop any existing start and keep
		// startElementReference (it still marks the entry element for the
		// invocable caller). The native engine treats InvocableProcess /
		// AutoLaunchedFlow-without-start as callable-only.
		delete(working, "start")
	}
	working["processType"] = profile.NewProcessType

	// Rewrite myVariable_* -> $Record / $Record__Old across every string
	// leaf. DON'T touch the processMetadataValues block — it documents the
	// original mapping and rewriting it would lose that provenance. We
	// leave it alongside the native FlowStart; it is ignored by the engine
	// but preserved for roundtrip fidelity.
	rewrites := rewriteVariableTokens(working, map[string]bool{"processMetadataValues": true})

	// Roundtrip validation: marshal -> unmarshal -> deep-equal. Catches
	// unmarshalable values (e.g. functions) before we write to the DB.
	migCompact, err := json.Marshal(working)
	if err != nil {
		return nil, fmt.Errorf("compact marshal migrated: %w", err)
	}
	var roundtrip map[string]any
	if err := json.Unmarshal(migCompact, &roundtrip); err != nil {
		return nil, fmt.Errorf("roundtrip unmarshal: %w", err)
	}
	if !reflect.DeepEqual(working, roundtrip) {
		return nil, fmt.Errorf("roundtrip mismatch: marshal/unmarshal lost information")
	}

	migPretty, err := prettyMarshal(working)
	if err != nil {
		return nil, fmt.Errorf("pretty marshal migrated: %w", err)
	}

	// AlreadyMigrated is true iff the migrated metadata is structurally
	// identical to what is already in the DB AND rewrite touched nothing.
	// Structural comparison is done via DeepEqual of the freshly
	// unmarshalled migrated blob vs the caller-supplied rec.Metadata,
	// since the two maps may serialise to different byte strings with
	// the same meaning (key ordering, float formatting, etc.).
	var origStructure map[string]any
	if rec.MetadataRaw != "" {
		if err := json.Unmarshal([]byte(rec.MetadataRaw), &origStructure); err != nil {
			return nil, fmt.Errorf("unmarshal MetadataRaw: %w", err)
		}
	} else {
		// Fall back to rec.Metadata via roundtrip so both sides go
		// through the same normalisation.
		b, err := json.Marshal(rec.Metadata)
		if err != nil {
			return nil, fmt.Errorf("marshal rec.Metadata: %w", err)
		}
		if err := json.Unmarshal(b, &origStructure); err != nil {
			return nil, fmt.Errorf("renormalise rec.Metadata: %w", err)
		}
	}
	alreadyMigrated := reflect.DeepEqual(origStructure, working)

	return &MigrationResult{
		DeveloperName:    rec.DeveloperName,
		OriginalPretty:   origPretty,
		MigratedPretty:   migPretty,
		MigratedCompact:  migCompact,
		NewProcessType:   profile.NewProcessType,
		NewTriggerType:   triggerType,
		NewTriggerObject: triggerObject,
		RewriteCount:     rewrites,
		AlreadyMigrated:  alreadyMigrated,
	}, nil
}

// migrationProfile captures the per-flow policy decisions we have to make
// before building FlowStart. Keeping it in one struct makes the special
// cases explicit.
type migrationProfile struct {
	DeveloperName   string
	IsCallableOnly  bool   // true for InvocableProcess callees
	IsScheduled     bool   // Order_Expiration_Notice_90 special case
	NewProcessType  string // always "AutoLaunchedFlow" after migration
}

// calleeFlows is the set of InvocableProcess callees. Pinning this to
// DeveloperName rather than live ProcessType means the classification
// is stable across re-runs — after the first --apply, the stored
// ProcessType is already AutoLaunchedFlow, so a "check originalProcessType
// == InvocableProcess" heuristic would silently misclassify callees
// on repeat runs.
var calleeFlows = map[string]bool{
	"Order_Expiration_Alert_7":  true,
	"Order_Expiration_Alert_15": true,
	"Order_Expiration_Alert_30": true,
	"Order_Expiration_Alert_60": true,
	"Order_Expiration_Alert_90": true,
}

// classify applies the per-flow policy rules from the Phase 1 design doc.
// The two special cases are explicit, everything else is a default
// record-triggered migration.
func classify(name, originalProcessType string, _ parsedPMV) (migrationProfile, error) {
	p := migrationProfile{DeveloperName: name, NewProcessType: "AutoLaunchedFlow"}

	// Special case A: InvocableProcess callees — AutoLaunchedFlow with no
	// FlowStart. They are invoked by other flows via actionType="flow".
	//
	// Source: design doc Phase 1 step 2, "InvocableProcess ... no ObjectType
	// ... migrated to ProcessType = AutoLaunchedFlow with no FlowStart".
	// In our dataset all 5 Order_Expiration_Alert_* flows carry ObjectType
	// in processMetadataValues (equal to "Order") but they are still
	// callable-only — the presence of ObjectType in pmv reflects legacy SF
	// metadata, not a record trigger binding.
	if calleeFlows[name] || originalProcessType == "InvocableProcess" {
		p.IsCallableOnly = true
		return p, nil
	}

	// Special case B: Order_Expiration_Notice_90 is a Workflow on record
	// trigger but its 3 Waits orchestrate scheduled day-boundary
	// notifications. Design doc Wave 3 calls for Scheduled + Wait. Migrate
	// it as AutoLaunchedFlow with FlowStart.triggerType="Scheduled".
	if name == "Order_Expiration_Notice_90" {
		p.IsScheduled = true
		return p, nil
	}

	// Default: record-triggered AutoLaunchedFlow.
	return p, nil
}

// rewriteVariableTokens walks the metadata tree and rewrites any string
// leaf that matches the legacy variable tokens. It returns the number of
// rewrites performed.
//
// Tokens rewritten (as full-token, boundary-aware substitutions):
//   - `myVariable_current`   -> `$Record`
//   - `myVariable_current.X` -> `$Record.X` (including `__r.X` chains)
//   - `myVariable_old`       -> `$Record__Old`
//   - `myVariable_old.X`     -> `$Record__Old.X`
//
// We deliberately operate at the string leaf level rather than on a typed
// struct because the SF Metadata payload is large, we only care about a
// handful of leaf fields (name, value.elementReference, formulaExpression,
// ...), and a tree-walker is safer than enumerating every legacy field.
//
// The skipKeys argument blocks recursion into specified top-level keys —
// used to preserve the `processMetadataValues` block untouched for
// provenance.
func rewriteVariableTokens(v any, skipKeys map[string]bool) int {
	n := 0
	switch x := v.(type) {
	case map[string]any:
		for k, vv := range x {
			if skipKeys[k] {
				continue
			}
			if s, ok := vv.(string); ok {
				if newS, delta := rewriteTokensInString(s); delta > 0 {
					x[k] = newS
					n += delta
				}
				continue
			}
			n += rewriteVariableTokens(vv, nil)
		}
	case []any:
		for i, vv := range x {
			if s, ok := vv.(string); ok {
				if newS, delta := rewriteTokensInString(s); delta > 0 {
					x[i] = newS
					n += delta
				}
				continue
			}
			n += rewriteVariableTokens(vv, nil)
		}
	}
	return n
}

// variableTokenRegex matches myVariable_current or myVariable_old as
// whole-identifier tokens, optionally followed by a `.X[.Y...]` chain.
//
// Word boundary handling:
//   - Leading: "not preceded by [A-Za-z0-9_]" (captured in group 1 so we
//     can put it back in the replacement).
//   - Trailing: "followed by end-of-string, `.` plus an identifier, or
//     any non-identifier char". We can't express look-ahead in Go's
//     RE2, so we capture the optional `.X.Y...` chain explicitly and
//     then validate the trailing character in the replacement function
//     (skip the match if a raw identifier char follows the bare token
//     or the chain).
//
// We use a regex because the tokens may appear inside formula expressions
// (e.g. `IF(myVariable_current.Amount__c > 0, ...)`) where surrounding
// characters are arbitrary punctuation. The post-match trailing-boundary
// check catches names like `myVariable_currentX` that would otherwise
// be clipped to `$RecordX`.
var variableTokenRegex = regexp.MustCompile(`(^|[^A-Za-z0-9_])(myVariable_(current|old))((?:\.[A-Za-z_][A-Za-z0-9_]*)*)`)

func rewriteTokensInString(s string) (string, int) {
	if !strings.Contains(s, "myVariable_") {
		return s, 0
	}
	// We walk matches explicitly (rather than ReplaceAllStringFunc)
	// because we need to inspect the character immediately after each
	// match to enforce the trailing word boundary. Go's RE2 cannot
	// express look-ahead, so the check is done here.
	matches := variableTokenRegex.FindAllStringSubmatchIndex(s, -1)
	if len(matches) == 0 {
		return s, 0
	}

	var out strings.Builder
	out.Grow(len(s) + 16)
	count := 0
	cursor := 0
	for _, m := range matches {
		// m layout: [matchStart, matchEnd, g1Start, g1End, g2Start,
		// g2End, g3Start, g3End, g4Start, g4End]
		matchEnd := m[1]
		// Trailing boundary: if the char immediately after the match is
		// an identifier char, the match is a false hit
		// (e.g. "myVariable_currentX"). Skip the rewrite for this
		// occurrence.
		if matchEnd < len(s) && isIdentChar(s[matchEnd]) {
			continue
		}

		prefixStart, prefixEnd := m[2], m[3]
		whichStart, whichEnd := m[6], m[7]
		chainStart, chainEnd := m[8], m[9]

		// Everything between the previous cursor and the match start
		// is pass-through.
		out.WriteString(s[cursor:m[0]])
		out.WriteString(s[prefixStart:prefixEnd])
		if s[whichStart:whichEnd] == "current" {
			out.WriteString("$Record")
		} else {
			out.WriteString("$Record__Old")
		}
		out.WriteString(s[chainStart:chainEnd])
		cursor = matchEnd
		count++
	}
	out.WriteString(s[cursor:])
	return out.String(), count
}

// isIdentChar reports whether b is an ASCII identifier character
// [A-Za-z0-9_]. SF field API names are pinned to that alphabet.
func isIdentChar(b byte) bool {
	switch {
	case b >= 'a' && b <= 'z':
	case b >= 'A' && b <= 'Z':
	case b >= '0' && b <= '9':
	case b == '_':
	default:
		return false
	}
	return true
}

// deepCopy clones a JSON-shaped value by marshalling + unmarshalling. Works
// for anything produced by json.Unmarshal into map[string]any / []any.
func deepCopy(v map[string]any) (map[string]any, error) {
	b, err := json.Marshal(v)
	if err != nil {
		return nil, err
	}
	var out map[string]any
	if err := json.Unmarshal(b, &out); err != nil {
		return nil, err
	}
	return out, nil
}

// prettyMarshal returns indented JSON without HTML escaping. SF flows
// contain `<` and `>` inside descriptions; json.Marshal escapes those to
// \u003c, making the dry-run diff harder to read.
func prettyMarshal(v any) ([]byte, error) {
	var buf bytes.Buffer
	enc := json.NewEncoder(&buf)
	enc.SetIndent("", "  ")
	enc.SetEscapeHTML(false)
	if err := enc.Encode(v); err != nil {
		return nil, err
	}
	// json.Encoder appends a trailing newline; normalise to a single
	// trailing newline for diff-friendliness.
	return buf.Bytes(), nil
}

