package services

// Composer building-block library service — t-paliad-315 Slice C
// (design doc docs/design-submission-generator-v2-2026-05-26.md §8 +
// §4.4).
//
// Per the Q2 ratification (m, 2026-05-26): building blocks are plain
// text paste sources. The library row is the source; the lawyer's
// section row is the destination. After paste, the section row has
// no link back to the library — the prose belongs to the section.
//
// Per the Q9 ratification: four visibility tiers — private / team /
// firm / global. The DB-side RLS policy (mig 149) handles the
// "private rows only the author sees" coarse gate. This service
// applies the fine-grained tier predicate at query time, so the
// picker on the section editor only shows blocks the caller actually
// has reach to.
//
// Admin mutations are gated at the handler layer (adminGate). The
// service exposes Create + Update + SoftDelete + RestoreVersion which
// all assume the caller has already passed the admin check.
// Append-only audit history (_admin_versions) is retained at 20 rows
// per block, GCed in the same transaction as each Save.

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

	"github.com/google/uuid"
	"github.com/jmoiron/sqlx"
)

// BuildingBlock mirrors a row in paliad.submission_building_blocks.
type BuildingBlock struct {
	ID               uuid.UUID  `db:"id"                  json:"id"`
	Slug             string     `db:"slug"                json:"slug"`
	Firm             *string    `db:"firm"                json:"firm,omitempty"`
	SectionKey       string     `db:"section_key"         json:"section_key"`
	ProceedingFamily *string    `db:"proceeding_family"   json:"proceeding_family,omitempty"`
	TitleDE          string     `db:"title_de"            json:"title_de"`
	TitleEN          string     `db:"title_en"            json:"title_en"`
	DescriptionDE    *string    `db:"description_de"      json:"description_de,omitempty"`
	DescriptionEN    *string    `db:"description_en"      json:"description_en,omitempty"`
	ContentMDDE      string     `db:"content_md_de"       json:"content_md_de"`
	ContentMDEN      string     `db:"content_md_en"       json:"content_md_en"`
	AuthorID         *uuid.UUID `db:"author_id"           json:"author_id,omitempty"`
	Visibility       string     `db:"visibility"          json:"visibility"`
	IsPublished      bool       `db:"is_published"        json:"is_published"`
	CreatedAt        time.Time  `db:"created_at"          json:"created_at"`
	UpdatedAt        time.Time  `db:"updated_at"          json:"updated_at"`
	DeletedAt        *time.Time `db:"deleted_at"          json:"-"`
}

// BuildingBlockVersion is one row from the admin-only audit history.
type BuildingBlockVersion struct {
	ID              uuid.UUID  `db:"id"                  json:"id"`
	BuildingBlockID uuid.UUID  `db:"building_block_id"   json:"building_block_id"`
	ContentMDDE     string     `db:"content_md_de"       json:"content_md_de"`
	ContentMDEN     string     `db:"content_md_en"       json:"content_md_en"`
	TitleDE         string     `db:"title_de"            json:"title_de"`
	TitleEN         string     `db:"title_en"            json:"title_en"`
	EditedBy        *uuid.UUID `db:"edited_by"           json:"edited_by,omitempty"`
	Note            *string    `db:"note"                json:"note,omitempty"`
	CreatedAt       time.Time  `db:"created_at"          json:"created_at"`
}

// BuildingBlockService handles the library + admin audit history.
type BuildingBlockService struct {
	db   *sqlx.DB
	firm string
}

// NewBuildingBlockService wires the service. firm is branding.Name —
// captured at construction time and used to apply the firm-tier
// filter on List/Get calls.
func NewBuildingBlockService(db *sqlx.DB, firm string) *BuildingBlockService {
	return &BuildingBlockService{db: db, firm: firm}
}

const (
	// VisPrivate / Team / Firm / Global — the 4 tiers per Q9.
	VisPrivate = "private"
	VisTeam    = "team"
	VisFirm    = "firm"
	VisGlobal  = "global"

	// Retention horizon for the admin audit history per block.
	buildingBlockVersionRetention = 20
)

