package admin

import (
	"context"
	"database/sql"
	"encoding/json"
	"errors"
	"fmt"
	"strings"
	"time"

	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgconn"
	"github.com/jackc/pgx/v5/pgxpool"
)

var ErrNotFound = errors.New("not found")

type Store struct {
	db *pgxpool.Pool
}

type PostListOptions struct {
	Status PostStatus
	Limit  int
	Offset int
}

type AuditLogInput struct {
	ActorID       *string
	ActorUsername string
	Action        string
	ResourceType  string
	ResourceID    string
	IPAddress     string
	UserAgent     string
	Details       map[string]any
}

type AuditLogListOptions struct {
	Action       string
	ResourceType string
	Query        string
	Limit        int
	Offset       int
}

type PostInput struct {
	Slug         string     `json:"slug"`
	Title        string     `json:"title"`
	Summary      string     `json:"summary"`
	BodyMarkdown string     `json:"bodyMarkdown"`
	Status       PostStatus `json:"status"`
	Tags         []string   `json:"tags"`
	Cover        string     `json:"cover"`
	SlugSource   string     `json:"slugSource"`
	SlugLocked   bool       `json:"slugLocked"`
	CreatedAt    *time.Time `json:"createdAt"`
}

func NewStore(db *pgxpool.Pool) *Store {
	return &Store{db: db}
}

func (s *Store) ListPosts(ctx context.Context, opts PostListOptions) ([]Post, error) {
	limit := opts.Limit
	if limit <= 0 || limit > 100 {
		limit = 50
	}
	offset := opts.Offset
	if offset < 0 {
		offset = 0
	}

	args := []any{limit, offset}
	where := "deleted_at IS NULL"
	if opts.Status != "" {
		if !ValidPostStatus(opts.Status) {
			return nil, fmt.Errorf("invalid post status: %s", opts.Status)
		}
		args = append(args, opts.Status)
		where += fmt.Sprintf(" AND status = $%d", len(args))
	}

	rows, err := s.db.Query(ctx, `
SELECT id, slug, title, summary, body_markdown, status, cover, version,
       slug_source, slug_locked, published_at, created_at, updated_at, deleted_at
FROM posts
WHERE `+where+`
ORDER BY updated_at DESC
LIMIT $1 OFFSET $2`, args...)
	if err != nil {
		return nil, fmt.Errorf("list posts: %w", err)
	}
	defer rows.Close()

	var posts []Post
	for rows.Next() {
		post, err := scanPost(rows)
		if err != nil {
			return nil, err
		}
		posts = append(posts, post)
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("list posts rows: %w", err)
	}
	if err := s.attachTags(ctx, posts); err != nil {
		return nil, err
	}
	return posts, nil
}

func (s *Store) CountPosts(ctx context.Context, status PostStatus) (int, error) {
	args := []any{}
	where := "deleted_at IS NULL"
	if status != "" {
		if !ValidPostStatus(status) {
			return 0, fmt.Errorf("invalid post status: %s", status)
		}
		args = append(args, status)
		where += " AND status = $1"
	}

	var total int
	if err := s.db.QueryRow(ctx, `SELECT count(*) FROM posts WHERE `+where, args...).Scan(&total); err != nil {
		return 0, fmt.Errorf("count posts: %w", err)
	}
	return total, nil
}

func (s *Store) GetPost(ctx context.Context, id string) (Post, error) {
	post, err := scanPost(s.db.QueryRow(ctx, `
SELECT id, slug, title, summary, body_markdown, status, cover, version,
       slug_source, slug_locked, published_at, created_at, updated_at, deleted_at
FROM posts
WHERE id = $1 AND deleted_at IS NULL`, id))
	if errors.Is(err, pgx.ErrNoRows) {
		return Post{}, ErrNotFound
	}
	if err != nil {
		return Post{}, fmt.Errorf("get post: %w", err)
	}
	posts := []Post{post}
	if err := s.attachTags(ctx, posts); err != nil {
		return Post{}, err
	}
	post = posts[0]
	return post, nil
}

