package services

// FilterSpec is the structured filter description that drives the substrate
// (ViewService.RunSpec). Same shape lives in paliad.user_views.filter_spec
// (jsonb) and is composed by the frontend's view editor.
//
// Design: docs/design-data-display-model-2026-05-06.md §3 Q2.
//
// Validation rules live alongside the type. Every public mutation path
// (POST/PUT /api/user-views, /api/views/{slug}/run with override params)
// runs FilterSpec.Validate() before touching the DB or executing a query.

import (
	"encoding/json"
	"fmt"
	"slices"
	"time"

	"github.com/google/uuid"
)

// DataSource identifies one of the substrate's source rails.
type DataSource string

const (
	SourceDeadline        DataSource = "deadline"
	SourceAppointment     DataSource = "appointment"
	SourceProjectEvent    DataSource = "project_event"
	SourceApprovalRequest DataSource = "approval_request"
)

// AllSources lists every supported source. Used by the validator and by
// the "all sources" UI affordance.
var AllSources = []DataSource{
	SourceDeadline,
	SourceAppointment,
	SourceProjectEvent,
	SourceApprovalRequest,
}

// SpecVersion is the on-the-wire schema version. Bumped when the shape
// changes incompatibly. Validator rejects unknown versions so we get a
// clear error instead of silent misinterpretation.
const SpecVersion = 1

// FilterSpec is the top-level filter description.
//
// UnreadOnly (t-paliad-249) is an inbox-specific overlay: when true,
// project_event rows older than the caller's inbox_seen_at cursor are
// dropped. Pending approval_request rows always survive (the cursor
// can't bury an in-flight approval, per the design doc §3 carve-out).
// Set by the bar's `unread_only` axis on /inbox; other surfaces leave
// it false and the spec is a no-op.
//
// Predicates is a flat per-source narrowing record: keys at the top
// level are data sources ("deadline", "appointment", …) and values are
// the per-source predicate structs directly. The shape on the wire and
// the shape the frontend emits agree exactly — see t-paliad-283 for the
// latent contract bug (Go used to wrap each entry in another Predicates
// struct, so the frontend's overlay clicks parsed back as no-op).
type FilterSpec struct {
	Version    int          `json:"version"`
	Sources    []DataSource `json:"sources"`
	Scope      ScopeSpec    `json:"scope"`
	Time       TimeSpec     `json:"time"`
	Predicates *Predicates  `json:"predicates,omitempty"`
	UnreadOnly bool         `json:"unread_only,omitempty"`
}

// ScopeSpec narrows which projects contribute rows. Resolved at query
// time:
//   - "all_visible" (default): no extra narrowing; RLS bounds to projects
//     the caller can see (direct + descendant + derived per t-paliad-139).
//   - "my_subtree": narrows to the caller's direct/descendant/derived
//     staffing tree only. Equivalent to "all_visible" today (visibility
//     == subtree); reserved for future when global_admin or shared
//     visibility models add a wider tier.
//   - explicit slice: exact project IDs. RLS still applies — IDs the
//     caller can't see contribute zero rows (Q17 fail-open).
//
// PersonalOnly narrows deadline + appointment to caller-created rows.
// Mutually exclusive with explicit Projects (validator enforces). When
// PersonalOnly is true, project_event + approval_request sources still
// run with the standard visibility predicate — the "personal" framing
// only meaningfully applies to entity rows.
type ScopeSpec struct {
	Projects     ScopeProjects `json:"projects"`
	PersonalOnly bool          `json:"personal_only,omitempty"`
}

// ScopeProjects is the project-narrowing payload. Either a sentinel string
// ("all_visible" / "my_subtree") or an explicit []uuid.UUID.
//
// Encoded as a tagged shape on the wire so we don't have to teach a custom
// JSON unmarshaller a polymorphic field:
//
//	{"mode": "all_visible"}
//	{"mode": "my_subtree"}
//	{"mode": "explicit", "ids": ["uuid", …]}
type ScopeProjects struct {
	Mode ScopeMode   `json:"mode"`
	IDs  []uuid.UUID `json:"ids,omitempty"`
}

type ScopeMode string

const (
	ScopeAllVisible ScopeMode = "all_visible"
	ScopeMySubtree  ScopeMode = "my_subtree"
	ScopeExplicit   ScopeMode = "explicit"
)

// TimeSpec is the time-window narrowing. Horizon picks a relative window;
// when Horizon == "custom", From/To are honoured as an absolute window.
//
// Field selects which date column to filter against per source:
//   - "auto" (default): each source picks — deadline → due_date,
//     appointment → start_at, project_event → created_at,
//     approval_request → requested_at.
//   - "created_at": every source uses its own created_at column. Useful
//     for "newly added events" views.
type TimeSpec struct {
	Horizon TimeHorizon `json:"horizon"`
	Field   TimeField   `json:"field,omitempty"`
	From    *time.Time  `json:"from,omitempty"`
	To      *time.Time  `json:"to,omitempty"`
}

