// Package sandbox — db_module.go
//
// v0.29.2 CS1: Structured db module for Starlark extensions.
//
// Permissions:
//   db.read  → db.query(), db.view(), db.list_tables()
//   db.write → all of the above + db.insert(), db.update(), db.delete()
//
// Starlark API:
//
//	rows  = db.query("logs", filters={"user_id": "abc"}, order="created_at", limit=50)
//	row   = db.insert("logs", {"message": "hello"})
//	ok    = db.update("logs", row_id, {"message": "updated"})
//	ok    = db.delete("logs", row_id)
//	names = db.list_tables()
//	rows  = db.view("users", filters={"id": "abc"})
//
// All table access is scoped to ext_{package_id}_{table_name}.
// Platform views (ext_view_users, ext_view_channels) are accessible
// via db.view() and are read-only regardless of permission level.
//
// Queries are parameterized — no raw SQL is ever accepted from scripts.
// Dialect differences (placeholder style) are handled internally.
package sandbox

import (
	"context"
	"crypto/rand"
	"database/sql"
	"encoding/hex"
	"fmt"
	"strings"

	"go.starlark.net/starlark"
	"go.starlark.net/starlarkstruct"
)

// DBModuleConfig holds configuration for a db module instance.
type DBModuleConfig struct {
	PackageID  string
	CanWrite   bool // true when db.write permission is granted
	DB         *sql.DB
	IsPostgres bool
}

// allowedViews is the set of platform views extensions may query via db.view().
var allowedViews = map[string]bool{
	"users":    true,
	"channels": true,
}

// BuildDBModule creates the "db" starlark module for an extension.
// The module enforces namespace isolation — all physical table names are
// prefixed with ext_{packageID}_. Write operations are guarded by CanWrite.
func BuildDBModule(ctx context.Context, cfg DBModuleConfig) *starlarkstruct.Module {
	fns := starlark.StringDict{
		"query":       starlark.NewBuiltin("db.query", dbQuery(ctx, cfg)),
		"view":        starlark.NewBuiltin("db.view", dbView(ctx, cfg)),
		"list_tables": starlark.NewBuiltin("db.list_tables", dbListTables(ctx, cfg)),
	}

	if cfg.CanWrite {
		fns["insert"] = starlark.NewBuiltin("db.insert", dbInsert(ctx, cfg))
		fns["update"] = starlark.NewBuiltin("db.update", dbUpdate(ctx, cfg))
		fns["delete"] = starlark.NewBuiltin("db.delete", dbDelete(ctx, cfg))
	}

	return MakeModule("db", fns)
}

// ── Helpers ──────────────────────────────────

// physicalTable returns the fully-qualified table name for an extension table.
func (cfg DBModuleConfig) physicalTable(logicalName string) (string, error) {
	if logicalName == "" {
		return "", fmt.Errorf("db: table name must not be empty")
	}
	if strings.ContainsAny(logicalName, " \t\n\"';-") {
		return "", fmt.Errorf("db: invalid table name %q", logicalName)
	}
	// Sanitize package ID for use in identifiers (replace - with _)
	pkgSlug := strings.ReplaceAll(cfg.PackageID, "-", "_")
	return fmt.Sprintf("ext_%s_%s", pkgSlug, logicalName), nil
}

// ph returns the parameter placeholder for the nth argument (1-based).
func (cfg DBModuleConfig) ph(n int) string {
	if cfg.IsPostgres {
		return fmt.Sprintf("$%d", n)
	}
	return "?"
}

// generateID produces a random hex ID for new rows.
func generateID() string {
	b := make([]byte, 16)
	_, _ = rand.Read(b)
	return hex.EncodeToString(b)
}