func (s *Store) UniqueSlug(ctx context.Context, base string, excludePostID string) (string, error) {
	base = sanitizeSlug(base)
	if base == "" {
		return "", errors.New("slug is empty")
	}
	for i := 0; i < 100; i++ {
		candidate := base
		if i > 0 {
			candidate = fmt.Sprintf("%s-%d", base, i+1)
		}
		exists, err := s.slugExists(ctx, candidate, excludePostID)
		if err != nil {
			return "", err
		}
		if !exists {
			return candidate, nil
		}
	}
	return "", fmt.Errorf("could not find available slug for %q", base)
}

func (s *Store) slugExists(ctx context.Context, slug string, excludePostID string) (bool, error) {
	var exists bool
	var err error
	if strings.TrimSpace(excludePostID) != "" {
		err = s.db.QueryRow(ctx, `
SELECT EXISTS (
  SELECT 1
  FROM posts
  WHERE slug = $1 AND id::text <> $2
)`, slug, excludePostID).Scan(&exists)
	} else {
		err = s.db.QueryRow(ctx, `
SELECT EXISTS (
  SELECT 1
  FROM posts
  WHERE slug = $1
)`, slug).Scan(&exists)
	}
	if err != nil {
		return false, fmt.Errorf("check slug exists: %w", err)
	}
	return exists, nil
}

func (s *Store) CreatePost(ctx context.Context, input PostInput) (Post, error) {
	if err := validatePostInput(input, true); err != nil {
		return Post{}, err
	}
	tx, err := s.db.Begin(ctx)
	if err != nil {
		return Post{}, fmt.Errorf("begin create post: %w", err)
	}
	defer tx.Rollback(ctx)

	post, err := insertPost(ctx, tx, input)
	if err != nil {
		return Post{}, err
	}
	if err := replacePostTags(ctx, tx, post.ID, input.Tags); err != nil {
		return Post{}, err
	}
	post.Tags = normalizeTagNames(input.Tags)
	if err := insertPostVersion(ctx, tx, post, VersionReasonSave); err != nil {
		return Post{}, err
	}
	if err := tx.Commit(ctx); err != nil {
		return Post{}, fmt.Errorf("commit create post: %w", err)
	}
	return post, nil
}

func (s *Store) UpdatePost(ctx context.Context, id string, input PostInput) (Post, error) {
	if err := validatePostInput(input, false); err != nil {
		return Post{}, err
	}

	tx, err := s.db.Begin(ctx)
	if err != nil {
		return Post{}, fmt.Errorf("begin update post: %w", err)
	}
	defer tx.Rollback(ctx)

	var currentStatus PostStatus
	err = tx.QueryRow(ctx, `
SELECT status
FROM posts
WHERE id = $1 AND deleted_at IS NULL
FOR UPDATE`, id).Scan(&currentStatus)
	if errors.Is(err, pgx.ErrNoRows) {
		return Post{}, ErrNotFound
	}
	if err != nil {
		return Post{}, fmt.Errorf("lock post: %w", err)
	}
	if err := ValidatePostStatusTransition(currentStatus, input.Status); err != nil {
		return Post{}, err
	}
	post, err := updatePost(ctx, tx, id, input)
	if err != nil {
		return Post{}, err
	}
	if err := replacePostTags(ctx, tx, post.ID, input.Tags); err != nil {
		return Post{}, err
	}
	post.Tags = normalizeTagNames(input.Tags)
	if err := insertPostVersion(ctx, tx, post, VersionReasonSave); err != nil {
		return Post{}, err
	}
	if err := tx.Commit(ctx); err != nil {
		return Post{}, fmt.Errorf("commit update post: %w", err)
	}
	return post, nil
}

func (s *Store) CreateManualBuildJob(ctx context.Context, postID string) (BuildJob, error) {
	tx, err := s.db.Begin(ctx)
	if err != nil {
		return BuildJob{}, fmt.Errorf("begin create build job: %w", err)
	}
	defer tx.Rollback(ctx)

	var exists bool
	if err := tx.QueryRow(ctx, `
SELECT EXISTS (
  SELECT 1
  FROM posts
  WHERE id = $1 AND deleted_at IS NULL
)`, postID).Scan(&exists); err != nil {
		return BuildJob{}, fmt.Errorf("check post for build: %w", err)
	}
	if !exists {
		return BuildJob{}, ErrNotFound
	}

	job, err := insertBuildJob(ctx, tx, BuildJobTriggerManual, &postID)
	if err != nil {
		return BuildJob{}, err
	}
	if err := tx.Commit(ctx); err != nil {
		return BuildJob{}, fmt.Errorf("commit create build job: %w", err)
	}
	return job, nil
}

