package sqlite

import (
	"context"
	"database/sql"
	"embed"
	"encoding/json"
	"fmt"
	"io/fs"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"time"

	_ "modernc.org/sqlite"

	"qctextbuilder/internal/domain"
	"qctextbuilder/internal/store"
)

//go:embed migrations/*.sql
var migrationFS embed.FS

type Store struct {
	db *sql.DB
}

func New(dbPath string) (*Store, error) {
	path := strings.TrimSpace(dbPath)
	if path == "" {
		path = "data/qctextbuilder.db"
	}
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
		return nil, fmt.Errorf("create db directory: %w", err)
	}

	db, err := sql.Open("sqlite", path)
	if err != nil {
		return nil, fmt.Errorf("open sqlite: %w", err)
	}
	db.SetMaxOpenConns(1)
	if _, err := db.Exec("PRAGMA foreign_keys = ON;"); err != nil {
		_ = db.Close()
		return nil, fmt.Errorf("enable foreign keys: %w", err)
	}
	if err := runMigrations(db); err != nil {
		_ = db.Close()
		return nil, fmt.Errorf("run migrations: %w", err)
	}
	return &Store{db: db}, nil
}

func (s *Store) Close() error {
	if s == nil || s.db == nil {
		return nil
	}
	return s.db.Close()
}

func (s *Store) UpsertTemplates(ctx context.Context, templates []domain.Template) error {
	tx, err := s.db.BeginTx(ctx, nil)
	if err != nil {
		return err
	}
	defer rollback(tx)

	stmt := `
	INSERT INTO qc_templates (
		id, name, description, locale, thumbnail_url, template_preview_url, type,
		palette_ready, raw_template_json, is_ai_template, is_onboarded, manifest_status, last_discovered_at, updated_at
	) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT is_onboarded FROM qc_templates WHERE id = ?), ?),
		COALESCE((SELECT manifest_status FROM qc_templates WHERE id = ?), ?),
		COALESCE((SELECT last_discovered_at FROM qc_templates WHERE id = ?), ?),
		?
	)
	ON CONFLICT(id) DO UPDATE SET
		name = excluded.name,
		description = excluded.description,
		locale = excluded.locale,
		thumbnail_url = excluded.thumbnail_url,
		template_preview_url = excluded.template_preview_url,
		type = excluded.type,
		palette_ready = excluded.palette_ready,
		raw_template_json = excluded.raw_template_json,
		is_ai_template = excluded.is_ai_template,
		updated_at = excluded.updated_at;
	`
	now := time.Now().UTC()
	for _, t := range templates {
		_, err := tx.ExecContext(ctx, stmt,
			t.ID, t.Name, t.Description, t.Locale, t.ThumbnailURL, t.TemplatePreviewURL, t.Type,
			boolToInt(t.PaletteReady), asRaw(t.RawJSON), boolToInt(t.IsAITemplate),
			t.ID, boolToInt(t.IsOnboarded),
			t.ID, defaultString(t.ManifestStatus, "missing"),
			t.ID, asRFC3339Ptr(t.LastDiscoveredAt),
			now.Format(time.RFC3339Nano),
		)
		if err != nil {
			return fmt.Errorf("upsert template %d: %w", t.ID, err)
		}
	}
	return tx.Commit()
}

func (s *Store) GetTemplateByID(ctx context.Context, id int64) (*domain.Template, error) {
	row := s.db.QueryRowContext(ctx, `
		SELECT id, name, description, locale, thumbnail_url, template_preview_url, type,
		       palette_ready, raw_template_json, is_ai_template, is_onboarded, manifest_status, last_discovered_at
		FROM qc_templates
		WHERE id = ?`, id)
	t, err := scanTemplate(row.Scan)
	if err != nil {
		return nil, err
	}
	return t, nil
}

func (s *Store) ListTemplates(ctx context.Context) ([]domain.Template, error) {
	rows, err := s.db.QueryContext(ctx, `
		SELECT id, name, description, locale, thumbnail_url, template_preview_url, type,
		       palette_ready, raw_template_json, is_ai_template, is_onboarded, manifest_status, last_discovered_at
		FROM qc_templates`)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	out := make([]domain.Template, 0)
	for rows.Next() {
		t, err := scanTemplate(rows.Scan)
		if err != nil {
			return nil, err
		}
		out = append(out, *t)
	}
	return out, rows.Err()
}