// starlarkFiltersToSQL converts a Starlark dict of {col: val} into a
// WHERE clause and argument slice. Returns empty string if filters is None.
func (cfg DBModuleConfig) starlarkFiltersToSQL(filters starlark.Value, startIdx int) (string, []any, error) {
	if filters == starlark.None || filters == nil {
		return "", nil, nil
	}
	d, ok := filters.(*starlark.Dict)
	if !ok {
		return "", nil, fmt.Errorf("db: filters must be a dict, got %s", filters.Type())
	}
	if d.Len() == 0 {
		return "", nil, nil
	}

	var parts []string
	var args []any
	idx := startIdx

	for _, item := range d.Items() {
		col, ok := item[0].(starlark.String)
		if !ok {
			return "", nil, fmt.Errorf("db: filter key must be a string, got %s", item[0].Type())
		}
		colStr := string(col)
		if strings.ContainsAny(colStr, " \t\n\"';-") {
			return "", nil, fmt.Errorf("db: invalid column name %q", colStr)
		}

		val, err := starlarkToGoValue(item[1])
		if err != nil {
			return "", nil, fmt.Errorf("db: filter value for %q: %w", colStr, err)
		}

		parts = append(parts, fmt.Sprintf("%s = %s", colStr, cfg.ph(idx)))
		args = append(args, val)
		idx++
	}

	return "WHERE " + strings.Join(parts, " AND "), args, nil
}

// starlarkToGoValue converts a Starlark value to a Go value suitable for SQL.
func starlarkToGoValue(v starlark.Value) (any, error) {
	switch val := v.(type) {
	case starlark.String:
		return string(val), nil
	case starlark.Int:
		i, ok := val.Int64()
		if !ok {
			return nil, fmt.Errorf("integer overflow")
		}
		return i, nil
	case starlark.Float:
		return float64(val), nil
	case starlark.Bool:
		return bool(val), nil
	case starlark.NoneType:
		return nil, nil
	default:
		return nil, fmt.Errorf("unsupported type %s", v.Type())
	}
}

// rowsToStarlark converts sql.Rows to a Starlark list of dicts.
func rowsToStarlark(rows *sql.Rows) (*starlark.List, error) {
	cols, err := rows.Columns()
	if err != nil {
		return nil, err
	}

	var result []starlark.Value
	for rows.Next() {
		vals := make([]any, len(cols))
		ptrs := make([]any, len(cols))
		for i := range vals {
			ptrs[i] = &vals[i]
		}
		if err := rows.Scan(ptrs...); err != nil {
			return nil, err
		}

		d := starlark.NewDict(len(cols))
		for i, col := range cols {
			sv, err := goToStarlark(vals[i])
			if err != nil {
				return nil, fmt.Errorf("column %q: %w", col, err)
			}
			if err := d.SetKey(starlark.String(col), sv); err != nil {
				return nil, err
			}
		}
		result = append(result, d)
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	return starlark.NewList(result), nil
}

// goToStarlark converts a Go value (from sql.Scan) to a Starlark value.
func goToStarlark(v any) (starlark.Value, error) {
	if v == nil {
		return starlark.None, nil
	}
	switch val := v.(type) {
	case string:
		return starlark.String(val), nil
	case []byte:
		return starlark.String(string(val)), nil
	case int64:
		return starlark.MakeInt64(val), nil
	case float64:
		return starlark.Float(val), nil
	case bool:
		return starlark.Bool(val), nil
	default:
		// Fallback: stringify
		return starlark.String(fmt.Sprintf("%v", val)), nil
	}
}

// ── Builtins ─────────────────────────────────

// dbQuery implements db.query(table, filters=None, order=None, limit=100).
func dbQuery(ctx context.Context, cfg DBModuleConfig) func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) {
	return func(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
		var table string
		var filters starlark.Value = starlark.None
		var order starlark.Value = starlark.None
		var limit starlark.Int = starlark.MakeInt(100)

		if err := starlark.UnpackArgs(b.Name(), args, kwargs,
			"table", &table,
			"filters?", &filters,
			"order?", &order,
			"limit?", &limit,
		); err != nil {
			return nil, err
		}

		physTable, err := cfg.physicalTable(table)
		if err != nil {
			return nil, err
		}

		whereClause, whereArgs, err := cfg.starlarkFiltersToSQL(filters, 1)
		if err != nil {
			return nil, err
		}

		lim, ok := limit.Int64()
		if !ok || lim < 1 || lim > 1000 {
			lim = 100
		}

		query := fmt.Sprintf("SELECT * FROM %s", physTable)
		if whereClause != "" {
			query += " " + whereClause
		}

		if order != starlark.None {
			col, ok := order.(starlark.String)
			if !ok {
				return nil, fmt.Errorf("db.query: order must be a string column name")
			}
			colStr := string(col)
			dir := "ASC"
			if strings.HasPrefix(colStr, "-") {
				colStr = colStr[1:]
				dir = "DESC"
			}
			if strings.ContainsAny(colStr, " \t\n\"';") {
				return nil, fmt.Errorf("db.query: invalid order column %q", colStr)
			}
			query += fmt.Sprintf(" ORDER BY %s %s", colStr, dir)
		}

		limitPH := cfg.ph(len(whereArgs) + 1)
		query += " LIMIT " + limitPH
		whereArgs = append(whereArgs, lim)

		rows, err := cfg.DB.QueryContext(ctx, query, whereArgs...)
		if err != nil {
			return nil, fmt.Errorf("db.query: %w", err)
		}
		defer rows.Close()

		return rowsToStarlark(rows)
	}
}