func (s *Store) DeletePost(ctx context.Context, id string) (*BuildJob, error) {
	tx, err := s.db.Begin(ctx)
	if err != nil {
		return nil, fmt.Errorf("begin delete post: %w", err)
	}
	defer tx.Rollback(ctx)

	var currentStatus PostStatus
	err = tx.QueryRow(ctx, `
SELECT status
FROM posts
WHERE id = $1 AND deleted_at IS NULL
FOR UPDATE`, id).Scan(&currentStatus)
	if errors.Is(err, pgx.ErrNoRows) {
		return nil, ErrNotFound
	}
	if err != nil {
		return nil, fmt.Errorf("lock post: %w", err)
	}

	commandTag, err := tx.Exec(ctx, `
UPDATE posts
SET status = 'deleted', deleted_at = now(), updated_at = now()
WHERE id = $1 AND deleted_at IS NULL`, id)
	if err != nil {
		return nil, fmt.Errorf("delete post: %w", err)
	}
	if commandTag.RowsAffected() == 0 {
		return nil, ErrNotFound
	}

	var job *BuildJob
	if IsPublicPostStatus(currentStatus) {
		created, err := insertBuildJob(ctx, tx, BuildJobTriggerManual, &id)
		if err != nil {
			return nil, err
		}
		job = &created
	}

	if err := tx.Commit(ctx); err != nil {
		return nil, fmt.Errorf("commit delete post: %w", err)
	}
	return job, nil
}

func (s *Store) PublishPost(ctx context.Context, id string) (Post, BuildJob, error) {
	tx, err := s.db.Begin(ctx)
	if err != nil {
		return Post{}, BuildJob{}, fmt.Errorf("begin publish post: %w", err)
	}
	defer tx.Rollback(ctx)

	var current Post
	current, err = scanPost(tx.QueryRow(ctx, `
SELECT id, slug, title, summary, body_markdown, status, cover, version,
       slug_source, slug_locked, published_at, created_at, updated_at, deleted_at
FROM posts
WHERE id = $1 AND deleted_at IS NULL
FOR UPDATE`, id))
	if errors.Is(err, pgx.ErrNoRows) {
		return Post{}, BuildJob{}, ErrNotFound
	}
	if err != nil {
		return Post{}, BuildJob{}, fmt.Errorf("lock post: %w", err)
	}
	if err := ValidatePostStatusTransition(current.Status, PostStatusPublished); err != nil {
		return Post{}, BuildJob{}, err
	}

	post, err := scanPost(tx.QueryRow(ctx, `
UPDATE posts
SET status = 'published',
    version = version + 1,
    published_at = COALESCE(published_at, now()),
    updated_at = now()
WHERE id = $1 AND deleted_at IS NULL
RETURNING id, slug, title, summary, body_markdown, status, cover, version,
          slug_source, slug_locked, published_at, created_at, updated_at, deleted_at`, id))
	if err != nil {
		return Post{}, BuildJob{}, fmt.Errorf("publish post: %w", err)
	}
	if err := insertPostVersion(ctx, tx, post, VersionReasonPublish); err != nil {
		return Post{}, BuildJob{}, err
	}

	job, err := insertBuildJob(ctx, tx, BuildJobTriggerPublish, &post.ID)
	if err != nil {
		return Post{}, BuildJob{}, err
	}
	if err := tx.Commit(ctx); err != nil {
		return Post{}, BuildJob{}, fmt.Errorf("commit publish post: %w", err)
	}
	posts := []Post{post}
	if err := s.attachTags(ctx, posts); err != nil {
		return Post{}, BuildJob{}, err
	}
	post = posts[0]
	return post, job, nil
}

func (s *Store) GetBuildJob(ctx context.Context, id string) (BuildJob, error) {
	job, err := scanBuildJob(s.db.QueryRow(ctx, `
SELECT id, trigger, status, post_id, started_at, finished_at, log, error, created_at, created_by
FROM build_jobs
WHERE id = $1`, id))
	if errors.Is(err, pgx.ErrNoRows) {
		return BuildJob{}, ErrNotFound
	}
	if err != nil {
		return BuildJob{}, fmt.Errorf("get build job: %w", err)
	}
	return job, nil
}

