package extension

import (
	"context"
	"fmt"
	"math"
	"time"

	"github.com/tidbcloud/AgentTeam/internal/coredb"
	"github.com/tidbcloud/AgentTeam/internal/store/model"
	"github.com/tidbcloud/AgentTeam/internal/store/storeerr"
)

// lastErrorMaxLen bounds last_error on update rows; workers write only
// redacted/truncated text (never secrets).
const lastErrorMaxLen = 2048

// AgentExtensionCatalog is the durable, secret-free Extension Catalog state for
// one Agent in a workspace. State is the deterministic JSON of the projected entry
// list at the current version ("[]" once every authorization is revoked); it is
// the persisted reference for no-op change detection, not an identity that
// leaves the server. No Action/Schema/credential/endpoint is stored.
type AgentExtensionCatalog struct {
	WorkspaceID string
	AgentID     string
	Version     int64
	State       string
	Enabled     bool
	CreatedAt   time.Time
	UpdatedAt   time.Time
}

// CatalogChangeMutator decides whether a grant/revoke changes the effective
// command set. Existing catalog rows call it under the Agent's catalog row lock;
// missing sparse rows may call it once as a precheck before creating a v0 lock
// row, then recompute under that row if a write is needed. It returns the
// canonical catalog state computed for the new version (current+1), plus
// whether the Agent still has authorized commands.
// changed=false is a no-op: the version does not advance and no update event is
// written.
type CatalogChangeMutator func(currentVersion int64, storedState string) (newState string, enabled bool, changed bool, err error)

// ApplyExtensionCatalogChangeOptions tunes missing-row behavior for the few
// write paths that need a durable empty fence. The default remains sparse: a
// never-touched Agent with an empty effective Catalog gets no row.
type ApplyExtensionCatalogChangeOptions struct {
	// KeepEmptyRowOnNoop creates/keeps a v0 disabled row when the Agent has no
	// effective Catalog change. Explicit default-allow opt-outs use this as a
	// tombstone so a concurrent Workspace reconcile worker with a stale
	// "missing row" snapshot cannot later insert an enabled v1 Catalog.
	KeepEmptyRowOnNoop bool
}

// ApplyExtensionCatalogChange is the single atomic entry point for an Agent
// Extension Catalog mutation (Extension Platform stage 6). It verifies the Agent
// belongs to the workspace, locks an existing catalog row FOR UPDATE, and keeps
// never-enabled/no-op Agents sparse (no empty v0 row). When a missing Agent does
// need a real Catalog, it first creates a v0 lock row and then recomputes under
// that lock. Only when the mutator reports a real change does it advance the
// version by exactly one (expected-version CAS), rewrite the canonical state
// and enabled flag, and write a durable catalog update event, all in the
// caller's transaction. The (workspace_id, agent_id) row lock serializes
// concurrent mutations; the CAS, byte-exact identity compare, and
// durable-state validation are defense-in-depth (incl. PAD SPACE tamper and
// corrupt-row detection). The durable-state check runs on every existing-row
// call, so a corrupt catalog fails closed rather than being silently carried.
func (tx *TxStore) ApplyExtensionCatalogChange(
	ctx context.Context,
	workspaceID string,
	agentID string,
	now time.Time,
	mutate CatalogChangeMutator,
	options ApplyExtensionCatalogChangeOptions,
) (int64, error) {
	if tx == nil || tx.db == nil {
		return 0, fmt.Errorf("extension store transaction is not configured")
	}
	if mutate == nil {
		return 0, fmt.Errorf("catalog change mutator is required")
	}
	if workspaceID == "" || agentID == "" {
		return 0, fmt.Errorf("workspaceId and agentId are required")
	}
	if now.IsZero() {
		now = time.Now().UTC()
	}
	if err := assertAgentInWorkspace(ctx, tx.db, workspaceID, agentID); err != nil {
		return 0, err
	}
	catalog, found, err := tx.loadAgentExtensionCatalogRowForUpdate(ctx, workspaceID, agentID)
	if err != nil {
		return 0, err
	}
	if !found {
		_, _, changed, err := mutate(0, "")
		if err != nil {
			return 0, err
		}
		if !changed && !options.KeepEmptyRowOnNoop {
			return 0, nil
		}
		catalog, found, err = tx.ensureAgentExtensionCatalogRowForUpdate(ctx, workspaceID, agentID, now)
		if err != nil {
			return 0, err
		}
		if !found {
			return 0, fmt.Errorf("lock newly-created agent extension catalog: row not found")
		}
		if !changed && catalog.Version == 0 {
			return 0, nil
		}
	}
	current := catalog.Version
	if current == math.MaxInt64 {
		return 0, storeerr.Forbidden("agent extension catalog version overflow")
	}
	newState, newEnabled, changed, err := mutate(current, catalog.State)
	if err != nil {
		return 0, err
	}
	if !changed {
		return current, nil
	}
	if err := validateCatalogState(current+1, newState, newEnabled); err != nil {
		return 0, err
	}
	newVersion := current + 1
	result, err := tx.db.Exec(ctx, `
UPDATE agent_extension_catalogs
SET version = version + 1, catalog_state = $4,
    enabled = $5, updated_at = $6
WHERE workspace_id = $1 AND agent_id = $2 AND version = $3`,
		workspaceID, agentID, current, newState, newEnabled, now)
	if err != nil {
		return 0, fmt.Errorf("advance agent extension catalog: %w", err)
	}
	if result.RowsAffected() == 0 {
		return 0, storeerr.Conflict("agent extension catalog version changed concurrently")
	}
	if _, err := tx.db.Exec(ctx, `
INSERT INTO agent_extension_catalog_updates (workspace_id, agent_id, version, status, attempts, created_at, updated_at)
VALUES ($1, $2, $3, 'pending', 0, $4, $4)`, workspaceID, agentID, newVersion, now); err != nil {
		return 0, fmt.Errorf("insert agent extension catalog update: %w", err)
	}
	return newVersion, nil
}