// dbView implements db.view(view_name, filters=None, limit=100).
// Queries ext_view_{view_name} — only allowedViews are permitted.
func dbView(ctx context.Context, cfg DBModuleConfig) func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) {
	return func(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
		var viewName string
		var filters starlark.Value = starlark.None
		var limit starlark.Int = starlark.MakeInt(100)

		if err := starlark.UnpackArgs(b.Name(), args, kwargs,
			"view_name", &viewName,
			"filters?", &filters,
			"limit?", &limit,
		); err != nil {
			return nil, err
		}

		if !allowedViews[viewName] {
			return nil, fmt.Errorf("db.view: unknown view %q (allowed: users, channels)", viewName)
		}

		physView := "ext_view_" + viewName

		whereClause, whereArgs, err := cfg.starlarkFiltersToSQL(filters, 1)
		if err != nil {
			return nil, err
		}

		lim, ok := limit.Int64()
		if !ok || lim < 1 || lim > 1000 {
			lim = 100
		}

		query := fmt.Sprintf("SELECT * FROM %s", physView)
		if whereClause != "" {
			query += " " + whereClause
		}
		limitPH := cfg.ph(len(whereArgs) + 1)
		query += " LIMIT " + limitPH
		whereArgs = append(whereArgs, lim)

		rows, err := cfg.DB.QueryContext(ctx, query, whereArgs...)
		if err != nil {
			return nil, fmt.Errorf("db.view: %w", err)
		}
		defer rows.Close()

		return rowsToStarlark(rows)
	}
}

// dbListTables implements db.list_tables() → list of logical table names.
func dbListTables(ctx context.Context, cfg DBModuleConfig) func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) {
	return func(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
		if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 0); err != nil {
			return nil, err
		}

		prefix := "ext_" + strings.ReplaceAll(cfg.PackageID, "-", "_") + "_"
		var query string
		var queryArgs []any

		if cfg.IsPostgres {
			query = `SELECT table_name FROM information_schema.tables
			          WHERE table_schema = 'public' AND table_name LIKE $1
			          ORDER BY table_name`
			queryArgs = []any{prefix + "%"}
		} else {
			query = `SELECT name FROM sqlite_master
			          WHERE type = 'table' AND name LIKE ?
			          ORDER BY name`
			queryArgs = []any{prefix + "%"}
		}

		rows, err := cfg.DB.QueryContext(ctx, query, queryArgs...)
		if err != nil {
			return nil, fmt.Errorf("db.list_tables: %w", err)
		}
		defer rows.Close()

		var names []starlark.Value
		for rows.Next() {
			var physName string
			if err := rows.Scan(&physName); err != nil {
				return nil, err
			}
			// Strip prefix to return logical name
			logical := strings.TrimPrefix(physName, prefix)
			names = append(names, starlark.String(logical))
		}
		if names == nil {
			names = []starlark.Value{}
		}
		return starlark.NewList(names), nil
	}
}

