package exec

import (
	"encoding/json"
	"errors"
	"fmt"
	"sort"
	"strings"
)

// RuntimeActionSource identifies the owner of one action descriptor.
type RuntimeActionSource string

const (
	// RuntimeActionBuiltin identifies a fixed first-party Exec action.
	RuntimeActionBuiltin RuntimeActionSource = "builtin"
	// RuntimeActionExtension identifies a Server-authorized Extension action.
	RuntimeActionExtension RuntimeActionSource = "extension"
)

// RuntimeAction is the descriptor-only projection used by the unified Exec
// CLI and route dispatcher. It contains no executable handler.
type RuntimeAction struct {
	Source       RuntimeActionSource
	Domain       string
	Name         string
	Summary      string
	Description  string
	InputSchema  json.RawMessage
	OutputSchema json.RawMessage
	Example      json.RawMessage
	Permission   string
	Safety       ExtensionSafety
	extension    *ExtensionAction
}

// Extension returns the immutable Extension descriptor for a dynamic action.
func (action RuntimeAction) Extension() (ExtensionAction, bool) {
	if action.Source != RuntimeActionExtension || action.extension == nil {
		return ExtensionAction{}, false
	}
	return cloneExtensionAction(*action.extension), true
}

// RuntimeCatalog combines the fixed built-in Exec contract and one optional
// verified Extension snapshot without adding execution hooks to either model.
type RuntimeCatalog struct {
	builtin    *Catalog
	extensions ExtensionSnapshot
	actions    []RuntimeAction
	byKey      map[string]RuntimeAction
	// diagnostics carries the deterministic, publish-safe isolation reasons for
	// Extension actions excluded from this Catalog (bad input/output schemas).
	// It is empty when no action was isolated.
	diagnostics []string
}

// NewRuntimeCatalog validates and combines the built-in Catalog with one
// optional Extension snapshot. An Extension problem is never fatal to the
// Catalog: structural validation failure drops every Extension domain
// (fail-closed) with one aggregated diagnostic, and a reserved-name collision
// isolates only the colliding Extension, so built-in actions and the remaining
// valid Extension actions always stay publishable.
func NewRuntimeCatalog(
	builtin *Catalog,
	extensions ExtensionSnapshot,
) (*RuntimeCatalog, error) {
	if builtin == nil {
		return nil, errors.New("Exec Runtime Catalog requires a built-in Catalog")
	}
	var diagnostics []string
	if err := ValidateExtensionSnapshot(extensions); err != nil {
		// Drop ALL domains before the merge agentteam: the agentteam performs none of the
		// structural checks below (consent, duplicate names, safety class), so
		// merging a structurally invalid snapshot would let e.g. an
		// unconsented destructive action into byKey and clobber built-ins. The
		// ETag is kept so a later conditional fetch can still reuse the
		// complete response; Empty() is domain-count based, so activation
		// treats this as no extensions.
		diagnostics = append(diagnostics, extensionDomainDiagnostic(
			"*",
			fmt.Errorf("snapshot failed structural validation; all Extensions excluded: %w", err),
		))
		extensions = NewExtensionSnapshot(extensions.ETag(), nil)
	}

	builtinDomains := make(map[string]struct{})
	runtimeCatalog := &RuntimeCatalog{
		builtin:     builtin,
		extensions:  NewExtensionSnapshot(extensions.ETag(), extensions.Domains()),
		byKey:       make(map[string]RuntimeAction, builtin.len()),
		diagnostics: diagnostics,
	}
	for _, descriptor := range builtin.actionsSnapshot() {
		builtinDomains[descriptor.Domain] = struct{}{}
		action := RuntimeAction{
			Source:       RuntimeActionBuiltin,
			Domain:       descriptor.Domain,
			Name:         descriptor.Name,
			Summary:      descriptor.Summary,
			Description:  descriptor.Description,
			InputSchema:  append(json.RawMessage(nil), descriptor.InputSchema...),
			OutputSchema: append(json.RawMessage(nil), descriptor.OutputSchema...),
			Permission:   string(descriptor.Permission),
		}
		if len(descriptor.Examples) > 0 {
			action.Example = append(json.RawMessage(nil), descriptor.Examples[0].Input...)
		}
		runtimeCatalog.add(action)
	}
	for _, domain := range runtimeCatalog.extensions.Domains() {
		mergeExtensionDomain(runtimeCatalog, builtinDomains, domain)
	}
	sort.Slice(runtimeCatalog.actions, func(i, j int) bool {
		return runtimeCatalog.actions[i].Domain+" "+runtimeCatalog.actions[i].Name <
			runtimeCatalog.actions[j].Domain+" "+runtimeCatalog.actions[j].Name
	})
	sort.Strings(runtimeCatalog.diagnostics)
	return runtimeCatalog, nil
}

