package plan

import (
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"sort"
)

const (
	implicitFailureIDPrefix       = "implicit_failure_"
	implicitFailedSinkID          = "implicit_failure_failed"
	implicitTimedOutSinkID        = "implicit_failure_timed_out"
	implicitUnhandledFailureCode  = "run.unhandled_failure"
	implicitUnhandledTimedOutCode = "effect.timed_out"

	defaultActionBackoff = "PT1S"
)

type implicitFailureRoute struct {
	sourceNodeID string
	sourcePort   string
	edgeID       string
}

func usesImplicitFailureAuthoring(document *Document) bool {
	return document != nil && document.APIVersion == APIVersion
}

func isImplicitFailurePort(port string) bool {
	return port == "failed" || port == "timed_out"
}

func effectiveActionRetry(document *Document, configured *RetryPolicy) RetryPolicy {
	if usesImplicitFailureAuthoring(document) && configured == nil {
		return RetryPolicy{MaxAttempts: 1, Backoff: defaultActionBackoff}
	}
	if configured == nil {
		return RetryPolicy{}
	}
	return *configured
}

func implicitFailureSinkID(port string) (string, bool) {
	switch port {
	case "failed":
		return implicitFailedSinkID, true
	case "timed_out":
		return implicitTimedOutSinkID, true
	default:
		return "", false
	}
}

func implicitFailureCode(port string) (string, bool) {
	switch port {
	case "failed":
		return implicitUnhandledFailureCode, true
	case "timed_out":
		return implicitUnhandledTimedOutCode, true
	default:
		return "", false
	}
}

func implicitFailureEdgeID(nodeID, port string) string {
	digest := sha256.Sum256([]byte("loop.plan.implicit-failure/v1\x00" + nodeID + "\x00" + port))
	// The prefix plus 20 digest bytes is 57 characters, below the 64-byte ID
	// limit. Source IDs with this prefix are reserved, so the generated
	// ID cannot collide with an authored edge.
	return implicitFailureIDPrefix + hex.EncodeToString(digest[:20])
}

func collectImplicitFailureRoutes(document *Document) []implicitFailureRoute {
	if !usesImplicitFailureAuthoring(document) {
		return nil
	}
	outgoing := make(map[string]map[string]int, len(document.Graph.Nodes))
	for index := range document.Graph.Edges {
		edge := &document.Graph.Edges[index]
		if outgoing[edge.Source.NodeID] == nil {
			outgoing[edge.Source.NodeID] = map[string]int{}
		}
		outgoing[edge.Source.NodeID][edge.Source.Port]++
	}
	routes := make([]implicitFailureRoute, 0)
	for index := range document.Graph.Nodes {
		node := &document.Graph.Nodes[index]
		for _, port := range sourcePorts(node) {
			if isImplicitFailurePort(port) && !compiledIRPortDropped(node, port) && outgoing[node.ID][port] == 0 {
				routes = append(routes, implicitFailureRoute{
					sourceNodeID: node.ID,
					sourcePort:   port,
					edgeID:       implicitFailureEdgeID(node.ID, port),
				})
			}
		}
	}
	sort.Slice(routes, func(i, j int) bool {
		if routes[i].sourceNodeID != routes[j].sourceNodeID {
			return routes[i].sourceNodeID < routes[j].sourceNodeID
		}
		return routes[i].sourcePort < routes[j].sourcePort
	})
	return routes
}

func implicitFailureBudgetDiagnostics(document *Document) []Diagnostic {
	routes := collectImplicitFailureRoutes(document)
	if len(routes) == 0 {
		return nil
	}
	ports := map[string]bool{}
	for _, route := range routes {
		ports[route.sourcePort] = true
	}
	diagnostics := make([]Diagnostic, 0, 2)
	if len(document.Graph.Nodes)+len(ports) > MaxGraphNodes {
		diagnostics = append(diagnostics, Diagnostic{
			Severity: SeverityError,
			Code:     "resource.too_many_compiled_nodes",
			Path:     "graph.nodes",
			Message:  fmt.Sprintf("compiled graph including implicit failure sinks must not contain more than %d nodes", MaxGraphNodes),
		})
	}
	if len(document.Graph.Edges)+len(routes) > MaxGraphEdges {
		diagnostics = append(diagnostics, Diagnostic{
			Severity: SeverityError,
			Code:     "resource.too_many_compiled_edges",
			Path:     "graph.edges",
			Message:  fmt.Sprintf("compiled graph including implicit failure edges must not contain more than %d edges", MaxGraphEdges),
		})
	}
	return diagnostics
}