// ErrBuildingBlockNotFound is the sentinel for "no block with that id
// visible to this user". Maps to 404 at the handler layer.
var ErrBuildingBlockNotFound = errors.New("submission building block: not found")

// ErrBuildingBlockInvalidVisibility is the sentinel for a Create /
// Update with an unknown tier value.
var ErrBuildingBlockInvalidVisibility = errors.New("submission building block: invalid visibility")

const buildingBlockColumns = `id, slug, firm, section_key, proceeding_family,
                              title_de, title_en, description_de, description_en,
                              content_md_de, content_md_en,
                              author_id, visibility, is_published,
                              created_at, updated_at, deleted_at`

// BlockListFilter narrows the picker query. All fields optional. Returns
// only published, non-deleted rows the caller has tier reach to.
type BlockListFilter struct {
	// SectionKey filters to blocks bound to one section (the picker
	// uses this to restrict "facts" blocks to facts sections, etc.).
	// Empty string = no filter.
	SectionKey string
	// ProceedingFamily filters to blocks tagged for one family OR
	// untagged (proceeding_family IS NULL = "any family"). Empty
	// string = no filter.
	ProceedingFamily string
	// Search free-text query against title + description + content.
	// Empty string = no filter.
	Search string
	// Limit caps the result count (0 = default 50).
	Limit int
}

// ListVisible returns blocks the caller can see, after the tier
// predicate is applied. Ordered by updated_at DESC. The DB-side
// SELECT policy already drops soft-deleted rows + private-other-author
// rows; this query additionally honours the picker filter + the
// is_published gate + the firm + team predicates.
func (s *BuildingBlockService) ListVisible(ctx context.Context, userID uuid.UUID, filter BlockListFilter) ([]BuildingBlock, error) {
	limit := filter.Limit
	if limit <= 0 {
		limit = 50
	}

	q := `SELECT ` + buildingBlockColumns + `
	      FROM paliad.submission_building_blocks
	      WHERE deleted_at IS NULL
	        AND is_published = true
	        AND (
	          visibility = 'global'
	          OR visibility = 'private' AND author_id = $1
	          OR visibility = 'firm' AND (firm IS NULL OR firm = $2)
	          OR visibility = 'team' AND EXISTS (
	              SELECT 1 FROM paliad.project_teams pt1
	              JOIN paliad.project_teams pt2 ON pt1.project_id = pt2.project_id
	              WHERE pt1.user_id = author_id AND pt2.user_id = $1
	          )
	        )`
	args := []any{userID, s.firm}
	idx := 3

	if filter.SectionKey != "" {
		q += fmt.Sprintf(" AND section_key = $%d", idx)
		args = append(args, filter.SectionKey)
		idx++
	}
	if filter.ProceedingFamily != "" {
		q += fmt.Sprintf(" AND (proceeding_family IS NULL OR proceeding_family = $%d)", idx)
		args = append(args, filter.ProceedingFamily)
		idx++
	}
	if filter.Search != "" {
		pattern := "%" + strings.ToLower(filter.Search) + "%"
		q += fmt.Sprintf(" AND (LOWER(title_de) LIKE $%d OR LOWER(title_en) LIKE $%d OR LOWER(COALESCE(description_de,'')) LIKE $%d OR LOWER(COALESCE(description_en,'')) LIKE $%d OR LOWER(content_md_de) LIKE $%d OR LOWER(content_md_en) LIKE $%d)",
			idx, idx, idx, idx, idx, idx)
		args = append(args, pattern)
		idx++
	}
	q += fmt.Sprintf(" ORDER BY updated_at DESC LIMIT $%d", idx)
	args = append(args, limit)

	var rows []BuildingBlock
	err := s.db.SelectContext(ctx, &rows, q, args...)
	if err != nil {
		return nil, fmt.Errorf("list building blocks: %w", err)
	}
	return rows, nil
}