func (s *Store) PublishedPostsForExport(ctx context.Context) ([]Post, error) {
	rows, err := s.db.Query(ctx, `
SELECT id, slug, title, summary, body_markdown, status, cover, version,
       slug_source, slug_locked, published_at, created_at, updated_at, deleted_at
FROM posts
WHERE status IN ('published', 'archived') AND deleted_at IS NULL
ORDER BY published_at DESC NULLS LAST, updated_at DESC`)
	if err != nil {
		return nil, fmt.Errorf("published posts for export: %w", err)
	}
	defer rows.Close()

	var posts []Post
	for rows.Next() {
		post, err := scanPost(rows)
		if err != nil {
			return nil, err
		}
		posts = append(posts, post)
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("published posts rows: %w", err)
	}
	if err := s.attachTags(ctx, posts); err != nil {
		return nil, err
	}
	return posts, nil
}

func (s *Store) MarkBuildJobRunning(ctx context.Context, id string) error {
	_, err := s.db.Exec(ctx, `
UPDATE build_jobs
SET status = 'running', started_at = now()
WHERE id = $1 AND status = 'queued'`, id)
	if err != nil {
		return fmt.Errorf("mark build job running: %w", err)
	}
	return nil
}

func (s *Store) MarkBuildJobSuccess(ctx context.Context, id string, log string) error {
	_, err := s.db.Exec(ctx, `
UPDATE build_jobs
SET status = 'success', finished_at = now(), log = $2, error = ''
WHERE id = $1`, id, log)
	if err != nil {
		return fmt.Errorf("mark build job success: %w", err)
	}
	return nil
}

func (s *Store) MarkBuildJobFailed(ctx context.Context, id string, log string, message string) error {
	_, err := s.db.Exec(ctx, `
UPDATE build_jobs
SET status = 'failed', finished_at = now(), log = $2, error = $3
WHERE id = $1`, id, log, message)
	if err != nil {
		return fmt.Errorf("mark build job failed: %w", err)
	}
	return nil
}

func (s *Store) CreateAuditLog(ctx context.Context, input AuditLogInput) error {
	action := strings.TrimSpace(input.Action)
	if action == "" {
		return errors.New("audit action is required")
	}
	details := input.Details
	if details == nil {
		details = map[string]any{}
	}
	detailsJSON, err := json.Marshal(details)
	if err != nil {
		return fmt.Errorf("encode audit details: %w", err)
	}
	_, err = s.db.Exec(ctx, `
INSERT INTO audit_logs (actor_id, actor_username, action, resource_type, resource_id, ip_address, user_agent, details)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)`,
		input.ActorID,
		input.ActorUsername,
		action,
		strings.TrimSpace(input.ResourceType),
		strings.TrimSpace(input.ResourceID),
		strings.TrimSpace(input.IPAddress),
		strings.TrimSpace(input.UserAgent),
		string(detailsJSON),
	)
	if err != nil {
		return fmt.Errorf("create audit log: %w", err)
	}
	return nil
}

func (s *Store) ListAuditLogs(ctx context.Context, opts AuditLogListOptions) ([]AuditLog, error) {
	limit := opts.Limit
	if limit <= 0 || limit > 200 {
		limit = 50
	}
	offset := opts.Offset
	if offset < 0 {
		offset = 0
	}

	args := []any{limit, offset}
	where := "true"
	if strings.TrimSpace(opts.Action) != "" {
		args = append(args, strings.TrimSpace(opts.Action))
		where += fmt.Sprintf(" AND action = $%d", len(args))
	}
	if strings.TrimSpace(opts.ResourceType) != "" {
		args = append(args, strings.TrimSpace(opts.ResourceType))
		where += fmt.Sprintf(" AND resource_type = $%d", len(args))
	}
	if strings.TrimSpace(opts.Query) != "" {
		args = append(args, "%"+strings.TrimSpace(opts.Query)+"%")
		where += fmt.Sprintf(` AND (
  actor_username ILIKE $%d OR action ILIKE $%d OR resource_type ILIKE $%d OR resource_id ILIKE $%d OR details::text ILIKE $%d
)`, len(args), len(args), len(args), len(args), len(args))
	}

	rows, err := s.db.Query(ctx, `
SELECT id, actor_id, actor_username, action, resource_type, resource_id, ip_address, user_agent, details, created_at
FROM audit_logs
WHERE `+where+`
ORDER BY created_at DESC
LIMIT $1 OFFSET $2`, args...)
	if err != nil {
		return nil, fmt.Errorf("list audit logs: %w", err)
	}
	defer rows.Close()

	var logs []AuditLog
	for rows.Next() {
		log, err := scanAuditLog(rows)
		if err != nil {
			return nil, err
		}
		logs = append(logs, log)
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("audit log rows: %w", err)
	}
	return logs, nil
}