func (s *Store) SetTemplateManifestStatus(ctx context.Context, templateID int64, status string, onboarded bool) error {
	res, err := s.db.ExecContext(ctx, `
		UPDATE qc_templates
		SET manifest_status = ?, is_onboarded = ?, last_discovered_at = ?, updated_at = ?
		WHERE id = ?`,
		defaultString(status, "missing"),
		boolToInt(onboarded),
		time.Now().UTC().Format(time.RFC3339Nano),
		time.Now().UTC().Format(time.RFC3339Nano),
		templateID,
	)
	if err != nil {
		return err
	}
	n, _ := res.RowsAffected()
	if n == 0 {
		return store.ErrNotFound
	}
	return nil
}

func (s *Store) CreateManifest(ctx context.Context, manifest domain.TemplateManifest, fields []domain.TemplateField) error {
	tx, err := s.db.BeginTx(ctx, nil)
	if err != nil {
		return err
	}
	defer rollback(tx)

	if _, err := tx.ExecContext(ctx, `UPDATE qc_template_manifests SET is_active = 0 WHERE template_id = ?`, manifest.TemplateID); err != nil {
		return err
	}

	_, err = tx.ExecContext(ctx, `
		INSERT INTO qc_template_manifests (
			id, template_id, manifest_version, source, language_used_discovery, discovery_payload_json,
			discovery_response_json, flattened_manifest_json, is_active, created_at, updated_at
		) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
		manifest.ID, manifest.TemplateID, manifest.Version, manifest.Source, manifest.LanguageUsedDiscovery,
		asRaw(manifest.DiscoveryPayloadJSON), asRaw(manifest.DiscoveryResponseJSON), asRaw(manifest.FlattenedManifestJSON),
		boolToInt(manifest.IsActive), manifest.CreatedAt.UTC().Format(time.RFC3339Nano), manifest.UpdatedAt.UTC().Format(time.RFC3339Nano),
	)
	if err != nil {
		return err
	}

	if _, err := tx.ExecContext(ctx, `DELETE FROM qc_template_fields WHERE manifest_id = ?`, manifest.ID); err != nil {
		return err
	}
	for _, f := range fields {
		_, err := tx.ExecContext(ctx, `
			INSERT INTO qc_template_fields (
				id, template_id, manifest_id, section, website_section, key_name, path, field_kind,
				sample_value, is_enabled, is_required_by_us, display_label, display_order, notes
			) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
			f.ID, f.TemplateID, f.ManifestID, f.Section, domain.NormalizeWebsiteSection(f.WebsiteSection), f.KeyName, f.Path, f.FieldKind,
			f.SampleValue, boolToInt(f.IsEnabled), boolToInt(f.IsRequiredByUs), f.DisplayLabel, f.DisplayOrder, f.Notes,
		)
		if err != nil {
			return err
		}
	}
	return tx.Commit()
}

func (s *Store) GetActiveManifestByTemplateID(ctx context.Context, templateID int64) (*domain.TemplateManifest, error) {
	row := s.db.QueryRowContext(ctx, `
		SELECT id, template_id, manifest_version, source, language_used_discovery, discovery_payload_json,
		       discovery_response_json, flattened_manifest_json, is_active, created_at, updated_at
		FROM qc_template_manifests
		WHERE template_id = ? AND is_active = 1
		ORDER BY created_at DESC
		LIMIT 1`, templateID)
	manifest, err := scanManifest(row.Scan)
	if err != nil {
		return nil, err
	}
	return manifest, nil
}