// ListAllForAdmin returns every non-deleted row regardless of tier.
// Handler-side adminGate is the access gate.
func (s *BuildingBlockService) ListAllForAdmin(ctx context.Context) ([]BuildingBlock, error) {
	var rows []BuildingBlock
	err := s.db.SelectContext(ctx, &rows,
		`SELECT `+buildingBlockColumns+`
		   FROM paliad.submission_building_blocks
		  WHERE deleted_at IS NULL
		  ORDER BY updated_at DESC`)
	if err != nil {
		return nil, fmt.Errorf("admin list building blocks: %w", err)
	}
	return rows, nil
}

// GetVisible fetches a block by id, applying the same tier predicate
// as ListVisible. ErrBuildingBlockNotFound when the row exists but
// the caller has no tier reach (handler maps to 404).
func (s *BuildingBlockService) GetVisible(ctx context.Context, userID, blockID uuid.UUID) (*BuildingBlock, error) {
	var b BuildingBlock
	err := s.db.GetContext(ctx, &b,
		`SELECT `+buildingBlockColumns+`
		   FROM paliad.submission_building_blocks
		  WHERE id = $1
		    AND deleted_at IS NULL
		    AND is_published = true
		    AND (
		      visibility = 'global'
		      OR visibility = 'private' AND author_id = $2
		      OR visibility = 'firm' AND (firm IS NULL OR firm = $3)
		      OR visibility = 'team' AND EXISTS (
		          SELECT 1 FROM paliad.project_teams pt1
		          JOIN paliad.project_teams pt2 ON pt1.project_id = pt2.project_id
		          WHERE pt1.user_id = author_id AND pt2.user_id = $2
		      )
		    )`,
		blockID, userID, s.firm)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, ErrBuildingBlockNotFound
	}
	if err != nil {
		return nil, fmt.Errorf("get building block: %w", err)
	}
	return &b, nil
}

// GetForAdmin fetches a block by id with no tier filter. adminGate at
// the handler is the access gate.
func (s *BuildingBlockService) GetForAdmin(ctx context.Context, blockID uuid.UUID) (*BuildingBlock, error) {
	var b BuildingBlock
	err := s.db.GetContext(ctx, &b,
		`SELECT `+buildingBlockColumns+`
		   FROM paliad.submission_building_blocks
		  WHERE id = $1 AND deleted_at IS NULL`,
		blockID)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, ErrBuildingBlockNotFound
	}
	if err != nil {
		return nil, fmt.Errorf("admin get building block: %w", err)
	}
	return &b, nil
}

// CreateInput carries the fields needed to insert a new block. Admin
// path only (Slice C); user-authored private blocks are a later
// feature.
type CreateInput struct {
	Slug             string
	Firm             *string
	SectionKey       string
	ProceedingFamily *string
	TitleDE          string
	TitleEN          string
	DescriptionDE    *string
	DescriptionEN    *string
	ContentMDDE      string
	ContentMDEN      string
	Visibility       string
	IsPublished      bool
}

// Create inserts a new block and seeds the first audit-history row.
// editorID is the admin's uuid; recorded in _admin_versions.edited_by.
func (s *BuildingBlockService) Create(ctx context.Context, editorID uuid.UUID, in CreateInput) (*BuildingBlock, error) {
	if !validVisibility(in.Visibility) {
		return nil, ErrBuildingBlockInvalidVisibility
	}
	in.Slug = strings.TrimSpace(in.Slug)
	in.SectionKey = strings.TrimSpace(in.SectionKey)
	in.TitleDE = strings.TrimSpace(in.TitleDE)
	in.TitleEN = strings.TrimSpace(in.TitleEN)
	if in.Slug == "" || in.SectionKey == "" || in.TitleDE == "" || in.TitleEN == "" {
		return nil, ErrInvalidInput
	}

	tx, err := s.db.BeginTxx(ctx, nil)
	if err != nil {
		return nil, fmt.Errorf("create building block tx: %w", err)
	}
	committed := false
	defer func() {
		if !committed {
			_ = tx.Rollback()
		}
	}()

	var b BuildingBlock
	err = tx.GetContext(ctx, &b,
		`INSERT INTO paliad.submission_building_blocks
		     (slug, firm, section_key, proceeding_family,
		      title_de, title_en, description_de, description_en,
		      content_md_de, content_md_en, author_id, visibility, is_published)
		 VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
		 RETURNING `+buildingBlockColumns,
		in.Slug, in.Firm, in.SectionKey, in.ProceedingFamily,
		in.TitleDE, in.TitleEN, in.DescriptionDE, in.DescriptionEN,
		in.ContentMDDE, in.ContentMDEN, editorID, in.Visibility, in.IsPublished)
	if err != nil {
		return nil, fmt.Errorf("insert building block: %w", err)
	}
	if err := s.appendVersionTx(ctx, tx, b.ID, editorID, &b, "create"); err != nil {
		return nil, err
	}
	if err := tx.Commit(); err != nil {
		return nil, fmt.Errorf("commit create building block: %w", err)
	}
	committed = true
	return &b, nil
}