func (s *Store) CountAuditLogs(ctx context.Context, opts AuditLogListOptions) (int, error) {
	args := []any{}
	where := "true"
	if strings.TrimSpace(opts.Action) != "" {
		args = append(args, strings.TrimSpace(opts.Action))
		where += fmt.Sprintf(" AND action = $%d", len(args))
	}
	if strings.TrimSpace(opts.ResourceType) != "" {
		args = append(args, strings.TrimSpace(opts.ResourceType))
		where += fmt.Sprintf(" AND resource_type = $%d", len(args))
	}
	if strings.TrimSpace(opts.Query) != "" {
		args = append(args, "%"+strings.TrimSpace(opts.Query)+"%")
		where += fmt.Sprintf(` AND (
  actor_username ILIKE $%d OR action ILIKE $%d OR resource_type ILIKE $%d OR resource_id ILIKE $%d OR details::text ILIKE $%d
)`, len(args), len(args), len(args), len(args), len(args))
	}

	var total int
	if err := s.db.QueryRow(ctx, `SELECT count(*) FROM audit_logs WHERE `+where, args...).Scan(&total); err != nil {
		return 0, fmt.Errorf("count audit logs: %w", err)
	}
	return total, nil
}

func validatePostInput(input PostInput, creating bool) error {
	if strings.TrimSpace(input.Title) == "" {
		return errors.New("title is required")
	}
	if strings.TrimSpace(input.Slug) == "" {
		return errors.New("slug is required")
	}
	if input.Status == "" {
		if creating {
			input.Status = PostStatusDraft
		} else {
			return errors.New("status is required")
		}
	}
	if !ValidPostStatus(input.Status) {
		return fmt.Errorf("invalid post status: %s", input.Status)
	}
	if creating && input.Status == PostStatusDeleted {
		return errors.New("new post cannot be deleted")
	}
	return nil
}

func (s *Store) attachTags(ctx context.Context, posts []Post) error {
	if len(posts) == 0 {
		return nil
	}

	ids := make([]string, 0, len(posts))
	index := make(map[string]int, len(posts))
	for i := range posts {
		ids = append(ids, posts[i].ID)
		index[posts[i].ID] = i
		posts[i].Tags = []string{}
	}

	rows, err := s.db.Query(ctx, `
SELECT pt.post_id::text, t.name
FROM post_tags pt
JOIN tags t ON t.id = pt.tag_id
WHERE pt.post_id::text = ANY($1)
ORDER BY t.name`, ids)
	if err != nil {
		return fmt.Errorf("load post tags: %w", err)
	}
	defer rows.Close()

	for rows.Next() {
		var postID string
		var tag string
		if err := rows.Scan(&postID, &tag); err != nil {
			return fmt.Errorf("scan post tag: %w", err)
		}
		if i, ok := index[postID]; ok {
			posts[i].Tags = append(posts[i].Tags, tag)
		}
	}
	if err := rows.Err(); err != nil {
		return fmt.Errorf("post tags rows: %w", err)
	}
	return nil
}