// lowerImplicitFailures appends only executable IR. The authoring Document is
// never mutated or persisted with generated nodes/edges. Every generated edge
// and sink is deterministic, explicit in the digest, and independently
// validated by DecodeArtifact.
func lowerImplicitFailures(artifact *Artifact, document *Document) error {
	routes := collectImplicitFailureRoutes(document)
	if len(routes) == 0 {
		return nil
	}
	neededPorts := map[string]bool{}
	for _, route := range routes {
		neededPorts[route.sourcePort] = true
	}
	for _, port := range []string{"failed", "timed_out"} {
		if !neededPorts[port] {
			continue
		}
		sinkID, _ := implicitFailureSinkID(port)
		code, _ := implicitFailureCode(port)
		artifact.Nodes = append(artifact.Nodes, ArtifactNode{
			ID:          sinkID,
			Opcode:      OpcodeFailPlan,
			InputPorts:  []ArtifactPort{{Name: "in", Contract: ArtifactContract{}}},
			OutputPorts: []ArtifactPort{},
			StateEffects: ArtifactStateEffects{
				Reads: []string{}, Writes: []string{},
			},
			ScopePath: []ArtifactScopeFrame{},
			Config: ArtifactNodeConfig{
				FailPlan: &ArtifactFailPlanConfig{FallbackCode: code, SourcePort: port},
			},
		})
	}
	for _, route := range routes {
		sinkID, _ := implicitFailureSinkID(route.sourcePort)
		edge := ArtifactEdge{
			ID:               route.edgeID,
			Source:           ArtifactEndpoint{NodeID: route.sourceNodeID, Port: route.sourcePort},
			Target:           ArtifactEndpoint{NodeID: sinkID, Port: "in"},
			Bindings:         []ArtifactBinding{},
			ScopeTransitions: []ArtifactScopeTransition{},
		}
		// An unhandled effect failure inside a dynamic item body terminates
		// only that item under collect semantics. The global fail-plan sink
		// remains the physical target for a stable wire shape, but runtime
		// consumes this boundary transition before activating that sink.
		for nodeIndex := range artifact.Nodes {
			node := &artifact.Nodes[nodeIndex]
			if node.ID != route.sourceNodeID {
				continue
			}
			for frameIndex := len(node.ScopePath) - 1; frameIndex >= 0; frameIndex-- {
				frame := node.ScopePath[frameIndex]
				if frame.Kind != ArtifactScopeFanOut {
					continue
				}
				action := "fail"
				if route.sourcePort == "timed_out" {
					action = "timeout"
				}
				edge.ScopeTransitions = append(edge.ScopeTransitions, ArtifactScopeTransition{
					ScopeKind: ArtifactScopeFanOut,
					OwnerID:   frame.OwnerID,
					Action:    action,
				})
				break
			}
			break
		}
		// A loop owner's failed edge is part of the loop boundary metadata even
		// when the edge was implicit in source.
		if route.sourcePort == "failed" {
			for scopeIndex := range artifact.Scopes {
				scope := &artifact.Scopes[scopeIndex]
				if scope.Kind == ArtifactScopeLoop && scope.OwnerID == route.sourceNodeID && scope.Loop != nil {
					edge.ScopeTransitions = append(edge.ScopeTransitions, ArtifactScopeTransition{
						ScopeKind: ArtifactScopeLoop, OwnerID: route.sourceNodeID, Action: "failed",
					})
					scope.Loop.FailedEdges = append(scope.Loop.FailedEdges, route.edgeID)
					sort.Strings(scope.Loop.FailedEdges)
					break
				}
			}
		}
		sortArtifactScopeTransitions(edge.ScopeTransitions)
		artifact.Edges = append(artifact.Edges, edge)
	}
	return nil
}