func (tx *TxStore) ensureAgentExtensionCatalogRowForUpdate(ctx context.Context, workspaceID string, agentID string, now time.Time) (AgentExtensionCatalog, bool, error) {
	if _, err := tx.db.Exec(ctx, `
INSERT INTO agent_extension_catalogs (
    workspace_id, agent_id, version, catalog_state,
    enabled, created_at, updated_at
)
VALUES ($1, $2, 0, '', 0, $3, $3)
ON DUPLICATE KEY UPDATE workspace_id = workspace_id`, workspaceID, agentID, now); err != nil {
		return AgentExtensionCatalog{}, false, fmt.Errorf("ensure agent extension catalog: %w", err)
	}
	return tx.loadAgentExtensionCatalogRowForUpdate(ctx, workspaceID, agentID)
}

func (tx *TxStore) loadAgentExtensionCatalogRowForUpdate(ctx context.Context, workspaceID string, agentID string) (AgentExtensionCatalog, bool, error) {
	row := tx.db.QueryRow(ctx, `
SELECT workspace_id, agent_id, version, catalog_state,
       enabled, created_at, updated_at
FROM agent_extension_catalogs
WHERE workspace_id = $1 AND agent_id = $2
FOR UPDATE`, workspaceID, agentID)
	catalog, err := scanAgentExtensionCatalog(row)
	if err != nil {
		if coredb.IsNotFound(err) {
			return AgentExtensionCatalog{}, false, nil
		}
		return AgentExtensionCatalog{}, false, fmt.Errorf("lock agent extension catalog: %w", err)
	}
	if catalog.WorkspaceID != workspaceID || catalog.AgentID != agentID {
		return AgentExtensionCatalog{}, false, storeerr.Forbidden("agent extension catalog identity does not match the request")
	}
	if err := validateCatalogState(catalog.Version, catalog.State, catalog.Enabled); err != nil {
		return AgentExtensionCatalog{}, false, err
	}
	return catalog, true, nil
}

// assertAgentInWorkspace verifies the Agent belongs to the workspace, reading the
// RAW id/workspace_id and comparing byte-exact (the agents FK is not
// workspace-scoped, and PAD SPACE can match a trailing-space id in the WHERE).
func assertAgentInWorkspace(ctx context.Context, db coredb.QueryExecer, workspaceID, agentID string) error {
	var rowAgent, rowWorkspace string
	err := db.QueryRow(ctx, `SELECT id, workspace_id FROM agents WHERE id = $1 AND workspace_id = $2 LIMIT 1`, agentID, workspaceID).Scan(&rowAgent, &rowWorkspace)
	if err != nil {
		if coredb.IsNotFound(err) {
			return storeerr.Forbidden("agent does not belong to this workspace")
		}
		return fmt.Errorf("verify agent workspace: %w", err)
	}
	if rowAgent != agentID || rowWorkspace != workspaceID {
		return storeerr.Forbidden("agent identity does not match the request")
	}
	return nil
}

// validateCatalogState enforces the durable catalog-state contract (fail-closed
// on corrupt state). A never-enabled Agent is version 0 with an empty state and
// enabled=false; any version > 0 carries the deterministic JSON entry list ("[]" is
// the valid revoked-empty state). This runs on every Apply (incl. no-op) and on
// Load, so a tampered/corrupt row surfaces instead of being silently carried.
func validateCatalogState(version int64, state string, enabled bool) error {
	if version < 0 {
		return storeerr.Forbidden("agent extension catalog version must be non-negative")
	}
	if version == 0 {
		if state != "" || enabled {
			return storeerr.Forbidden("agent extension catalog v0 must be empty and disabled")
		}
		return nil
	}
	if state == "" ||
		state[0] != '[' ||
		state[len(state)-1] != ']' ||
		!model.IsValidJSON([]byte(state)) {
		return storeerr.Forbidden("agent extension catalog has a corrupt state")
	}
	if enabled != (state != "[]") {
		return storeerr.Forbidden("agent extension catalog enabled flag contradicts its state")
	}
	return nil
}