// UpdatePatch carries the optional fields for an Update call.
type UpdatePatch struct {
	Slug             *string
	Firm             **string // **string for "set to null" semantics
	SectionKey       *string
	ProceedingFamily **string
	TitleDE          *string
	TitleEN          *string
	DescriptionDE    **string
	DescriptionEN    **string
	ContentMDDE      *string
	ContentMDEN      *string
	Visibility       *string
	IsPublished      *bool
	Note             *string // free-form note that lands in _admin_versions
}

// Update applies a patch. Appends an audit-history row; GCs to the
// retention=20 horizon in the same tx so old versions don't pile up.
func (s *BuildingBlockService) Update(ctx context.Context, editorID, blockID uuid.UUID, patch UpdatePatch) (*BuildingBlock, error) {
	if patch.Visibility != nil && !validVisibility(*patch.Visibility) {
		return nil, ErrBuildingBlockInvalidVisibility
	}

	tx, err := s.db.BeginTxx(ctx, nil)
	if err != nil {
		return nil, fmt.Errorf("update building block tx: %w", err)
	}
	committed := false
	defer func() {
		if !committed {
			_ = tx.Rollback()
		}
	}()

	setParts := []string{}
	args := []any{}
	idx := 1

	addText := func(col string, p *string) {
		if p == nil {
			return
		}
		setParts = append(setParts, fmt.Sprintf("%s = $%d", col, idx))
		args = append(args, *p)
		idx++
	}
	addBool := func(col string, p *bool) {
		if p == nil {
			return
		}
		setParts = append(setParts, fmt.Sprintf("%s = $%d", col, idx))
		args = append(args, *p)
		idx++
	}
	addNullable := func(col string, p **string) {
		if p == nil {
			return
		}
		setParts = append(setParts, fmt.Sprintf("%s = $%d", col, idx))
		args = append(args, *p)
		idx++
	}

	addText("slug", patch.Slug)
	addNullable("firm", patch.Firm)
	addText("section_key", patch.SectionKey)
	addNullable("proceeding_family", patch.ProceedingFamily)
	addText("title_de", patch.TitleDE)
	addText("title_en", patch.TitleEN)
	addNullable("description_de", patch.DescriptionDE)
	addNullable("description_en", patch.DescriptionEN)
	addText("content_md_de", patch.ContentMDDE)
	addText("content_md_en", patch.ContentMDEN)
	addText("visibility", patch.Visibility)
	addBool("is_published", patch.IsPublished)

	if len(setParts) == 0 {
		// No-op patch — still append a version with the user's note if
		// supplied. Otherwise just return current row.
		current, err := s.GetForAdmin(ctx, blockID)
		if err != nil {
			return nil, err
		}
		if patch.Note != nil && strings.TrimSpace(*patch.Note) != "" {
			if err := s.appendVersionTx(ctx, tx, blockID, editorID, current, *patch.Note); err != nil {
				return nil, err
			}
		}
		if err := tx.Commit(); err != nil {
			return nil, fmt.Errorf("commit no-op update building block: %w", err)
		}
		committed = true
		return current, nil
	}

	args = append(args, blockID)
	q := fmt.Sprintf(
		`UPDATE paliad.submission_building_blocks
		    SET %s
		  WHERE id = $%d AND deleted_at IS NULL
		  RETURNING `+buildingBlockColumns,
		strings.Join(setParts, ", "), idx,
	)
	var b BuildingBlock
	err = tx.GetContext(ctx, &b, q, args...)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, ErrBuildingBlockNotFound
	}
	if err != nil {
		return nil, fmt.Errorf("update building block: %w", err)
	}

	note := ""
	if patch.Note != nil {
		note = *patch.Note
	}
	if note == "" {
		note = "update"
	}
	if err := s.appendVersionTx(ctx, tx, blockID, editorID, &b, note); err != nil {
		return nil, err
	}
	if err := tx.Commit(); err != nil {
		return nil, fmt.Errorf("commit update building block: %w", err)
	}
	committed = true
	return &b, nil
}