func (s *Store) ListFieldsByManifestID(ctx context.Context, manifestID string) ([]domain.TemplateField, error) {
	rows, err := s.db.QueryContext(ctx, `
		SELECT id, template_id, manifest_id, section, website_section, key_name, path, field_kind, sample_value,
		       is_enabled, is_required_by_us, display_label, display_order, notes
		FROM qc_template_fields
		WHERE manifest_id = ?
		ORDER BY display_order ASC, id ASC`, manifestID)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	fields := make([]domain.TemplateField, 0)
	for rows.Next() {
		var f domain.TemplateField
		var isEnabled, isRequired int
		if err := rows.Scan(
			&f.ID, &f.TemplateID, &f.ManifestID, &f.Section, &f.WebsiteSection, &f.KeyName, &f.Path, &f.FieldKind, &f.SampleValue,
			&isEnabled, &isRequired, &f.DisplayLabel, &f.DisplayOrder, &f.Notes,
		); err != nil {
			return nil, err
		}
		f.IsEnabled = isEnabled == 1
		f.IsRequiredByUs = isRequired == 1
		f.WebsiteSection = domain.NormalizeWebsiteSection(f.WebsiteSection)
		fields = append(fields, f)
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	if len(fields) == 0 {
		return nil, store.ErrNotFound
	}
	return fields, nil
}

func (s *Store) UpdateFields(ctx context.Context, manifestID string, fields []domain.TemplateField) error {
	tx, err := s.db.BeginTx(ctx, nil)
	if err != nil {
		return err
	}
	defer rollback(tx)

	var exists int
	if err := tx.QueryRowContext(ctx, `SELECT COUNT(1) FROM qc_template_manifests WHERE id = ?`, manifestID).Scan(&exists); err != nil {
		return err
	}
	if exists == 0 {
		return store.ErrNotFound
	}
	if _, err := tx.ExecContext(ctx, `DELETE FROM qc_template_fields WHERE manifest_id = ?`, manifestID); err != nil {
		return err
	}
	for _, f := range fields {
		_, err := tx.ExecContext(ctx, `
			INSERT INTO qc_template_fields (
				id, template_id, manifest_id, section, website_section, key_name, path, field_kind,
				sample_value, is_enabled, is_required_by_us, display_label, display_order, notes
			) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
			f.ID, f.TemplateID, f.ManifestID, f.Section, domain.NormalizeWebsiteSection(f.WebsiteSection), f.KeyName, f.Path, f.FieldKind,
			f.SampleValue, boolToInt(f.IsEnabled), boolToInt(f.IsRequiredByUs), f.DisplayLabel, f.DisplayOrder, f.Notes,
		)
		if err != nil {
			return err
		}
	}
	return tx.Commit()
}

func (s *Store) CreateBuild(ctx context.Context, build domain.SiteBuild) error {
	_, err := s.db.ExecContext(ctx, `
		INSERT INTO site_builds (
			id, template_id, manifest_id, request_name, global_data_json, ai_data_json, final_sites_payload_json,
			qc_job_id, qc_site_id, qc_status, qc_preview_url, qc_editor_url, qc_result_json, qc_error_json, started_at, finished_at
		) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
		build.ID, build.TemplateID, build.ManifestID, build.RequestName, asRaw(build.GlobalDataJSON), asRaw(build.AIDataJSON), asRaw(build.FinalSitesPayload),
		build.QCJobID, build.QCSiteID, build.QCStatus, build.QCPreviewURL, build.QCEditorURL, asRaw(build.QCResultJSON), asRaw(build.QCErrorJSON),
		asRFC3339Ptr(build.StartedAt), asRFC3339Ptr(build.FinishedAt),
	)
	return err
}

func (s *Store) GetBuildByID(ctx context.Context, id string) (*domain.SiteBuild, error) {
	row := s.db.QueryRowContext(ctx, `
		SELECT id, template_id, manifest_id, request_name, global_data_json, ai_data_json, final_sites_payload_json,
		       qc_job_id, qc_site_id, qc_status, qc_preview_url, qc_editor_url, qc_result_json, qc_error_json, started_at, finished_at
		FROM site_builds
		WHERE id = ?`, id)
	build, err := scanBuild(row.Scan)
	if err != nil {
		return nil, err
	}
	return build, nil
}

func (s *Store) ListBuildsByStatuses(ctx context.Context, statuses []string, limit int) ([]domain.SiteBuild, error) {
	base := `
		SELECT id, template_id, manifest_id, request_name, global_data_json, ai_data_json, final_sites_payload_json,
		       qc_job_id, qc_site_id, qc_status, qc_preview_url, qc_editor_url, qc_result_json, qc_error_json, started_at, finished_at
		FROM site_builds`

	args := make([]any, 0)
	parts := make([]string, 0)
	if len(statuses) > 0 {
		placeholders := make([]string, 0, len(statuses))
		for _, status := range statuses {
			placeholders = append(placeholders, "?")
			args = append(args, status)
		}
		parts = append(parts, "qc_status IN ("+strings.Join(placeholders, ", ")+")")
	}
	query := base
	if len(parts) > 0 {
		query += " WHERE " + strings.Join(parts, " AND ")
	}
	query += " ORDER BY started_at ASC, id ASC"
	if limit > 0 {
		query += " LIMIT ?"
		args = append(args, limit)
	}

	rows, err := s.db.QueryContext(ctx, query, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	builds := make([]domain.SiteBuild, 0)
	for rows.Next() {
		build, err := scanBuild(rows.Scan)
		if err != nil {
			return nil, err
		}
		builds = append(builds, *build)
	}
	return builds, rows.Err()
}

func (s *Store) MarkBuildSubmitted(ctx context.Context, buildID string, jobID int64, status string, qcResult json.RawMessage, startedAt time.Time) error {
	res, err := s.db.ExecContext(ctx, `
		UPDATE site_builds
		SET qc_job_id = ?, qc_status = ?, qc_result_json = ?, started_at = ?
		WHERE id = ?`,
		jobID, status, asRaw(qcResult), startedAt.UTC().Format(time.RFC3339Nano), buildID,
	)
	if err != nil {
		return err
	}
	n, _ := res.RowsAffected()
	if n == 0 {
		return store.ErrNotFound
	}
	return nil
}

func (s *Store) UpdateBuildFromJob(ctx context.Context, buildID string, status string, siteID *int64, previewURL string, qcResult json.RawMessage, qcError json.RawMessage, finishedAt *time.Time) error {
	res, err := s.db.ExecContext(ctx, `
		UPDATE site_builds
		SET qc_status = ?, qc_site_id = COALESCE(?, qc_site_id), qc_preview_url = ?, qc_result_json = ?, qc_error_json = ?, finished_at = ?
		WHERE id = ?`,
		status, siteID, previewURL, asRaw(qcResult), asRaw(qcError), asRFC3339Ptr(finishedAt), buildID,
	)
	if err != nil {
		return err
	}
	n, _ := res.RowsAffected()
	if n == 0 {
		return store.ErrNotFound
	}
	return nil
}

func (s *Store) UpdateBuildEditorURL(ctx context.Context, buildID string, editorURL string, qcResult json.RawMessage) error {
	res, err := s.db.ExecContext(ctx, `
		UPDATE site_builds
		SET qc_editor_url = ?, qc_result_json = ?
		WHERE id = ?`,
		editorURL, asRaw(qcResult), buildID,
	)
	if err != nil {
		return err
	}
	n, _ := res.RowsAffected()
	if n == 0 {
		return store.ErrNotFound
	}
	return nil
}

func (s *Store) UpsertSettings(ctx context.Context, settings domain.AppSettings) error {
	promptBlocksRaw, err := json.Marshal(domain.NormalizePromptBlocks(settings.PromptBlocks))
	if err != nil {
		return fmt.Errorf("marshal prompt blocks: %w", err)
	}
	provider := domain.NormalizeLLMProvider(settings.LLMActiveProvider)
	model := domain.NormalizeLLMModel(provider, settings.LLMActiveModel)
	_, err = s.db.ExecContext(ctx, `
		INSERT INTO app_settings (
			id, qc_base_url, qc_bearer_token_encrypted, language_output_mode, job_poll_interval_seconds, job_poll_timeout_seconds,
			llm_active_provider, llm_active_model, llm_base_url, llm_temperature, llm_max_tokens,
			openai_api_key_encrypted, anthropic_api_key_encrypted, google_api_key_encrypted, xai_api_key_encrypted, ollama_api_key_encrypted,
			master_prompt, prompt_blocks_json, updated_at
		) VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
		ON CONFLICT(id) DO UPDATE SET
			qc_base_url = excluded.qc_base_url,
			qc_bearer_token_encrypted = excluded.qc_bearer_token_encrypted,
			language_output_mode = excluded.language_output_mode,
			job_poll_interval_seconds = excluded.job_poll_interval_seconds,
			job_poll_timeout_seconds = excluded.job_poll_timeout_seconds,
			llm_active_provider = excluded.llm_active_provider,
			llm_active_model = excluded.llm_active_model,
			llm_base_url = excluded.llm_base_url,
			llm_temperature = excluded.llm_temperature,
			llm_max_tokens = excluded.llm_max_tokens,
			openai_api_key_encrypted = excluded.openai_api_key_encrypted,
			anthropic_api_key_encrypted = excluded.anthropic_api_key_encrypted,
			google_api_key_encrypted = excluded.google_api_key_encrypted,
			xai_api_key_encrypted = excluded.xai_api_key_encrypted,
			ollama_api_key_encrypted = excluded.ollama_api_key_encrypted,
			master_prompt = excluded.master_prompt,
			prompt_blocks_json = excluded.prompt_blocks_json,
			updated_at = excluded.updated_at`,
		settings.QCBaseURL,
		settings.QCBearerTokenEncrypted,
		defaultString(settings.LanguageOutputMode, "EN"),
		settings.JobPollIntervalSeconds,
		settings.JobPollTimeoutSeconds,
		provider,
		model,
		strings.TrimSpace(settings.LLMBaseURL),
		domain.NormalizeLLMTemperature(settings.LLMTemperature),
		domain.NormalizeLLMMaxTokens(settings.LLMMaxTokens),
		strings.TrimSpace(settings.OpenAIAPIKeyEncrypted),
		strings.TrimSpace(settings.AnthropicAPIKeyEncrypted),
		strings.TrimSpace(settings.GoogleAPIKeyEncrypted),
		strings.TrimSpace(settings.XAIAPIKeyEncrypted),
		strings.TrimSpace(settings.OllamaAPIKeyEncrypted),
		domain.NormalizeMasterPrompt(settings.MasterPrompt),
		promptBlocksRaw,
		time.Now().UTC().Format(time.RFC3339Nano),
	)
	return err
}

func (s *Store) GetSettings(ctx context.Context) (*domain.AppSettings, error) {
	row := s.db.QueryRowContext(ctx, `
		SELECT qc_base_url, qc_bearer_token_encrypted, language_output_mode, job_poll_interval_seconds, job_poll_timeout_seconds,
		       llm_active_provider, llm_active_model, llm_base_url, llm_temperature, llm_max_tokens,
		       openai_api_key_encrypted, anthropic_api_key_encrypted, google_api_key_encrypted, xai_api_key_encrypted, ollama_api_key_encrypted,
		       master_prompt, prompt_blocks_json
		FROM app_settings
		WHERE id = 1`)
	var settings domain.AppSettings
	var promptBlocksRaw []byte
	if err := row.Scan(
		&settings.QCBaseURL,
		&settings.QCBearerTokenEncrypted,
		&settings.LanguageOutputMode,
		&settings.JobPollIntervalSeconds,
		&settings.JobPollTimeoutSeconds,
		&settings.LLMActiveProvider,
		&settings.LLMActiveModel,
		&settings.LLMBaseURL,
		&settings.LLMTemperature,
		&settings.LLMMaxTokens,
		&settings.OpenAIAPIKeyEncrypted,
		&settings.AnthropicAPIKeyEncrypted,
		&settings.GoogleAPIKeyEncrypted,
		&settings.XAIAPIKeyEncrypted,
		&settings.OllamaAPIKeyEncrypted,
		&settings.MasterPrompt,
		&promptBlocksRaw,
	); err != nil {
		if err == sql.ErrNoRows {
			return nil, store.ErrNotFound
		}
		return nil, err
	}
	settings.MasterPrompt = domain.NormalizeMasterPrompt(settings.MasterPrompt)
	if len(promptBlocksRaw) > 0 {
		_ = json.Unmarshal(promptBlocksRaw, &settings.PromptBlocks)
	}
	settings.LLMActiveProvider = domain.NormalizeLLMProvider(settings.LLMActiveProvider)
	settings.LLMActiveModel = domain.NormalizeLLMModel(settings.LLMActiveProvider, settings.LLMActiveModel)
	settings.LLMBaseURL = strings.TrimSpace(settings.LLMBaseURL)
	settings.LLMTemperature = domain.NormalizeLLMTemperature(settings.LLMTemperature)
	settings.LLMMaxTokens = domain.NormalizeLLMMaxTokens(settings.LLMMaxTokens)
	settings.OpenAIAPIKeyEncrypted = strings.TrimSpace(settings.OpenAIAPIKeyEncrypted)
	settings.AnthropicAPIKeyEncrypted = strings.TrimSpace(settings.AnthropicAPIKeyEncrypted)
	settings.GoogleAPIKeyEncrypted = strings.TrimSpace(settings.GoogleAPIKeyEncrypted)
	settings.XAIAPIKeyEncrypted = strings.TrimSpace(settings.XAIAPIKeyEncrypted)
	settings.OllamaAPIKeyEncrypted = strings.TrimSpace(settings.OllamaAPIKeyEncrypted)
	settings.PromptBlocks = domain.NormalizePromptBlocks(settings.PromptBlocks)
	return &settings, nil
}

func (s *Store) CreateDraft(ctx context.Context, draft domain.BuildDraft) error {
	_, err := s.db.ExecContext(ctx, `
		INSERT INTO build_drafts (
			id, template_id, manifest_id, source, request_name, global_data_json,
			field_values_json, draft_context_json, suggestion_state_json, status, notes, created_at, updated_at
		) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
		draft.ID, nullableInt64(draft.TemplateID), draft.ManifestID, draft.Source, draft.RequestName, asRaw(draft.GlobalDataJSON),
		asRaw(draft.FieldValuesJSON), asRaw(draft.DraftContextJSON), asRaw(draft.SuggestionStateJSON), draft.Status, draft.Notes, draft.CreatedAt.UTC().Format(time.RFC3339Nano), draft.UpdatedAt.UTC().Format(time.RFC3339Nano),
	)
	return err
}

func (s *Store) UpdateDraft(ctx context.Context, draft domain.BuildDraft) error {
	res, err := s.db.ExecContext(ctx, `
		UPDATE build_drafts
		SET template_id = ?, manifest_id = ?, source = ?, request_name = ?, global_data_json = ?, field_values_json = ?, draft_context_json = ?, suggestion_state_json = ?,
		    status = ?, notes = ?, updated_at = ?
		WHERE id = ?`,
		nullableInt64(draft.TemplateID), draft.ManifestID, draft.Source, draft.RequestName, asRaw(draft.GlobalDataJSON), asRaw(draft.FieldValuesJSON), asRaw(draft.DraftContextJSON), asRaw(draft.SuggestionStateJSON),
		draft.Status, draft.Notes, draft.UpdatedAt.UTC().Format(time.RFC3339Nano), draft.ID,
	)
	if err != nil {
		return err
	}
	n, _ := res.RowsAffected()
	if n == 0 {
		return store.ErrNotFound
	}
	return nil
}

func (s *Store) GetDraftByID(ctx context.Context, id string) (*domain.BuildDraft, error) {
	row := s.db.QueryRowContext(ctx, `
		SELECT id, template_id, manifest_id, source, request_name, global_data_json, field_values_json, draft_context_json, suggestion_state_json, status, notes, created_at, updated_at
		FROM build_drafts
		WHERE id = ?`, id)
	return scanDraft(row.Scan)
}

func (s *Store) ListDrafts(ctx context.Context, limit int) ([]domain.BuildDraft, error) {
	query := `
		SELECT id, template_id, manifest_id, source, request_name, global_data_json, field_values_json, draft_context_json, suggestion_state_json, status, notes, created_at, updated_at
		FROM build_drafts
		ORDER BY updated_at DESC`
	args := make([]any, 0, 1)
	if limit > 0 {
		query += " LIMIT ?"
		args = append(args, limit)
	}
	rows, err := s.db.QueryContext(ctx, query, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	out := make([]domain.BuildDraft, 0)
	for rows.Next() {
		draft, err := scanDraft(rows.Scan)
		if err != nil {
			return nil, err
		}
		out = append(out, *draft)
	}
	return out, rows.Err()
}

func runMigrations(db *sql.DB) error {
	if _, err := db.Exec(`
		CREATE TABLE IF NOT EXISTS schema_migrations (
			version TEXT PRIMARY KEY,
			applied_at TEXT NOT NULL
		)`); err != nil {
		return err
	}

	entries, err := fs.ReadDir(migrationFS, "migrations")
	if err != nil {
		return err
	}
	files := make([]string, 0, len(entries))
	for _, e := range entries {
		if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") {
			continue
		}
		files = append(files, e.Name())
	}
	sort.Strings(files)

	for _, name := range files {
		var exists int
		if err := db.QueryRow(`SELECT COUNT(1) FROM schema_migrations WHERE version = ?`, name).Scan(&exists); err != nil {
			return err
		}
		if exists > 0 {
			continue
		}

		raw, err := migrationFS.ReadFile("migrations/" + name)
		if err != nil {
			return err
		}
		tx, err := db.Begin()
		if err != nil {
			return err
		}
		if _, err := tx.Exec(string(raw)); err != nil {
			_ = tx.Rollback()
			return fmt.Errorf("apply %s: %w", name, err)
		}
		if _, err := tx.Exec(`INSERT INTO schema_migrations(version, applied_at) VALUES(?, ?)`, name, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
			_ = tx.Rollback()
			return err
		}
		if err := tx.Commit(); err != nil {
			return err
		}
	}
	return nil
}

func scanTemplate(scan func(dest ...any) error) (*domain.Template, error) {
	var t domain.Template
	var paletteReady int
	var isAITemplate int
	var isOnboarded int
	var lastDiscovered sql.NullString
	var raw []byte
	if err := scan(
		&t.ID, &t.Name, &t.Description, &t.Locale, &t.ThumbnailURL, &t.TemplatePreviewURL, &t.Type,
		&paletteReady, &raw, &isAITemplate, &isOnboarded, &t.ManifestStatus, &lastDiscovered,
	); err != nil {
		if err == sql.ErrNoRows {
			return nil, store.ErrNotFound
		}
		return nil, err
	}
	t.PaletteReady = paletteReady == 1
	t.IsAITemplate = isAITemplate == 1
	t.IsOnboarded = isOnboarded == 1
	t.RawJSON = cloneBytes(raw)
	if lastDiscovered.Valid {
		if ts, err := time.Parse(time.RFC3339Nano, lastDiscovered.String); err == nil {
			t.LastDiscoveredAt = &ts
		}
	}
	return &t, nil
}

func scanManifest(scan func(dest ...any) error) (*domain.TemplateManifest, error) {
	var m domain.TemplateManifest
	var isActive int
	var payloadRaw []byte
	var responseRaw []byte
	var flattenedRaw []byte
	var createdAtRaw string
	var updatedAtRaw string
	if err := scan(
		&m.ID, &m.TemplateID, &m.Version, &m.Source, &m.LanguageUsedDiscovery, &payloadRaw,
		&responseRaw, &flattenedRaw, &isActive, &createdAtRaw, &updatedAtRaw,
	); err != nil {
		if err == sql.ErrNoRows {
			return nil, store.ErrNotFound
		}
		return nil, err
	}
	m.IsActive = isActive == 1
	m.DiscoveryPayloadJSON = cloneBytes(payloadRaw)
	m.DiscoveryResponseJSON = cloneBytes(responseRaw)
	m.FlattenedManifestJSON = cloneBytes(flattenedRaw)
	m.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdAtRaw)
	m.UpdatedAt, _ = time.Parse(time.RFC3339Nano, updatedAtRaw)
	return &m, nil
}

func scanBuild(scan func(dest ...any) error) (*domain.SiteBuild, error) {
	var b domain.SiteBuild
	var globalRaw []byte
	var aiDataRaw []byte
	var payloadRaw []byte
	var resultRaw []byte
	var errorRaw []byte
	var startedAtRaw sql.NullString
	var finishedAtRaw sql.NullString
	var jobID sql.NullInt64
	var siteID sql.NullInt64
	if err := scan(
		&b.ID, &b.TemplateID, &b.ManifestID, &b.RequestName, &globalRaw, &aiDataRaw, &payloadRaw,
		&jobID, &siteID, &b.QCStatus, &b.QCPreviewURL, &b.QCEditorURL, &resultRaw, &errorRaw, &startedAtRaw, &finishedAtRaw,
	); err != nil {
		if err == sql.ErrNoRows {
			return nil, store.ErrNotFound
		}
		return nil, err
	}
	b.GlobalDataJSON = cloneBytes(globalRaw)
	b.AIDataJSON = cloneBytes(aiDataRaw)
	b.FinalSitesPayload = cloneBytes(payloadRaw)
	b.QCResultJSON = cloneBytes(resultRaw)
	b.QCErrorJSON = cloneBytes(errorRaw)
	if jobID.Valid {
		id := jobID.Int64
		b.QCJobID = &id
	}
	if siteID.Valid {
		id := siteID.Int64
		b.QCSiteID = &id
	}
	b.StartedAt = parseTimePtr(startedAtRaw)
	b.FinishedAt = parseTimePtr(finishedAtRaw)
	return &b, nil
}

func scanDraft(scan func(dest ...any) error) (*domain.BuildDraft, error) {
	var d domain.BuildDraft
	var templateID sql.NullInt64
	var globalRaw []byte
	var fieldsRaw []byte
	var draftContextRaw []byte
	var suggestionStateRaw []byte
	var createdAtRaw string
	var updatedAtRaw string
	if err := scan(
		&d.ID, &templateID, &d.ManifestID, &d.Source, &d.RequestName, &globalRaw, &fieldsRaw, &draftContextRaw, &suggestionStateRaw, &d.Status, &d.Notes, &createdAtRaw, &updatedAtRaw,
	); err != nil {
		if err == sql.ErrNoRows {
			return nil, store.ErrNotFound
		}
		return nil, err
	}
	if templateID.Valid {
		d.TemplateID = templateID.Int64
	}
	d.GlobalDataJSON = cloneBytes(globalRaw)
	d.FieldValuesJSON = cloneBytes(fieldsRaw)
	d.DraftContextJSON = cloneBytes(draftContextRaw)
	d.SuggestionStateJSON = cloneBytes(suggestionStateRaw)
	d.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdAtRaw)
	d.UpdatedAt, _ = time.Parse(time.RFC3339Nano, updatedAtRaw)
	return &d, nil
}

func rollback(tx *sql.Tx) {
	_ = tx.Rollback()
}

func boolToInt(v bool) int {
	if v {
		return 1
	}
	return 0
}

func defaultString(value, fallback string) string {
	if strings.TrimSpace(value) == "" {
		return fallback
	}
	return strings.TrimSpace(value)
}

func asRaw(raw json.RawMessage) []byte {
	if len(raw) == 0 {
		return nil
	}
	out := make([]byte, len(raw))
	copy(out, raw)
	return out
}

func cloneBytes(raw []byte) []byte {
	if len(raw) == 0 {
		return nil
	}
	out := make([]byte, len(raw))
	copy(out, raw)
	return out
}

func asRFC3339Ptr(t *time.Time) *string {
	if t == nil {
		return nil
	}
	v := t.UTC().Format(time.RFC3339Nano)
	return &v
}

func parseTimePtr(value sql.NullString) *time.Time {
	if !value.Valid {
		return nil
	}
	ts, err := time.Parse(time.RFC3339Nano, value.String)
	if err != nil {
		return nil
	}
	return &ts
}

func nullableInt64(value int64) any {
	if value <= 0 {
		return nil
	}
	return value
}