type TimeHorizon string

const (
	HorizonNext1d  TimeHorizon = "next_1d"
	HorizonNext7d  TimeHorizon = "next_7d"
	HorizonNext14d TimeHorizon = "next_14d"
	HorizonNext30d TimeHorizon = "next_30d"
	HorizonNext90d TimeHorizon = "next_90d"
	HorizonNextAll TimeHorizon = "next_all"
	HorizonPast1d  TimeHorizon = "past_1d"
	HorizonPast7d  TimeHorizon = "past_7d"
	HorizonPast14d TimeHorizon = "past_14d"
	HorizonPast30d TimeHorizon = "past_30d"
	HorizonPast90d TimeHorizon = "past_90d"
	HorizonPastAll TimeHorizon = "past_all"
	HorizonAny     TimeHorizon = "any"
	HorizonAll     TimeHorizon = "all"
	HorizonCustom  TimeHorizon = "custom"
)

type TimeField string

const (
	FieldAuto      TimeField = "auto"
	FieldCreatedAt TimeField = "created_at"
)

// Predicates is the per-source narrowing payload. Empty fields mean
// "no narrowing" — never "exclude all". One field per data source;
// the wire shape is the same: `{"deadline": {...}, "appointment": {...}}`.
type Predicates struct {
	Deadline        *DeadlinePredicates        `json:"deadline,omitempty"`
	Appointment     *AppointmentPredicates     `json:"appointment,omitempty"`
	ProjectEvent    *ProjectEventPredicates    `json:"project_event,omitempty"`
	ApprovalRequest *ApprovalRequestPredicates `json:"approval_request,omitempty"`
}

// DeadlinePredicates narrows the deadline rail.
type DeadlinePredicates struct {
	Status         []string    `json:"status,omitempty"`          // "pending" | "completed"
	ApprovalStatus []string    `json:"approval_status,omitempty"` // "approved" | "pending" | "legacy"
	EventTypeIDs   []uuid.UUID `json:"event_types,omitempty"`
	IncludeUntyped bool        `json:"include_untyped,omitempty"`
}

// AppointmentPredicates narrows the appointment rail.
type AppointmentPredicates struct {
	ApprovalStatus   []string `json:"approval_status,omitempty"`
	AppointmentTypes []string `json:"appointment_types,omitempty"` // hearing/meeting/consultation/deadline_hearing
}

// ProjectEventPredicates narrows the audit/Verlauf rail. EventTypes is the
// curated list of project-event kinds (project_created, status_changed,
// deadline_created, …) — see KnownProjectEventKinds.
type ProjectEventPredicates struct {
	EventTypes []string `json:"event_types,omitempty"`
}

// ApprovalRequestPredicates narrows the approval-inbox rail.
//
// ViewerRole shapes the per-row visibility:
//   - "approver_eligible": rows where the caller is qualified to approve
//     (the t-paliad-138 inbox path, extended by t-paliad-139 derivation).
//   - "self_requested": rows the caller submitted.
//   - "any_visible": every approval_request the caller can see via RLS
//     (includes both of the above plus already-decided rows on visible
//     projects). Used for retrospective/audit views.
type ApprovalRequestPredicates struct {
	ViewerRole  string   `json:"viewer_role,omitempty"`
	Status      []string `json:"status,omitempty"`       // "pending" | "approved" | "rejected" | "revoked"
	EntityTypes []string `json:"entity_types,omitempty"` // "deadline" | "appointment"
}

// KnownProjectEventKinds is the curated set of project_event.event_type
// values the substrate exposes via the filter UI. Code-resident per
// design Q19.
//
// Mirrors the strings emitted by insertProjectEvent / insertProjectEventWithMeta
// in internal/services/*. New kinds appear here as they're added in code.
var KnownProjectEventKinds = []string{
	"project_created",
	"project_archived",
	"project_reparented",
	"project_type_changed",
	"status_changed",
	"deadline_created",
	"deadline_completed",
	"deadline_reopened",
	"deadline_updated",
	"deadline_deleted",
	"deadlines_imported",
	"appointment_created",
	"appointment_updated",
	"appointment_deleted",
	"approval_decided",
	"member_role_changed",
	"note_created",
	"our_side_changed",
}

