package httpapi

import (
	"context"
	"encoding/json"
	"fmt"
	"log/slog"
	"time"

	"github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen"
	"github.com/dtoro/oikos/internal/core/domain"
	"github.com/dtoro/oikos/internal/httpapi/gen"
	"github.com/dtoro/oikos/internal/observability"
	"github.com/dtoro/oikos/internal/safego"
	"github.com/google/uuid"
	"github.com/jackc/pgx/v5"
)

// ─── Approvals ─────────────────────────────────────────────────────────

func (s *Server) ListApprovals(ctx context.Context, req gen.ListApprovalsRequestObject) (gen.ListApprovalsResponseObject, error) {
	limit := clampLimit(req.Params.Limit)
	var status *string
	if req.Params.Status != nil {
		s := string(*req.Params.Status)
		status = &s
	}
	var kind *string
	if req.Params.Kind != nil {
		k := string(*req.Params.Kind)
		kind = &k
	}
	rows, err := s.pool.Query(ctx, `
		SELECT a.entity_id, a.action, a.risk_class, a.kind, a.payload,
		       a.status, a.expires_at, a.decided_at, a.decided_by::text,
		       a.created_at, e.slug
		FROM approvals a
		JOIN entities e ON e.id = COALESCE(a.subject_entity_id, a.entity_id)
		WHERE ($1::text IS NULL OR a.status = $1)
		  AND ($2::text IS NULL OR a.kind = $2)
		  AND ($3::text IS NULL OR e.slug > $3)
		ORDER BY e.slug
		LIMIT $4`,
		status, kind, req.Params.Cursor, limit+1)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	items := []gen.Approval{}
	for rows.Next() {
		var a gen.Approval
		var payloadBytes []byte
		var decidedBy *string
		if err := rows.Scan(&a.Id, &a.Action, &a.RiskClass, &a.Kind, &payloadBytes,
			&a.Status, &a.ExpiresAt, &a.DecidedAt, &decidedBy,
			&a.CreatedAt, &a.Slug); err != nil {
			return nil, err
		}
		a.DecidedBy = decidedBy
		var payload map[string]any
		if len(payloadBytes) > 0 && json.Unmarshal(payloadBytes, &payload) == nil {
			a.Payload = &payload
		}
		items = append(items, a)
	}
	if rows.Err() != nil {
		return nil, rows.Err()
	}

	var next *string
	if len(items) > limit {
		items = items[:limit]
		next = &items[len(items)-1].Slug
	}
	if items == nil {
		items = []gen.Approval{}
	}
	return gen.ListApprovals200JSONResponse{Items: items, NextCursor: next}, nil
}

