package services

import (
	"context"
	"database/sql"
	"encoding/json"
	"errors"
	"fmt"
	"regexp"

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

// ScenarioFlagsService owns the per-project scenario state — the
// single source of truth introduced in mig 154 (m/paliad#149 Phase 2 P0).
//
// The state lives in paliad.projects.scenario_flags (jsonb object) and
// carries two key shapes:
//
//   - **Named flags** — keys whose name appears in paliad.scenario_flag_catalog
//     (today: with_ccr / with_amend / with_cci). These drive condition_expr
//     evaluation in pkg/litigationplanner and the Verfahrensablauf
//     scenario-strip UI.
//
//   - **Per-rule selection deviations** — keys of shape "rule:<uuid>".
//     They record an explicit deviation from the rule's priority-driven
//     default (mandatory always selected; recommended default-selected;
//     optional default-unselected). The UUID must resolve to an
//     active+published sequencing_rule on the project's proceeding type.
//
// Values are always JSON booleans. Missing keys take the priority-driven
// default — the absence of an entry is the absence of a deviation.
//
// All writes go through Patch (PATCH semantics: keys not in the delta are
// left untouched; passing `null` for a key deletes it from the map so the
// default behaviour returns). Patch validates every key + every UUID
// before persisting; a single bad key fails the whole patch.
type ScenarioFlagsService struct {
	db       *sqlx.DB
	projects *ProjectService
}

func NewScenarioFlagsService(db *sqlx.DB, projects *ProjectService) *ScenarioFlagsService {
	return &ScenarioFlagsService{db: db, projects: projects}
}

// ScenarioFlagCatalogEntry mirrors one row of paliad.scenario_flag_catalog.
type ScenarioFlagCatalogEntry struct {
	FlagKey         string  `db:"flag_key"          json:"flag_key"`
	LabelDE         string  `db:"label_de"          json:"label_de"`
	LabelEN         string  `db:"label_en"          json:"label_en"`
	Description     *string `db:"description"       json:"description,omitempty"`
	HiddenUnlessSet bool    `db:"hidden_unless_set" json:"hidden_unless_set"`
}

// ScenarioFlagsView is the GET response shape — the live flag map plus
// the catalog the UI needs to render the scenario-flags strip.
type ScenarioFlagsView struct {
	Flags   map[string]bool            `json:"flags"`
	Catalog []ScenarioFlagCatalogEntry `json:"catalog"`
}

// rulePrefix is the prefix that distinguishes a per-rule selection
// entry from a named flag. Kept lowercase to match the catalog's
// CHECK constraint pattern.
const rulePrefix = "rule:"

// ruleKeyRe parses "rule:<uuid>" into the UUID portion. Uses the
// case-insensitive uuid regex so callers can paste either lower or
// uppercase UUIDs.
var ruleKeyRe = regexp.MustCompile(`^rule:([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$`)

// Get returns the current scenario state for a project. Visibility-gated
// via paliad.can_see_project (mirrors EventChoiceService.requireProjectVisible).
//
// The returned map is never nil; an empty object means "every rule takes
// the priority-driven default". The catalog is always populated so the
// UI can render the scenario-strip without a second round-trip.
func (s *ScenarioFlagsService) Get(ctx context.Context, userID, projectID uuid.UUID) (*ScenarioFlagsView, error) {
	if err := s.requireProjectVisible(ctx, userID, projectID); err != nil {
		return nil, err
	}

	var raw []byte
	err := s.db.GetContext(ctx, &raw,
		`SELECT scenario_flags FROM paliad.projects WHERE id = $1`, projectID)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, ErrNotVisible
	}
	if err != nil {
		return nil, fmt.Errorf("read scenario_flags: %w", err)
	}

	flags, err := decodeFlagMap(raw)
	if err != nil {
		return nil, fmt.Errorf("decode scenario_flags: %w", err)
	}

	catalog, err := s.ListCatalog(ctx)
	if err != nil {
		return nil, err
	}

	return &ScenarioFlagsView{Flags: flags, Catalog: catalog}, nil
}

// ListCatalog returns every paliad.scenario_flag_catalog row, ordered by
// added_at so the seeded with_ccr / with_amend / with_cci tier surfaces
// first and later-added flags appear after.
func (s *ScenarioFlagsService) ListCatalog(ctx context.Context) ([]ScenarioFlagCatalogEntry, error) {
	out := []ScenarioFlagCatalogEntry{}
	if err := s.db.SelectContext(ctx, &out,
		`SELECT flag_key, label_de, label_en, description, hidden_unless_set
		   FROM paliad.scenario_flag_catalog
		  ORDER BY added_at ASC, flag_key ASC`); err != nil {
		return nil, fmt.Errorf("list flag catalog: %w", err)
	}
	return out, nil
}