// SoftDelete marks a block deleted. RLS hides deleted rows; the
// admin can still see them via GetForAdmin if the row is referenced
// by audit history.
func (s *BuildingBlockService) SoftDelete(ctx context.Context, editorID, blockID uuid.UUID) error {
	tx, err := s.db.BeginTxx(ctx, nil)
	if err != nil {
		return fmt.Errorf("soft delete tx: %w", err)
	}
	committed := false
	defer func() {
		if !committed {
			_ = tx.Rollback()
		}
	}()
	var b BuildingBlock
	err = tx.GetContext(ctx, &b,
		`UPDATE paliad.submission_building_blocks
		    SET deleted_at = now()
		  WHERE id = $1 AND deleted_at IS NULL
		  RETURNING `+buildingBlockColumns,
		blockID)
	if errors.Is(err, sql.ErrNoRows) {
		return ErrBuildingBlockNotFound
	}
	if err != nil {
		return fmt.Errorf("soft delete: %w", err)
	}
	if err := s.appendVersionTx(ctx, tx, blockID, editorID, &b, "delete"); err != nil {
		return err
	}
	if err := tx.Commit(); err != nil {
		return fmt.Errorf("commit soft delete: %w", err)
	}
	committed = true
	return nil
}

// ListVersions returns the audit history for a block (most recent
// first), capped at retention. Admin path only.
func (s *BuildingBlockService) ListVersions(ctx context.Context, blockID uuid.UUID) ([]BuildingBlockVersion, error) {
	var rows []BuildingBlockVersion
	err := s.db.SelectContext(ctx, &rows,
		`SELECT id, building_block_id, content_md_de, content_md_en,
		        title_de, title_en, edited_by, note, created_at
		   FROM paliad.submission_building_block_admin_versions
		  WHERE building_block_id = $1
		  ORDER BY created_at DESC
		  LIMIT $2`,
		blockID, buildingBlockVersionRetention)
	if err != nil {
		return nil, fmt.Errorf("list building block versions: %w", err)
	}
	return rows, nil
}

// RestoreVersion overwrites the block's current content + titles with
// the named version's snapshot. Appends a new audit row noting the
// restore. Admin path only.
func (s *BuildingBlockService) RestoreVersion(ctx context.Context, editorID, blockID, versionID uuid.UUID) (*BuildingBlock, error) {
	tx, err := s.db.BeginTxx(ctx, nil)
	if err != nil {
		return nil, fmt.Errorf("restore version tx: %w", err)
	}
	committed := false
	defer func() {
		if !committed {
			_ = tx.Rollback()
		}
	}()

	var v BuildingBlockVersion
	err = tx.GetContext(ctx, &v,
		`SELECT id, building_block_id, content_md_de, content_md_en,
		        title_de, title_en, edited_by, note, created_at
		   FROM paliad.submission_building_block_admin_versions
		  WHERE id = $1 AND building_block_id = $2`,
		versionID, blockID)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, ErrBuildingBlockNotFound
	}
	if err != nil {
		return nil, fmt.Errorf("fetch version: %w", err)
	}

	var b BuildingBlock
	err = tx.GetContext(ctx, &b,
		`UPDATE paliad.submission_building_blocks
		    SET content_md_de = $1, content_md_en = $2,
		        title_de = $3, title_en = $4
		  WHERE id = $5 AND deleted_at IS NULL
		  RETURNING `+buildingBlockColumns,
		v.ContentMDDE, v.ContentMDEN, v.TitleDE, v.TitleEN, blockID)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, ErrBuildingBlockNotFound
	}
	if err != nil {
		return nil, fmt.Errorf("restore update: %w", err)
	}

	note := fmt.Sprintf("restore from %s", versionID.String())
	if err := s.appendVersionTx(ctx, tx, blockID, editorID, &b, note); err != nil {
		return nil, err
	}
	if err := tx.Commit(); err != nil {
		return nil, fmt.Errorf("commit restore: %w", err)
	}
	committed = true
	return &b, nil
}