// dbInsert implements db.insert(table, row_dict) → inserted row dict with id.
func dbInsert(ctx context.Context, cfg DBModuleConfig) func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) {
	return func(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
		var table string
		var rowVal starlark.Value

		if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 2, &table, &rowVal); err != nil {
			return nil, err
		}

		row, ok := rowVal.(*starlark.Dict)
		if !ok {
			return nil, fmt.Errorf("db.insert: row must be a dict, got %s", rowVal.Type())
		}

		physTable, err := cfg.physicalTable(table)
		if err != nil {
			return nil, err
		}

		id := generateID()
		cols := []string{"id"}
		placeholders := []string{cfg.ph(1)}
		vals := []any{id}
		idx := 2

		for _, item := range row.Items() {
			col, ok := item[0].(starlark.String)
			if !ok {
				return nil, fmt.Errorf("db.insert: column key must be a string")
			}
			colStr := string(col)
			if colStr == "id" || colStr == "created_at" {
				continue // auto-managed
			}
			if strings.ContainsAny(colStr, " \t\n\"';-") {
				return nil, fmt.Errorf("db.insert: invalid column name %q", colStr)
			}
			val, err := starlarkToGoValue(item[1])
			if err != nil {
				return nil, fmt.Errorf("db.insert: column %q: %w", colStr, err)
			}
			cols = append(cols, colStr)
			placeholders = append(placeholders, cfg.ph(idx))
			vals = append(vals, val)
			idx++
		}

		query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)",
			physTable,
			strings.Join(cols, ", "),
			strings.Join(placeholders, ", "),
		)

		if _, err := cfg.DB.ExecContext(ctx, query, vals...); err != nil {
			return nil, fmt.Errorf("db.insert: %w", err)
		}

		// Return the inserted row (re-query by id)
		selectQ := fmt.Sprintf("SELECT * FROM %s WHERE id = %s", physTable, cfg.ph(1))
		rows, err := cfg.DB.QueryContext(ctx, selectQ, id)
		if err != nil {
			return nil, fmt.Errorf("db.insert: re-query failed: %w", err)
		}
		defer rows.Close()

		list, err := rowsToStarlark(rows)
		if err != nil {
			return nil, err
		}
		if list.Len() == 0 {
			return starlark.None, nil
		}
		return list.Index(0), nil
	}
}

// dbUpdate implements db.update(table, id, partial_dict) → True.
func dbUpdate(ctx context.Context, cfg DBModuleConfig) func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) {
	return func(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
		var table, rowID string
		var patchVal starlark.Value

		if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 3, &table, &rowID, &patchVal); err != nil {
			return nil, err
		}

		patch, ok := patchVal.(*starlark.Dict)
		if !ok {
			return nil, fmt.Errorf("db.update: patch must be a dict, got %s", patchVal.Type())
		}
		if patch.Len() == 0 {
			return starlark.True, nil // nothing to do
		}

		physTable, err := cfg.physicalTable(table)
		if err != nil {
			return nil, err
		}

		var setParts []string
		var vals []any
		idx := 1

		for _, item := range patch.Items() {
			col, ok := item[0].(starlark.String)
			if !ok {
				return nil, fmt.Errorf("db.update: column key must be a string")
			}
			colStr := string(col)
			if colStr == "id" || colStr == "created_at" {
				continue
			}
			if strings.ContainsAny(colStr, " \t\n\"';-") {
				return nil, fmt.Errorf("db.update: invalid column name %q", colStr)
			}
			val, err := starlarkToGoValue(item[1])
			if err != nil {
				return nil, fmt.Errorf("db.update: column %q: %w", colStr, err)
			}
			setParts = append(setParts, fmt.Sprintf("%s = %s", colStr, cfg.ph(idx)))
			vals = append(vals, val)
			idx++
		}

		if len(setParts) == 0 {
			return starlark.True, nil
		}

		vals = append(vals, rowID)
		query := fmt.Sprintf("UPDATE %s SET %s WHERE id = %s",
			physTable,
			strings.Join(setParts, ", "),
			cfg.ph(idx),
		)

		if _, err := cfg.DB.ExecContext(ctx, query, vals...); err != nil {
			return nil, fmt.Errorf("db.update: %w", err)
		}
		return starlark.True, nil
	}
}

// dbDelete implements db.delete(table, id) → True.
func dbDelete(ctx context.Context, cfg DBModuleConfig) func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) {
	return func(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
		var table, rowID string
		if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 2, &table, &rowID); err != nil {
			return nil, err
		}

		physTable, err := cfg.physicalTable(table)
		if err != nil {
			return nil, err
		}

		query := fmt.Sprintf("DELETE FROM %s WHERE id = %s", physTable, cfg.ph(1))
		if _, err := cfg.DB.ExecContext(ctx, query, rowID); err != nil {
			return nil, fmt.Errorf("db.delete: %w", err)
		}
		return starlark.True, nil
	}
}