func (s *Server) DecideApproval(ctx context.Context, req gen.DecideApprovalRequestObject) (gen.DecideApprovalResponseObject, error) {
	if req.Body == nil {
		return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
	}

	id, err := s.resolveEntityID(ctx, req.Id)
	if err != nil {
		return nil, err
	}

	actorType, actor := actorInfo(ctx)

	tx, err := s.pool.Begin(ctx)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback(ctx)

	q := sqlcgen.New(tx)

	// Verify HMAC token if provided (single-use, S5).
	if req.Body.Token != nil && *req.Body.Token != "" {
		var tokenHash *string
		var apprStatus string
		var expiresAt time.Time
		err := tx.QueryRow(ctx,
			"SELECT token_hash, status, expires_at FROM approvals WHERE entity_id = $1",
			id).Scan(&tokenHash, &apprStatus, &expiresAt)
		if err != nil || tokenHash == nil {
			return nil, fmt.Errorf("%w: approval not found", domain.ErrNotFound)
		}
		if apprStatus != "pending" {
			return nil, fmt.Errorf("%w: approval already decided", domain.ErrInvalidTransition)
		}
		if expiresAt.Before(time.Now()) {
			return nil, fmt.Errorf("%w: approval token expired", domain.ErrInvalidTransition)
		}
		if *tokenHash != hashToken(*req.Body.Token) {
			return nil, fmt.Errorf("%w: invalid approval token", domain.ErrInvalidInput)
		}
	}

	// Map decision to status.
	var status string
	switch req.Body.Decision {
	case gen.Approve:
		status = "approved"
	case gen.Deny:
		status = "denied"
	case gen.Revoke:
		status = "revoked"
	default:
		return nil, fmt.Errorf("%w: invalid decision %q", domain.ErrInvalidInput, req.Body.Decision)
	}

	if err := q.UpdateApprovalStatus(ctx, sqlcgen.UpdateApprovalStatusParams{
		EntityID: id,
		Status:   status,
	}); err != nil {
		if err == pgx.ErrNoRows {
			return nil, fmt.Errorf("%w: approval %s not found or already decided", domain.ErrNotFound, req.Id)
		}
		return nil, err
	}

	// Re-read approval.
	app, err := q.GetApprovalByID(ctx, id)
	if err != nil {
		return nil, err
	}

	approval := approvalToGen(app)

	if auditErr := observability.Audit(ctx, q, actorType, actor, "decide",
		&id, "POST", "/api/v1/approvals/"+req.Id+"/decision", "",
		nil,
		map[string]any{"decision": status}); auditErr != nil {
		return nil, auditErr
	}

	// Emit for SSE fan-out (in-tx; NOTIFY fires post-commit).
	if evErr := observability.Event(ctx, q, "approval.decided", &id, "info", "api", "",
		map[string]any{"decision": status, "actor": actor}); evErr != nil {
		return nil, evErr
	}

	// On approve: execute the linked gated command.
	if status == "approved" {
		var execID, targetID uuid.UUID
		var actionStr, targetSlug, riskClass string
		err := tx.QueryRow(ctx, `
			SELECT e.entity_id, e.target_entity_id, e.action, e.risk_class
			FROM executions e
			WHERE e.approval_id = $1 AND e.status = 'pending_approval'
			LIMIT 1`, id).Scan(&execID, &targetID, &actionStr, &riskClass)
		if err == nil {
			// Resolve target entity slug from targetID.
			_ = tx.QueryRow(ctx, "SELECT slug FROM entities WHERE id = $1", targetID).Scan(&targetSlug)

			safego.Go("httpapi:executeApprovedAction", func() {
				s.executeApprovedAction(context.Background(), s.pool, execID, targetSlug, actionStr)
			})
			// Status only — risk_class was set correctly at request time
			// (e.g. by policy.ClassifyCommand for `run`); overwriting it to
			// a hardcoded 'config_mutation' here corrupted the audit ledger
			// for every other risk class, including destructive.
			_, _ = tx.Exec(ctx, `UPDATE executions SET status = 'approved' WHERE entity_id = $1`, execID)

			// Approving a plan step — by ANY route (this endpoint backs both
			// the chat Approve button and chat-assent) — opens/extends the
			// agent's assent window. This is the scope gate the Nomos
			// auto-continuation worker checks: with the window open, the
			// finished execution's result is fed back to the agent so it runs
			// the plan to completion. Without opening it here, approving via
			// the button (instead of typing "go ahead") would silently not
			// auto-continue.
			var agentID *uuid.UUID
			if qerr := tx.QueryRow(ctx, "SELECT agent_id FROM executions WHERE entity_id = $1", execID).Scan(&agentID); qerr == nil && agentID != nil {
				expires := time.Now().Add(30 * time.Minute).UTC().Format(time.RFC3339)
				_, _ = tx.Exec(ctx, `INSERT INTO autonomy_settings (key, value) VALUES ($1, $2)
					ON CONFLICT (key) DO UPDATE SET value = $2`, "assent_window.agent:"+agentID.String(), expires)

				// Approving a DESTRUCTIVE step via the button is exactly as
				// explicit as a typed "I confirm" — the operator affirmatively
				// clicked Approve on a card that said DESTRUCTIVE. Open the
				// same short, target-scoped destructive window chat-assent's
				// typed-confirm path opens, for parity: a multi-step
				// destructive recovery (stop, then destroy) shouldn't need a
				// fresh confirmation per click any more than it needs one per
				// typed phrase.
				if riskClass == "destructive" && targetSlug != "" {
					dExpires := time.Now().Add(15 * time.Minute).UTC().Format(time.RFC3339)
					_, _ = tx.Exec(ctx, `INSERT INTO autonomy_settings (key, value) VALUES ($1, $2)
						ON CONFLICT (key) DO UPDATE SET value = $2`,
						"destructive_window.agent:"+agentID.String()+".target:"+targetSlug, dExpires)
				}
			}

			slog.Info("httpapi: approved execution queued",
				"execution_id", execID, "target", targetSlug, "action", actionStr)
		} else {
			slog.Warn("httpapi: no pending execution found for approval", "approval_id", id, "error", err)
		}
	} else {
		// Denied/revoked: reflect it on the linked execution too. Previously
		// only the approvals row changed, so the execution stayed
		// 'pending_approval' forever — any UI/poller reading execution
		// status (not approval status) never saw the decision.
		_, _ = tx.Exec(ctx, `UPDATE executions SET status = $2, completed_at = now() WHERE approval_id = $1 AND status = 'pending_approval'`, id, status)
	}

	// If this execution belongs to a nomos session, flip it out of
	// awaiting_input — the counterpart to classifyAndGate flipping it IN
	// the moment the approval was created (internal/mcp/server.go's
	// markSessionAwaitingApproval). Runs for all three decisions (approve/
	// deny/revoke): each one is an operator answer to "what do I do about
	// this?", same as answerQuestion's unconditional resume-to-executing
	// (cmd/nomos/store.go) for a session_questions answer.
	var awaitingSessionID string
	_ = tx.QueryRow(ctx, `
		SELECT pe.session_id FROM nomos_plan_executions pe
		JOIN executions ex ON ex.entity_id = pe.execution_id
		WHERE ex.approval_id = $1
		LIMIT 1`, id).Scan(&awaitingSessionID)
	if awaitingSessionID != "" {
		if rtag, rerr := tx.Exec(ctx, `
			UPDATE agent_sessions SET status = 'executing', last_active_at = now()
			WHERE id = $1 AND status = 'awaiting_input'`, awaitingSessionID); rerr == nil && rtag.RowsAffected() > 0 {
			var taskEntID *uuid.UUID
			var e uuid.UUID
			if qerr := tx.QueryRow(ctx, `SELECT entity_id FROM agent_sessions WHERE id = $1`, awaitingSessionID).Scan(&e); qerr == nil && e != uuid.Nil {
				taskEntID = &e
			}
			_ = observability.Event(ctx, q, "task.status", taskEntID, "info", "api", awaitingSessionID,
				map[string]any{"status": "executing", "reason": "approval_decided", "decision": status})
		}
	}

	if err := tx.Commit(ctx); err != nil {
		return nil, err
	}

	return gen.DecideApproval200JSONResponse(approval), nil
}

func approvalToGen(a sqlcgen.Approval) gen.Approval {
	app := gen.Approval{
		Id:        a.EntityID,
		Action:    a.Action,
		RiskClass: a.RiskClass,
		Kind:      gen.ApprovalKind(a.Kind),
		Status:    gen.ApprovalStatus(a.Status),
		ExpiresAt: a.ExpiresAt,
		DecidedAt: a.DecidedAt,
		CreatedAt: a.CreatedAt,
	}
	if a.DecidedBy != nil {
		s := a.DecidedBy.String()
		app.DecidedBy = &s
	}
	var payload map[string]any
	if len(a.Payload) > 0 && json.Unmarshal(a.Payload, &payload) == nil && len(payload) > 0 {
		app.Payload = &payload
	}
	return app
}
