package db

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"

	"github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen"
	"github.com/google/uuid"
	"github.com/jackc/pgx/v5"
)

// ErrTransitionInvalid is a sentinel returned by ValidateTransition when the
// from→to pair is not a declared lifecycle transition or a precondition fails.
// Callers test with errors.Is to distinguish semantic validation failures
// (→ HTTP 409) from infrastructure errors (→ HTTP 500).
var ErrTransitionInvalid = errors.New("invalid lifecycle transition")

// ValidateTransition enforces an entity type's lifecycle: fromState → toState
// must be a declared transition, and every precondition it lists must hold. A
// type with no lifecycle defined allows any state. A no-op (fromState ==
// toState) passes immediately.
//
// Shared by the HTTP PATCH path and the MCP set_entity_state tool so both
// surfaces apply identical lifecycle rules — previously only the HTTP path
// validated transitions, so an agent changing state via MCP could skip the
// graph's retire/deprecate guardrails entirely.
func ValidateTransition(ctx context.Context, tx pgx.Tx, entityID uuid.UUID, entityType, fromState, toState string) error {
	if toState == fromState {
		return nil
	}
	lc, err := sqlcgen.New(tx).GetLifecycleForType(ctx, entityType)
	if err != nil {
		if err == pgx.ErrNoRows {
			return nil // no lifecycle defined → any state allowed
		}
		return err
	}
	var transitions map[string]map[string]json.RawMessage
	if err := json.Unmarshal(lc.Transitions, &transitions); err != nil {
		return fmt.Errorf("parse lifecycle transitions: %w", err)
	}
	tos, ok := transitions[fromState]
	if !ok {
		return fmt.Errorf("%w: no transitions defined from %q", ErrTransitionInvalid, fromState)
	}
	trans, ok := tos[toState]
	if !ok {
		return fmt.Errorf("%w: %s → %s is not a declared lifecycle transition", ErrTransitionInvalid, fromState, toState)
	}
	var gate struct {
		Requires []string `json:"requires"`
	}
	if err := json.Unmarshal(trans, &gate); err == nil {
		for _, check := range gate.Requires {
			if err := checkPrecondition(ctx, tx, entityID, entityType, check); err != nil {
				return fmt.Errorf("%w: precondition %q not met: %w", ErrTransitionInvalid, check, err)
			}
		}
	}
	return nil
}

// checkPrecondition evaluates one mechanical precondition named by a lifecycle
// transition's `requires` list. Soft/operator-confirmed checks pass; unknown
// checks are skipped (operator intent overrides). Moved here from httpapi so
// both surfaces share one implementation.
func checkPrecondition(ctx context.Context, tx pgx.Tx, entityID uuid.UUID, entityType, check string) error {
	switch check {
	case "no-inbound-edges":
		var count int
		if err := tx.QueryRow(ctx,
			"SELECT count(*) FROM relationships WHERE target_id = $1 AND valid_to IS NULL", entityID).Scan(&count); err != nil {
			return err
		}
		if count > 0 {
			return fmt.Errorf("%d inbound relationship edges remaining", count)
		}
	case "backups-verified", "secrets-revoked", "ingress-dns-removed":
		attrs, err := fetchAttrs(ctx, tx, entityID)
		if err != nil {
			return err
		}
		want := map[string]string{
			"backups-verified":    "backups_verified",
			"secrets-revoked":     "secrets_revoked",
			"ingress-dns-removed": "ingress_dns_removed",
		}[check]
		if !attrTruthy(attrs, want) {
			return fmt.Errorf("%s not recorded in entity attributes", want)
		}
	case "age-key-enrolled-if-needed":
		if entityType == "workstation" {
			attrs, err := fetchAttrs(ctx, tx, entityID)
			if err != nil {
				return err
			}
			if !attrTruthy(attrs, "age_pubkey") {
				return fmt.Errorf("age key not enrolled (no age_pubkey in attributes)")
			}
		}
	case "mesh-joined-if-needed":
		if entityType == "workstation" {
			attrs, err := fetchAttrs(ctx, tx, entityID)
			if err != nil {
				return err
			}
			if !attrTruthy(attrs, "mesh_ip") {
				return fmt.Errorf("mesh not joined (no mesh_ip in attributes)")
			}
		}
	case "health-check-answering":
		st, err := sqlcgen.New(tx).GetEntityStatus(ctx, entityID)
		if err != nil || st.Health == "unknown" || st.Health == "down" {
			h := "unknown"
			if err == nil {
				h = st.Health
			}
			return fmt.Errorf("health check not answering (status: %s)", h)
		}
	case "doc-page-complete":
		var count int
		if err := tx.QueryRow(ctx, `
			SELECT count(*) FROM relationships r
			JOIN entities ke ON ke.id = r.source_id
			WHERE r.target_id = $1 AND r.valid_to IS NULL
			  AND r.type = 'documents' AND ke.type IN ('document','runbook','investigation')`,
			entityID).Scan(&count); err != nil {
			return err
		}
		if count == 0 {
			return fmt.Errorf("no documentation linked to entity")
		}
	case "inventory-entry", "ip-reserved", "storage-pool-chosen", "cancelled-note",
		"preflight-passed", "error-summary", "replacement-live-or-role-retired",
		"replacement-failed", "post-verify-passed", "recovery-verified", "written-off",
		"ingress-live-if-public", "doc-page-stub", "un-deprecate-note", "write-off-note":
		// Soft checks — always pass. Operator-confirmed via the transition
		// request itself, or not mechanically enforceable.
	default:
		// Unknown preconditions are skipped (operator intent overrides).
	}
	return nil
}

// fetchAttrs loads an entity's JSONB attributes column as a decoded map.
// Missing attributes decode to an empty map (every key absent).
func fetchAttrs(ctx context.Context, tx pgx.Tx, entityID uuid.UUID) (map[string]any, error) {
	var raw string
	if err := tx.QueryRow(ctx, "SELECT coalesce(attributes::text,'{}') FROM entities WHERE id = $1", entityID).Scan(&raw); err != nil {
		return nil, err
	}
	var attrs map[string]any
	if err := json.Unmarshal([]byte(raw), &attrs); err != nil {
		return nil, fmt.Errorf("decode entity attributes: %w", err)
	}
	if attrs == nil {
		attrs = map[string]any{}
	}
	return attrs, nil
}

// attrTruthy reports whether key is present in attrs with a meaningful value.
// It replaces substring matching on raw JSONB text: a previous strings.Contains
// check treated {"backups_verified": false} as satisfied (the key text was
// present) and bypassed the attributes GIN index. Booleans must be true;
// strings must be non-empty; nil/absent fail.
func attrTruthy(attrs map[string]any, key string) bool {
	v, ok := attrs[key]
	if !ok || v == nil {
		return false
	}
	switch t := v.(type) {
	case bool:
		return t
	case string:
		return t != ""
	default:
		return true // numbers, objects, arrays count as present
	}
}