// Patch merges a partial delta into the project's scenario_flags. Per
// the design (§2.3): keys not in the delta are left untouched; a key
// set to `nil` (JSON null) is deleted from the map so the default
// returns; bool values are stored verbatim.
//
// Every key in the delta is validated before any write happens:
//
//   - keys matching "rule:<uuid>" must resolve to an active+published
//     sequencing_rule whose proceeding_type matches the project's
//     proceeding_type_id;
//   - all other keys must appear in paliad.scenario_flag_catalog.
//
// Bad keys raise ErrInvalidInput with a message that names the offending
// key. The whole patch is rejected on the first bad key — no partial
// writes.
func (s *ScenarioFlagsService) Patch(
	ctx context.Context,
	userID, projectID uuid.UUID,
	delta map[string]*bool,
) (*ScenarioFlagsView, error) {
	if err := s.requireProjectVisible(ctx, userID, projectID); err != nil {
		return nil, err
	}
	if len(delta) == 0 {
		return s.Get(ctx, userID, projectID)
	}

	if err := s.validateDelta(ctx, projectID, delta); err != nil {
		return nil, err
	}

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

	if err := setAuditReasonTx(ctx, tx,
		fmt.Sprintf("scenario-flags PATCH by user %s on project %s", userID, projectID)); err != nil {
		return nil, err
	}

	var raw []byte
	if err := tx.GetContext(ctx, &raw,
		`SELECT scenario_flags FROM paliad.projects WHERE id = $1 FOR UPDATE`,
		projectID); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return nil, ErrNotVisible
		}
		return nil, fmt.Errorf("lock project row: %w", err)
	}

	current, err := decodeFlagMap(raw)
	if err != nil {
		return nil, fmt.Errorf("decode current scenario_flags: %w", err)
	}

	for k, v := range delta {
		if v == nil {
			delete(current, k)
			continue
		}
		current[k] = *v
	}

	merged, err := json.Marshal(current)
	if err != nil {
		return nil, fmt.Errorf("encode merged scenario_flags: %w", err)
	}

	if _, err := tx.ExecContext(ctx,
		`UPDATE paliad.projects
		    SET scenario_flags = $1::jsonb,
		        updated_at     = now()
		  WHERE id = $2`, merged, projectID); err != nil {
		return nil, fmt.Errorf("write scenario_flags: %w", err)
	}

	if err := tx.Commit(); err != nil {
		return nil, fmt.Errorf("commit scenario-flags patch: %w", err)
	}

	catalog, err := s.ListCatalog(ctx)
	if err != nil {
		return nil, err
	}
	return &ScenarioFlagsView{Flags: current, Catalog: catalog}, nil
}

// validateDelta runs every key in the delta through the appropriate
// validator. Returns the first error it finds — callers receive
// ErrInvalidInput wrapped with the offending key.
func (s *ScenarioFlagsService) validateDelta(
	ctx context.Context,
	projectID uuid.UUID,
	delta map[string]*bool,
) error {
	var (
		ruleUUIDs  []uuid.UUID
		flagKeys   []string
		ruleIDsKey = map[string]uuid.UUID{}
	)
	for k := range delta {
		if k == "" {
			return fmt.Errorf("%w: empty key in scenario_flags delta", ErrInvalidInput)
		}
		if m := ruleKeyRe.FindStringSubmatch(k); m != nil {
			u, err := uuid.Parse(m[1])
			if err != nil {
				return fmt.Errorf("%w: %q has malformed UUID", ErrInvalidInput, k)
			}
			ruleUUIDs = append(ruleUUIDs, u)
			ruleIDsKey[k] = u
			continue
		}
		flagKeys = append(flagKeys, k)
	}

	if len(flagKeys) > 0 {
		known, err := s.knownFlagKeys(ctx, flagKeys)
		if err != nil {
			return err
		}
		for _, k := range flagKeys {
			if _, ok := known[k]; !ok {
				return fmt.Errorf("%w: scenario flag %q is not in scenario_flag_catalog", ErrInvalidInput, k)
			}
		}
	}

	if len(ruleUUIDs) > 0 {
		if err := s.validateRuleUUIDs(ctx, projectID, ruleUUIDs, ruleIDsKey, delta); err != nil {
			return err
		}
	}
	return nil
}