func insertPost(ctx context.Context, tx pgx.Tx, input PostInput) (Post, error) {
	if input.Status == "" {
		input.Status = PostStatusDraft
	}
	if input.SlugSource == "" {
		input.SlugSource = "manual"
	}

	post, err := scanPost(tx.QueryRow(ctx, `
INSERT INTO posts (slug, title, summary, body_markdown, status, cover, slug_source, slug_locked, published_at, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, CASE WHEN $5 IN ('published', 'archived') THEN now() ELSE NULL END, COALESCE($9, now()))
RETURNING id, slug, title, summary, body_markdown, status, cover, version,
          slug_source, slug_locked, published_at, created_at, updated_at, deleted_at`,
		input.Slug,
		input.Title,
		input.Summary,
		input.BodyMarkdown,
		input.Status,
		input.Cover,
		input.SlugSource,
		input.SlugLocked,
		input.CreatedAt,
	))
	if err != nil {
		return Post{}, normalizeStoreError("insert post", err)
	}
	return post, nil
}

func updatePost(ctx context.Context, tx pgx.Tx, id string, input PostInput) (Post, error) {
	if input.SlugSource == "" {
		input.SlugSource = "manual"
	}

	post, err := scanPost(tx.QueryRow(ctx, `
UPDATE posts
SET slug = $2,
    title = $3,
    summary = $4,
    body_markdown = $5,
    status = $6,
    cover = $7,
    slug_source = $8,
    slug_locked = $9,
    version = version + 1,
    published_at = CASE WHEN $6 IN ('published', 'archived') AND published_at IS NULL THEN now() ELSE published_at END,
    created_at = COALESCE($10, created_at),
    updated_at = now()
WHERE id = $1 AND deleted_at IS NULL
RETURNING id, slug, title, summary, body_markdown, status, cover, version,
          slug_source, slug_locked, published_at, created_at, updated_at, deleted_at`,
		id,
		input.Slug,
		input.Title,
		input.Summary,
		input.BodyMarkdown,
		input.Status,
		input.Cover,
		input.SlugSource,
		input.SlugLocked,
		input.CreatedAt,
	))
	if errors.Is(err, pgx.ErrNoRows) {
		return Post{}, ErrNotFound
	}
	if err != nil {
		return Post{}, normalizeStoreError("update post", err)
	}
	return post, nil
}