// appendVersionTx inserts an audit row + GCs to the retention horizon.
// Runs inside the caller's transaction so a failure rolls back the
// associated Create / Update / Delete / Restore.
func (s *BuildingBlockService) appendVersionTx(ctx context.Context, tx *sqlx.Tx, blockID, editorID uuid.UUID, b *BuildingBlock, note string) error {
	_, err := tx.ExecContext(ctx,
		`INSERT INTO paliad.submission_building_block_admin_versions
		     (building_block_id, content_md_de, content_md_en,
		      title_de, title_en, edited_by, note)
		 VALUES ($1, $2, $3, $4, $5, $6, $7)`,
		blockID, b.ContentMDDE, b.ContentMDEN, b.TitleDE, b.TitleEN, editorID, note)
	if err != nil {
		return fmt.Errorf("append version: %w", err)
	}
	// GC: keep only the most recent N versions per block.
	_, err = tx.ExecContext(ctx,
		`DELETE FROM paliad.submission_building_block_admin_versions
		  WHERE id IN (
		    SELECT id FROM paliad.submission_building_block_admin_versions
		     WHERE building_block_id = $1
		     ORDER BY created_at DESC
		     OFFSET $2
		  )`,
		blockID, buildingBlockVersionRetention)
	if err != nil {
		return fmt.Errorf("gc version history: %w", err)
	}
	return nil
}

// InsertIntoSection clones a block's content_md_<lang> into the named
// section by appending at the end (with a paragraph break separator).
// Per Q2: no lineage stamped on the section. The returned
// SubmissionSection carries the updated content.
//
// The handler enforces draft ownership before calling this; the
// service does the visibility check on the block itself and the
// SectionService.Get + Update sequence inside one transaction so an
// in-flight failure rolls back cleanly.
func (s *BuildingBlockService) InsertIntoSection(ctx context.Context, userID, blockID, sectionID uuid.UUID, sections *SectionService) (*SubmissionSection, error) {
	block, err := s.GetVisible(ctx, userID, blockID)
	if err != nil {
		return nil, err
	}
	sec, err := sections.Get(ctx, sectionID)
	if err != nil {
		return nil, err
	}

	// Determine which lang column to splice into based on the section
	// row's existing content + the block's content. We splice both
	// lang columns so the section is bilingually current — the
	// lawyer's draft language picker still drives which one renders.
	newDE := appendBlockContent(sec.ContentMDDE, block.ContentMDDE)
	newEN := appendBlockContent(sec.ContentMDEN, block.ContentMDEN)

	patch := SectionPatch{ContentMDDE: &newDE, ContentMDEN: &newEN}
	return sections.Update(ctx, sectionID, patch)
}

func appendBlockContent(existing, addition string) string {
	if strings.TrimSpace(existing) == "" {
		return addition
	}
	if strings.TrimSpace(addition) == "" {
		return existing
	}
	return strings.TrimRight(existing, "\n") + "\n\n" + addition
}

func validVisibility(v string) bool {
	switch v {
	case VisPrivate, VisTeam, VisFirm, VisGlobal:
		return true
	}
	return false
}