// InboxProjectEventKinds is the curated sub-list surfaced by default on
// /inbox (t-paliad-249, Slice A; head pick Q1=A on 2026-05-25).
//
// What's in:
//   - Lifecycle moves the team should notice: project_archived,
//     project_reparented, project_type_changed.
//   - Deadline / appointment authoring across the visible scope.
//   - Notes (`note_created`) and party-side flips
//     (`our_side_changed`).
//   - `member_role_changed` — Slice A surfaces it for everyone who can
//     see the project; Slice B narrows to "role change affects the
//     viewer or someone above them in the project tree" (head's
//     refinement #1).
//
// What's out:
//   - All `*_approval_*` event_types — duplicates of approval_request
//     rows. View-service drops them automatically when ApprovalRequest
//     is also in spec.Sources (see view_service.allowedProjectEventKinds).
//   - `status_changed`, `project_created` — too granular / authoring
//     noise.
//   - `checklist_*` — low signal; surfaces on the project's checklist
//     tab instead.
//
// Design ref: docs/design-inbox-overhaul-2026-05-25.md §2 + §12.
var InboxProjectEventKinds = []string{
	"project_archived",
	"project_reparented",
	"project_type_changed",
	"deadline_created",
	"deadline_completed",
	"deadline_reopened",
	"deadline_updated",
	"deadline_deleted",
	"deadlines_imported",
	"appointment_created",
	"appointment_updated",
	"appointment_deleted",
	"note_created",
	"our_side_changed",
	"member_role_changed",
}

// validApprovalStatuses are the legal values for entity-side approval_status
// filters and request-side status filters respectively.
var (
	validEntityApprovalStatuses  = []string{"approved", "pending", "legacy"}
	validRequestStatuses         = []string{"pending", "approved", "rejected", "revoked", "changes_requested"}
	validApprovalEntityTypes     = []string{"deadline", "appointment"}
	validApprovalViewerRoles     = []string{"approver_eligible", "self_requested", "any_visible"}
	validDeadlineStatuses        = []string{"pending", "completed"}
	validAppointmentTypes        = []string{"hearing", "meeting", "consultation", "deadline_hearing"}
)

// Validate runs the full FilterSpec contract. Returns a wrapped
// ErrInvalidInput on the first violation.
//
// The validator is the single gate that protects the DB from malformed
// jsonb. Both POST /api/user-views and runtime-override params on
// GET /api/views/{slug}/run go through it.
func (s *FilterSpec) Validate() error {
	if s == nil {
		return fmt.Errorf("%w: filter_spec is required", ErrInvalidInput)
	}
	if s.Version != SpecVersion {
		return fmt.Errorf("%w: filter_spec version %d not supported (expected %d)", ErrInvalidInput, s.Version, SpecVersion)
	}
	if len(s.Sources) == 0 {
		return fmt.Errorf("%w: at least one source must be selected", ErrInvalidInput)
	}
	seen := make(map[DataSource]bool, len(s.Sources))
	for _, src := range s.Sources {
		if !isKnownSource(src) {
			return fmt.Errorf("%w: unknown source %q", ErrInvalidInput, src)
		}
		if seen[src] {
			return fmt.Errorf("%w: duplicate source %q", ErrInvalidInput, src)
		}
		seen[src] = true
	}

	if err := s.Scope.validate(); err != nil {
		return err
	}
	if err := s.Time.validate(s.Scope); err != nil {
		return err
	}

	if s.Predicates != nil {
		// Reject predicates set on a source the spec doesn't list — we'd
		// silently drop the narrowing otherwise. Walk the set fields.
		type srcCheck struct {
			src     DataSource
			present bool
		}
		checks := []srcCheck{
			{SourceDeadline, s.Predicates.Deadline != nil},
			{SourceAppointment, s.Predicates.Appointment != nil},
			{SourceProjectEvent, s.Predicates.ProjectEvent != nil},
			{SourceApprovalRequest, s.Predicates.ApprovalRequest != nil},
		}
		for _, c := range checks {
			if c.present && !seen[c.src] {
				return fmt.Errorf("%w: predicates set on source %q which is not selected", ErrInvalidInput, c.src)
			}
		}
		if err := s.Predicates.validate(); err != nil {
			return err
		}
	}
	return nil
}

func (s *ScopeSpec) validate() error {
	switch s.Projects.Mode {
	case ScopeAllVisible, ScopeMySubtree:
		if len(s.Projects.IDs) > 0 {
			return fmt.Errorf("%w: scope.projects.ids must be empty when mode=%q", ErrInvalidInput, s.Projects.Mode)
		}
	case ScopeExplicit:
		if len(s.Projects.IDs) == 0 {
			return fmt.Errorf("%w: scope.projects.ids must be non-empty when mode=%q", ErrInvalidInput, ScopeExplicit)
		}
		if s.PersonalOnly {
			return fmt.Errorf("%w: scope.personal_only cannot be combined with explicit projects", ErrInvalidInput)
		}
	default:
		return fmt.Errorf("%w: unknown scope.projects.mode %q", ErrInvalidInput, s.Projects.Mode)
	}
	return nil
}