// knownFlagKeys returns the subset of `flagKeys` that exists in the
// catalog. Used to reject writes that name unknown flags.
func (s *ScenarioFlagsService) knownFlagKeys(ctx context.Context, flagKeys []string) (map[string]struct{}, error) {
	if len(flagKeys) == 0 {
		return map[string]struct{}{}, nil
	}
	rows, err := s.db.QueryContext(ctx,
		`SELECT flag_key FROM paliad.scenario_flag_catalog WHERE flag_key = ANY($1)`,
		pq.Array(flagKeys))
	if err != nil {
		return nil, fmt.Errorf("lookup flag catalog: %w", err)
	}
	defer rows.Close()
	out := map[string]struct{}{}
	for rows.Next() {
		var k string
		if err := rows.Scan(&k); err != nil {
			return nil, err
		}
		out[k] = struct{}{}
	}
	return out, rows.Err()
}

// validateRuleUUIDs ensures every rule:<uuid> entry in the delta
// references a sequencing_rule that:
//
//  1. exists, is active, and lifecycle_state='published'
//  2. belongs to the project's current proceeding_type_id
//  3. is NOT priority='mandatory' when the value is `false` (mandatory
//     rules cannot be deselected — that's a UX lie disguised as data)
func (s *ScenarioFlagsService) validateRuleUUIDs(
	ctx context.Context,
	projectID uuid.UUID,
	ids []uuid.UUID,
	keyByUUID map[string]uuid.UUID,
	delta map[string]*bool,
) error {
	var ptID sql.NullInt64
	if err := s.db.GetContext(ctx, &ptID,
		`SELECT proceeding_type_id FROM paliad.projects WHERE id = $1`,
		projectID); err != nil {
		return fmt.Errorf("load project proceeding_type_id: %w", err)
	}
	if !ptID.Valid {
		return fmt.Errorf("%w: project %s has no proceeding_type_id — per-rule selection entries require one", ErrInvalidInput, projectID)
	}

	type row struct {
		ID       uuid.UUID `db:"id"`
		Priority string    `db:"priority"`
	}
	rows := []row{}
	idStrs := make([]string, len(ids))
	for i, id := range ids {
		idStrs[i] = id.String()
	}
	if err := s.db.SelectContext(ctx, &rows,
		`SELECT id, priority
		   FROM paliad.sequencing_rules
		  WHERE id = ANY($1::uuid[])
		    AND proceeding_type_id = $2
		    AND is_active = true
		    AND lifecycle_state = 'published'`,
		pq.Array(idStrs), ptID.Int64); err != nil {
		return fmt.Errorf("validate rule UUIDs: %w", err)
	}
	priorityByID := make(map[uuid.UUID]string, len(rows))
	for _, r := range rows {
		priorityByID[r.ID] = r.Priority
	}
	for key, id := range keyByUUID {
		prio, ok := priorityByID[id]
		if !ok {
			return fmt.Errorf("%w: rule %s is not an active+published rule on the project's proceeding type", ErrInvalidInput, id)
		}
		val := delta[key]
		if val != nil && !*val && prio == "mandatory" {
			return fmt.Errorf("%w: rule %s is mandatory and cannot be deselected", ErrInvalidInput, id)
		}
	}
	return nil
}

func (s *ScenarioFlagsService) requireProjectVisible(ctx context.Context, userID, projectID uuid.UUID) error {
	visible, err := s.projects.CanSee(ctx, userID, projectID)
	if err != nil {
		return err
	}
	if !visible {
		return ErrNotVisible
	}
	return nil
}

// decodeFlagMap returns a (key → bool) map from the raw jsonb bytes.
// Stored values that aren't bool are silently dropped — they should
// never occur (the service rejects them on write) but defensive read
// avoids crashing the API if a hand-written row sneaks through.
func decodeFlagMap(raw []byte) (map[string]bool, error) {
	if len(raw) == 0 {
		return map[string]bool{}, nil
	}
	var anyMap map[string]any
	if err := json.Unmarshal(raw, &anyMap); err != nil {
		return nil, err
	}
	out := make(map[string]bool, len(anyMap))
	for k, v := range anyMap {
		if b, ok := v.(bool); ok {
			out[k] = b
		}
	}
	return out, nil
}