func insertPostVersion(ctx context.Context, tx pgx.Tx, post Post, reason VersionReason) error {
	_, err := tx.Exec(ctx, `
INSERT INTO post_versions (post_id, version, title, summary, body_markdown, status, reason)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
		post.ID,
		post.Version,
		post.Title,
		post.Summary,
		post.BodyMarkdown,
		post.Status,
		reason,
	)
	if err != nil {
		return fmt.Errorf("insert post version: %w", err)
	}
	return nil
}

func replacePostTags(ctx context.Context, tx pgx.Tx, postID string, tags []string) error {
	if _, err := tx.Exec(ctx, `DELETE FROM post_tags WHERE post_id = $1`, postID); err != nil {
		return fmt.Errorf("clear post tags: %w", err)
	}

	for _, name := range normalizeTagNames(tags) {
		slug := tagSlug(name)
		if slug == "" {
			continue
		}

		var tagID string
		if err := tx.QueryRow(ctx, `
INSERT INTO tags (name, slug)
VALUES ($1, $2)
ON CONFLICT (slug)
DO UPDATE SET name = excluded.name, updated_at = now()
RETURNING id`, name, slug).Scan(&tagID); err != nil {
			return fmt.Errorf("upsert tag %s: %w", name, err)
		}

		if _, err := tx.Exec(ctx, `
INSERT INTO post_tags (post_id, tag_id)
VALUES ($1, $2)
ON CONFLICT DO NOTHING`, postID, tagID); err != nil {
			return fmt.Errorf("link post tag %s: %w", name, err)
		}
	}
	return nil
}

func normalizeTagNames(tags []string) []string {
	seen := map[string]bool{}
	var normalized []string
	for _, tag := range tags {
		name := strings.TrimSpace(tag)
		if name == "" {
			continue
		}
		key := strings.ToLower(name)
		if seen[key] {
			continue
		}
		seen[key] = true
		normalized = append(normalized, name)
	}
	return normalized
}

func tagSlug(tag string) string {
	slug := strings.ToLower(strings.TrimSpace(tag))
	slug = strings.ReplaceAll(slug, "_", "-")
	slug = strings.Join(strings.Fields(slug), "-")
	return strings.Trim(slug, "-")
}

func sanitizeSlug(slug string) string {
	slug = strings.ToLower(strings.TrimSpace(slug))
	replacer := strings.NewReplacer("_", "-", " ", "-")
	slug = replacer.Replace(slug)
	var builder strings.Builder
	lastHyphen := false
	for _, r := range slug {
		valid := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9')
		if valid {
			builder.WriteRune(r)
			lastHyphen = false
			continue
		}
		if r == '-' && !lastHyphen {
			builder.WriteRune('-')
			lastHyphen = true
		}
	}
	cleaned := strings.Trim(builder.String(), "-")
	if len(cleaned) > 80 {
		cleaned = strings.Trim(cleaned[:80], "-")
	}
	return cleaned
}

func insertBuildJob(ctx context.Context, tx pgx.Tx, trigger BuildJobTrigger, postID *string) (BuildJob, error) {
	job, err := scanBuildJob(tx.QueryRow(ctx, `
INSERT INTO build_jobs (trigger, status, post_id)
VALUES ($1, 'queued', $2)
RETURNING id, trigger, status, post_id, started_at, finished_at, log, error, created_at, created_by`,
		trigger,
		postID,
	))
	if err != nil {
		return BuildJob{}, fmt.Errorf("insert build job: %w", err)
	}
	return job, nil
}

type postScanner interface {
	Scan(dest ...any) error
}

func scanPost(row postScanner) (Post, error) {
	var post Post
	var publishedAt sql.NullTime
	var deletedAt sql.NullTime
	err := row.Scan(
		&post.ID,
		&post.Slug,
		&post.Title,
		&post.Summary,
		&post.BodyMarkdown,
		&post.Status,
		&post.Cover,
		&post.Version,
		&post.SlugSource,
		&post.SlugLocked,
		&publishedAt,
		&post.CreatedAt,
		&post.UpdatedAt,
		&deletedAt,
	)
	if err != nil {
		return Post{}, err
	}
	post.PublishedAt = nullTimePtr(publishedAt)
	post.DeletedAt = nullTimePtr(deletedAt)
	return post, nil
}

func scanBuildJob(row postScanner) (BuildJob, error) {
	var job BuildJob
	var postID sql.NullString
	var startedAt sql.NullTime
	var finishedAt sql.NullTime
	var createdBy sql.NullString
	err := row.Scan(
		&job.ID,
		&job.Trigger,
		&job.Status,
		&postID,
		&startedAt,
		&finishedAt,
		&job.Log,
		&job.Error,
		&job.CreatedAt,
		&createdBy,
	)
	if err != nil {
		return BuildJob{}, err
	}
	job.PostID = nullStringPtr(postID)
	job.StartedAt = nullTimePtr(startedAt)
	job.FinishedAt = nullTimePtr(finishedAt)
	job.CreatedBy = nullStringPtr(createdBy)
	return job, nil
}

func scanAuditLog(row postScanner) (AuditLog, error) {
	var log AuditLog
	var actorID sql.NullString
	var details []byte
	err := row.Scan(
		&log.ID,
		&actorID,
		&log.ActorUsername,
		&log.Action,
		&log.ResourceType,
		&log.ResourceID,
		&log.IPAddress,
		&log.UserAgent,
		&details,
		&log.CreatedAt,
	)
	if err != nil {
		return AuditLog{}, err
	}
	log.ActorID = nullStringPtr(actorID)
	if len(details) > 0 {
		if err := json.Unmarshal(details, &log.Details); err != nil {
			return AuditLog{}, fmt.Errorf("decode audit details: %w", err)
		}
	}
	if log.Details == nil {
		log.Details = map[string]any{}
	}
	return log, nil
}

func nullTimePtr(value sql.NullTime) *time.Time {
	if !value.Valid {
		return nil
	}
	return &value.Time
}

func nullStringPtr(value sql.NullString) *string {
	if !value.Valid {
		return nil
	}
	return &value.String
}

func normalizeStoreError(action string, err error) error {
	var pgErr *pgconn.PgError
	if errors.As(err, &pgErr) && pgErr.Code == "23505" {
		return fmt.Errorf("%s: slug already exists", action)
	}
	return fmt.Errorf("%s: %w", action, err)
}