func (t *TimeSpec) validate(scope ScopeSpec) error {
	switch t.Horizon {
	case HorizonNext1d, HorizonNext7d, HorizonNext14d, HorizonNext30d, HorizonNext90d, HorizonNextAll,
		HorizonPast1d, HorizonPast7d, HorizonPast14d, HorizonPast30d, HorizonPast90d, HorizonPastAll,
		HorizonAny:
		// fine
	case HorizonAll:
		// Q26: reject "all" unless scope.projects is explicit. Performance
		// safeguard — an unbounded substrate query across every visible
		// project is the worst case and we want it gated by intent.
		if scope.Projects.Mode != ScopeExplicit {
			return fmt.Errorf("%w: time.horizon=%q requires scope.projects.mode=%q", ErrInvalidInput, HorizonAll, ScopeExplicit)
		}
	case HorizonCustom:
		if t.From == nil || t.To == nil {
			return fmt.Errorf("%w: time.horizon=%q requires both from and to", ErrInvalidInput, HorizonCustom)
		}
		if !t.To.After(*t.From) {
			return fmt.Errorf("%w: time.to must be strictly after time.from", ErrInvalidInput)
		}
	default:
		return fmt.Errorf("%w: unknown time.horizon %q", ErrInvalidInput, t.Horizon)
	}

	switch t.Field {
	case "", FieldAuto, FieldCreatedAt:
		// fine
	default:
		return fmt.Errorf("%w: unknown time.field %q", ErrInvalidInput, t.Field)
	}
	return nil
}

func (p *Predicates) validate() error {
	if p.Deadline != nil {
		if err := validateStringEnum("deadline.status", p.Deadline.Status, validDeadlineStatuses); err != nil {
			return err
		}
		if err := validateStringEnum("deadline.approval_status", p.Deadline.ApprovalStatus, validEntityApprovalStatuses); err != nil {
			return err
		}
	}
	if p.Appointment != nil {
		if err := validateStringEnum("appointment.approval_status", p.Appointment.ApprovalStatus, validEntityApprovalStatuses); err != nil {
			return err
		}
		if err := validateStringEnum("appointment.appointment_types", p.Appointment.AppointmentTypes, validAppointmentTypes); err != nil {
			return err
		}
	}
	if p.ProjectEvent != nil {
		if err := validateStringEnum("project_event.event_types", p.ProjectEvent.EventTypes, KnownProjectEventKinds); err != nil {
			return err
		}
	}
	if p.ApprovalRequest != nil {
		if r := p.ApprovalRequest.ViewerRole; r != "" {
			if err := validateStringEnum("approval_request.viewer_role", []string{r}, validApprovalViewerRoles); err != nil {
				return err
			}
		}
		if err := validateStringEnum("approval_request.status", p.ApprovalRequest.Status, validRequestStatuses); err != nil {
			return err
		}
		if err := validateStringEnum("approval_request.entity_types", p.ApprovalRequest.EntityTypes, validApprovalEntityTypes); err != nil {
			return err
		}
	}
	return nil
}

func validateStringEnum(field string, values, allowed []string) error {
	for _, v := range values {
		if !slices.Contains(allowed, v) {
			return fmt.Errorf("%w: unknown %s value %q", ErrInvalidInput, field, v)
		}
	}
	return nil
}

func isKnownSource(s DataSource) bool {
	return slices.Contains(AllSources, s)
}

// DefaultFilterSpec returns a minimal valid spec — used as the seed when
// the editor opens with a blank canvas.
func DefaultFilterSpec() FilterSpec {
	return FilterSpec{
		Version: SpecVersion,
		Sources: []DataSource{SourceDeadline, SourceAppointment},
		Scope:   ScopeSpec{Projects: ScopeProjects{Mode: ScopeAllVisible}},
		Time:    TimeSpec{Horizon: HorizonNext30d, Field: FieldAuto},
	}
}

// MarshalFilterSpec is a convenience wrapper used by handlers when storing
// the spec into the jsonb column. Pre-validates so a malformed spec never
// reaches the DB.
func MarshalFilterSpec(s FilterSpec) ([]byte, error) {
	if err := s.Validate(); err != nil {
		return nil, err
	}
	return json.Marshal(s)
}

// UnmarshalFilterSpec parses + validates a stored / submitted spec.
func UnmarshalFilterSpec(b []byte) (FilterSpec, error) {
	var s FilterSpec
	if err := json.Unmarshal(b, &s); err != nil {
		return FilterSpec{}, fmt.Errorf("%w: filter_spec malformed: %v", ErrInvalidInput, err)
	}
	if err := s.Validate(); err != nil {
		return FilterSpec{}, err
	}
	return s, nil
}