// mergeExtensionDomain merges one Extension domain into the Catalog. Every
// failure mode is isolated per unit: a reserved-name collision excludes only
// this Extension, and a bad action schema excludes only that action, each with
// a publish-safe diagnostic, so built-ins and the surviving Extension actions
// always stay publishable.
func mergeExtensionDomain(
	runtimeCatalog *RuntimeCatalog,
	builtinDomains map[string]struct{},
	domain ExtensionDomain,
) {
	if _, reserved := builtinDomains[domain.ExtensionName]; reserved {
		// A reserved-name collision isolates only this Extension. Returning
		// an error here (the old behavior) let one colliding Extension kill
		// the whole Catalog; add() would otherwise also let its actions
		// clobber the built-in byKey entries.
		runtimeCatalog.diagnostics = append(runtimeCatalog.diagnostics,
			extensionDomainDiagnostic(
				domain.ExtensionName,
				fmt.Errorf("Extension name collides with reserved built-in Exec domain"),
			))
		return
	}
	for _, descriptor := range domain.Actions {
		// Per-action schema callability is isolated: a bad action is
		// excluded from lookup/actions/help/HTTP routes fail-closed with an
		// actionable diagnostic, while the rest of the Catalog (built-ins
		// and other valid extension actions) keeps publishing. The
		isolated, diagnostic := isolateExtensionAction(descriptor)
		if isolated {
			runtimeCatalog.diagnostics = append(runtimeCatalog.diagnostics, diagnostic)
			continue
		}
		inputSchema, err := extensionSchemaJSON(descriptor.InputSchema)
		if err != nil {
			runtimeCatalog.diagnostics = append(runtimeCatalog.diagnostics,
				extensionCatalogDiagnostic(descriptor, "input", err))
			continue
		}
		outputSchema, err := extensionResultSchemaJSON(descriptor)
		if err != nil {
			runtimeCatalog.diagnostics = append(runtimeCatalog.diagnostics,
				extensionCatalogDiagnostic(descriptor, "output", err))
			continue
		}
		example, err := json.Marshal(descriptor.Example)
		if err != nil {
			runtimeCatalog.diagnostics = append(runtimeCatalog.diagnostics,
				extensionCatalogDiagnostic(descriptor, "example", err))
			continue
		}
		// The wrapped input/output schemas must compile as standalone
		// envelopes; a compile failure is also isolated.
		if _, err := compileSchema(inputSchema); err != nil {
			runtimeCatalog.diagnostics = append(runtimeCatalog.diagnostics,
				extensionCatalogDiagnostic(descriptor, "input", err))
			continue
		}
		if _, err := compileSchema(outputSchema); err != nil {
			runtimeCatalog.diagnostics = append(runtimeCatalog.diagnostics,
				extensionCatalogDiagnostic(descriptor, "output", err))
			continue
		}
		copy := cloneExtensionAction(descriptor)
		runtimeCatalog.add(RuntimeAction{
			Source:       RuntimeActionExtension,
			Domain:       domain.ExtensionName,
			Name:         descriptor.Name,
			Summary:      descriptor.Summary,
			Description:  descriptor.Description,
			InputSchema:  inputSchema,
			OutputSchema: outputSchema,
			Example:      example,
			Safety:       descriptor.Safety,
			extension:    &copy,
		})
	}
}