// ReadAgentExtensionCatalog reads one Agent's validated durable catalog state
// without taking a write lock. Callers that also read the live authorization
// projection must do both reads in one transaction so the database snapshot
// keeps them consistent. A missing row is not an error.
func (tx *TxStore) ReadAgentExtensionCatalog(
	ctx context.Context,
	workspaceID string,
	agentID string,
) (AgentExtensionCatalog, bool, error) {
	if tx == nil || tx.db == nil {
		return AgentExtensionCatalog{}, false,
			fmt.Errorf("extension store transaction is not configured")
	}
	if workspaceID == "" || agentID == "" {
		return AgentExtensionCatalog{}, false,
			fmt.Errorf("workspaceId and agentId are required")
	}
	if err := assertAgentInWorkspace(
		ctx,
		tx.db,
		workspaceID,
		agentID,
	); err != nil {
		return AgentExtensionCatalog{}, false, err
	}
	row := tx.db.QueryRow(ctx, `
SELECT workspace_id, agent_id, version, catalog_state,
       enabled, created_at, updated_at
FROM agent_extension_catalogs
WHERE workspace_id = $1 AND agent_id = $2`,
		workspaceID,
		agentID,
	)
	catalog, err := scanAgentExtensionCatalog(row)
	if err != nil {
		if coredb.IsNotFound(err) {
			return AgentExtensionCatalog{}, false, nil
		}
		return AgentExtensionCatalog{}, false,
			fmt.Errorf("read agent extension catalog: %w", err)
	}
	if catalog.WorkspaceID != workspaceID || catalog.AgentID != agentID {
		return AgentExtensionCatalog{}, false,
			storeerr.Forbidden(
				"agent extension catalog identity does not match the request",
			)
	}
	if err := validateCatalogState(
		catalog.Version,
		catalog.State,
		catalog.Enabled,
	); err != nil {
		return AgentExtensionCatalog{}, false, err
	}
	return catalog, true, nil
}

// LoadAgentExtensionCatalogForUpdate loads and locks one Agent's Extension
// Catalog row for the caller's transaction (used by the delivery worker and
// tests), validates the durable state, and byte-exact-compares the loaded
// identity to the request (PAD SPACE defense). A missing row is not an error.
func (tx *TxStore) LoadAgentExtensionCatalogForUpdate(ctx context.Context, workspaceID, agentID string) (AgentExtensionCatalog, bool, error) {
	if tx == nil || tx.db == nil {
		return AgentExtensionCatalog{}, false, fmt.Errorf("extension store transaction is not configured")
	}
	if workspaceID == "" || agentID == "" {
		return AgentExtensionCatalog{}, false, fmt.Errorf("workspaceId and agentId are required")
	}
	// Ownership fence (worker depends on this): the Agent must belong to this
	// workspace. The two independent FKs allow a (workspace B, agent A) row where
	// agent A belongs elsewhere; assertAgentInWorkspace reads the RAW id/workspace
	// and byte-exact compares.
	if err := assertAgentInWorkspace(ctx, tx.db, workspaceID, agentID); err != nil {
		return AgentExtensionCatalog{}, false, err
	}
	row := tx.db.QueryRow(ctx, `
SELECT workspace_id, agent_id, version, catalog_state,
       enabled, created_at, updated_at
FROM agent_extension_catalogs
WHERE workspace_id = $1 AND agent_id = $2
FOR UPDATE`, workspaceID, agentID)
	catalog, err := scanAgentExtensionCatalog(row)
	if err != nil {
		if coredb.IsNotFound(err) {
			return AgentExtensionCatalog{}, false, nil
		}
		return AgentExtensionCatalog{}, false, fmt.Errorf("load agent extension catalog: %w", err)
	}
	if catalog.WorkspaceID != workspaceID || catalog.AgentID != agentID {
		return AgentExtensionCatalog{}, false, storeerr.Forbidden("agent extension catalog identity does not match the request")
	}
	if err := validateCatalogState(catalog.Version, catalog.State, catalog.Enabled); err != nil {
		return AgentExtensionCatalog{}, false, err
	}
	return catalog, true, nil
}

func scanAgentExtensionCatalog(row coredb.Row) (AgentExtensionCatalog, error) {
	var catalog AgentExtensionCatalog
	if err := row.Scan(
		&catalog.WorkspaceID,
		&catalog.AgentID,
		&catalog.Version,
		&catalog.State,
		&catalog.Enabled,
		&catalog.CreatedAt,
		&catalog.UpdatedAt,
	); err != nil {
		return AgentExtensionCatalog{}, err
	}
	// Identity fields are preserved RAW (never TrimSpace): TiDB/MySQL utf8mb4_bin
	// applies PAD SPACE, so the caller must compare these byte-exact against the
	// request to detect a trailing-space tamper rather than rely on SQL equality.
	return catalog, nil
}