// extensionCatalogDiagnostic builds the actionable, publish-safe diagnostic for
// one isolated Extension action. It names the extension, the action, the
// failing schema stage, and the bounded root cause; it never leaks raw schema
// bodies or secrets.
func extensionCatalogDiagnostic(
	descriptor ExtensionAction,
	stage string,
	cause error,
) string {
	return extensionActionDiagnostic(descriptor.ExtensionName, descriptor.Name, stage, cause)
}

// extensionActionDiagnostic builds the publish-safe diagnostic for one skipped
// or isolated Extension action, naming the extension, the action, the failing
// stage, and the bounded root cause.
func extensionActionDiagnostic(
	extensionName string,
	actionName string,
	stage string,
	cause error,
) string {
	return fmt.Sprintf(
		"extension=%s action=%s schema=%s: %s",
		extensionName,
		actionName,
		stage,
		boundedDiagnosticCause(cause),
	)
}

// extensionDomainDiagnostic builds the publish-safe diagnostic for one whole
// Extension excluded at fetch or merge time (invalid identity, no surviving
// Action, reserved-name collision).
func extensionDomainDiagnostic(extensionName string, cause error) string {
	return fmt.Sprintf(
		"extension=%s action=- schema=domain: %s",
		extensionName,
		boundedDiagnosticCause(cause),
	)
}

// boundedDiagnosticCause renders one bounded, publish-safe root cause. It never
// includes raw schema bodies, input, or secrets.
func boundedDiagnosticCause(cause error) string {
	if cause == nil {
		return ""
	}
	text := strings.TrimSpace(cause.Error())
	if text == "" {
		return ""
	}
	if truncated := []rune(text); len(truncated) > 256 {
		return string(truncated[:256]) + "…"
	}
	return text
}

// isolateExtensionAction reports whether an Extension action's input/output
// schemas are structurally unusable before wrapping (e.g. not an object
// schema). Runtime schema compile failures are isolated separately after the
// wrap so the diagnostics can name the exact schema stage.
func isolateExtensionAction(descriptor ExtensionAction) (bool, string) {
	if descriptor.InputSchema == nil {
		return true, extensionCatalogDiagnostic(descriptor, "input",
			errors.New("input schema is missing"))
	}
	if descriptor.OutputSchema == nil {
		return true, extensionCatalogDiagnostic(descriptor, "output",
			errors.New("output schema is missing"))
	}
	return false, ""
}

// Diagnostics returns the deterministic, publish-safe isolation diagnostics for
// Extension actions excluded from this Runtime Catalog. Empty when no action
// was isolated. Diagnostics never contain raw schema bodies or secrets.
func (catalog *RuntimeCatalog) Diagnostics() []string {
	if catalog == nil {
		return nil
	}
	return append([]string(nil), catalog.diagnostics...)
}

func (catalog *RuntimeCatalog) add(action RuntimeAction) {
	key := action.Domain + " " + action.Name
	catalog.actions = append(catalog.actions, cloneRuntimeAction(action))
	catalog.byKey[key] = cloneRuntimeAction(action)
}

// Lookup returns one exact action descriptor as an immutable copy.
func (catalog *RuntimeCatalog) Lookup(domain, name string) (RuntimeAction, bool) {
	if catalog == nil {
		return RuntimeAction{}, false
	}
	action, ok := catalog.byKey[domain+" "+name]
	if !ok {
		return RuntimeAction{}, false
	}
	return cloneRuntimeAction(action), true
}

// Actions returns all descriptors in deterministic domain/action order.
func (catalog *RuntimeCatalog) Actions() []RuntimeAction {
	if catalog == nil {
		return nil
	}
	actions := make([]RuntimeAction, 0, len(catalog.actions))
	for _, action := range catalog.actions {
		actions = append(actions, cloneRuntimeAction(action))
	}
	return actions
}

// ExtensionSnapshot returns the pinned Extension snapshot as a deep copy.
func (catalog *RuntimeCatalog) ExtensionSnapshot() ExtensionSnapshot {
	if catalog == nil {
		return ExtensionSnapshot{}
	}
	return NewExtensionSnapshot(
		catalog.extensions.ETag(),
		catalog.extensions.Domains(),
	)
}

// ValidateInput validates one JSON input against its owning descriptor.
func (catalog *RuntimeCatalog) ValidateInput(
	domain string,
	name string,
	input json.RawMessage,
) error {
	action, ok := catalog.Lookup(domain, name)
	if !ok {
		return fmt.Errorf("exec: unknown action %s %s", domain, name)
	}
	if action.Source == RuntimeActionBuiltin {
		return catalog.builtin.ValidateInput(domain, name, input)
	}
	extension, ok := action.Extension()
	if !ok {
		return errors.New("exec: Extension descriptor unavailable")
	}
	return extension.ValidateInput(input)
}

func extensionSchemaJSON(schema map[string]any) (json.RawMessage, error) {
	if schema == nil {
		return json.RawMessage(`{}`), nil
	}
	raw, err := json.Marshal(schema)
	if err != nil {
		return nil, err
	}
	return raw, nil
}

func extensionResultSchemaJSON(action ExtensionAction) (json.RawMessage, error) {
	dataSchema := cloneJSONMap(action.OutputSchema)
	if dataSchema == nil {
		return nil, fmt.Errorf(
			"Extension output schema is required for %q/%q",
			action.ExtensionName,
			action.Name,
		)
	}
	// Resource-safe embedding (Draft 2020-12). The data schema is kept as ONE
	// self-contained schema resource: its root $id (when present) or a
	// deterministic per-action $id is preserved, and its $defs/$anchor plus all
	// recursive and multi-layer local "#/$defs/..." references stay inside that
	// resource boundary. The envelope references the resource by $id instead of
	// inlining it, so:
	//   - an output schema whose root carries $id resolves its local refs
	//     against that $id (never against the envelope root);
	//   - an output schema without $id is given a deterministic per-action $id
	//     so its local refs still resolve inside its own resource;
	//   - same-named $defs across different actions never collide, because each
	//     action's wrapped envelope is compiled independently.
	// The FINAL envelope is what gets compiled and validated, never just the
	// original schema. Wrapping is local runtime materialization and does not
	// mutate the cached server representation or its opaque ETag.
	resourceID := extensionOutputResourceID(dataSchema, action)
	dataSchema["$id"] = resourceID
	envelope := map[string]any{
		"$schema":              "https://json-schema.org/draft/2020-12/schema",
		"type":                 "object",
		"additionalProperties": false,
		"required":             []any{"domain", "action", "data"},
		"properties": map[string]any{
			"domain": map[string]any{
				"type":  "string",
				"const": action.ExtensionName,
			},
			"action": map[string]any{
				"type":  "string",
				"const": action.Name,
			},
			// Reference the embedded data resource by its $id so the data
			// schema's internal $defs/$anchor refs stay in its own boundary.
			"data": map[string]any{"$ref": resourceID},
		},
		"$defs": map[string]any{
			"extensionOutput": dataSchema,
		},
	}
	return extensionSchemaJSON(envelope)
}

const extensionOutputResourceIDPrefix = "https://agentteam.invalid/exec/ext/"

// extensionOutputResourceID returns the resource identity for one Extension
// action's output data schema. An existing non-empty root $id is preserved
// verbatim (Draft 2020-12 uses it as the resource base URI); otherwise a
// deterministic per-action absolute $id is assigned so the schema becomes a
// self-contained resource with its local refs resolving inside it.
func extensionOutputResourceID(schema map[string]any, action ExtensionAction) string {
	if id, ok := schema["$id"].(string); ok && strings.TrimSpace(id) != "" {
		return id
	}
	return extensionOutputResourceIDPrefix + action.ExtensionName + "/" + action.Name + "/output"
}

func cloneRuntimeAction(action RuntimeAction) RuntimeAction {
	action.InputSchema = append(json.RawMessage(nil), action.InputSchema...)
	action.OutputSchema = append(json.RawMessage(nil), action.OutputSchema...)
	action.Example = append(json.RawMessage(nil), action.Example...)
	if action.extension != nil {
		copy := cloneExtensionAction(*action.extension)
		action.extension = &copy
	}
	return action
}
