package services

// AuditService produces a unified, paginated, filterable timeline across
// every audit source we keep in the paliad schema. There is no single
// audit_log table — instead we union five sources:
//
//   - paliad.project_events       — per-project audit (creates, updates, etc.)
//   - paliad.caldav_sync_log      — CalDAV push/pull outcomes per user
//   - paliad.reminder_log         — bundled-digest reminder sends
//   - paliad.partner_unit_events  — partner-unit CRUD + membership changes
//   - paliad.policy_audit_log     — approval-policy CRUD (t-paliad-154)
//   - paliad.system_audit_log     — org-wide / scope-spanning actions (t-paliad-214)
//
// The union happens in SQL (one round-trip, server-side ordering) and is
// keyset-paginated on (timestamp, id) DESC so the cursor stays stable across
// page boundaries even when new rows arrive at the head.
//
// Rendering decisions (event_type → human label, value-only → narrative)
// stay on the frontend so the i18n logic the dashboard already uses
// (translateEvent) can be reused. We send the raw event_type plus the raw
// stored title/description columns and let the client localise.

import (
	"context"
	"fmt"
	"strings"
	"time"

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

// Audit source discriminators. Stable strings — exposed in the JSON payload,
// referenced in the i18n keys, and used as filter values.
const (
	AuditSourceProjectEvents     = "project_events"
	AuditSourceCalDAVLog         = "caldav_sync_log"
	AuditSourceReminderLog       = "reminder_log"
	AuditSourcePartnerUnitEvents = "partner_unit_events"
	AuditSourcePolicyAuditLog    = "policy_audit_log"
	AuditSourceSystemAuditLog    = "system_audit_log"
)

// MaxAuditPageLimit caps a single ListEntries page.
const MaxAuditPageLimit = 200

// DefaultAuditPageLimit is the page size when the caller doesn't specify.
const DefaultAuditPageLimit = 50

// AuditEntry is one unified row in the global audit timeline.
type AuditEntry struct {
	Timestamp   time.Time  `db:"ts"          json:"timestamp"`
	ID          uuid.UUID  `db:"id"          json:"id"`
	Source      string     `db:"source"      json:"source"`
	EventType   string     `db:"event_type"  json:"event_type"`
	Actor       string     `db:"actor"       json:"actor"`
	Subject     string     `db:"subject"     json:"subject"`
	ProjectID   *uuid.UUID `db:"project_id"  json:"project_id,omitempty"`
	Title       *string    `db:"title"       json:"title,omitempty"`
	Description *string    `db:"description" json:"description,omitempty"`
}

// AuditFilter is the input to ListEntries.
type AuditFilter struct {
	// Source restricts to one source ("" = all).
	Source string
	// From / To restrict to an inclusive timestamp range. Zero values mean
	// "no bound on that side" — pass time.Time{} to skip.
	From time.Time
	To   time.Time
	// Search is a case-insensitive ILIKE match against subject + description
	// + title. Empty means no text filter.
	Search string
	// BeforeTS / BeforeID are the keyset cursor — return rows strictly older
	// than (BeforeTS, BeforeID). Both must be set to use cursor pagination;
	// if either is zero/nil the cursor is ignored.
	BeforeTS *time.Time
	BeforeID *uuid.UUID
	// Limit caps the page size. 0 → DefaultAuditPageLimit, capped at
	// MaxAuditPageLimit.
	Limit int
}

// AuditService unions paliad.project_events / caldav_sync_log / reminder_log
// into one timeline. Read-only — the rows themselves are written by the
// services that own each source.
type AuditService struct {
	db *sqlx.DB
}

func NewAuditService(db *sqlx.DB) *AuditService {
	return &AuditService{db: db}
}

// auditUnionSQL is the keyset-paginated UNION of all three audit sources.
//
// Each subquery normalises its row into the AuditEntry shape so the outer
// SELECT can ORDER BY a single (ts, id) tuple. Filters that are agnostic
// across sources (search, cursor) live in the outer query; per-source
// filters (date range, source restriction) live in each subquery so PG can
// prune sources via the WHERE before the union materialises.
//
// Parameters (positional):
//
//	$1 source (text)        — filter to one source, or "" for all
//	$2 from (timestamptz)   — inclusive lower bound, NULL = unbounded
//	$3 to   (timestamptz)   — inclusive upper bound, NULL = unbounded
//	$4 search (text)        — ILIKE substring against subject/description/title, NULL = none
//	$5 cursor_ts (timestamptz) — keyset upper bound, NULL = first page
//	$6 cursor_id (uuid)     — keyset tiebreaker, NULL = first page
//	$7 limit (int)
const auditUnionSQL = `
WITH unioned AS (
  SELECT
    'project_events'::text                      AS source,
    e.id                                        AS id,
    COALESCE(e.event_date, e.created_at)        AS ts,
    COALESCE(e.event_type, 'event')             AS event_type,
    COALESCE(u.email, 'system')                 AS actor,
    COALESCE(p.title, '—')                      AS subject,
    e.project_id                                AS project_id,
    e.title                                     AS title,
    e.description                               AS description
  FROM paliad.project_events e
  LEFT JOIN paliad.users    u ON u.id = e.created_by
  LEFT JOIN paliad.projects p ON p.id = e.project_id
  WHERE ($1::text IS NULL OR $1 = '' OR $1 = 'project_events')
    AND ($2::timestamptz IS NULL OR COALESCE(e.event_date, e.created_at) >= $2)
    AND ($3::timestamptz IS NULL OR COALESCE(e.event_date, e.created_at) <= $3)

  UNION ALL

  SELECT
    'caldav_sync_log'::text                     AS source,
    c.id                                        AS id,
    c.occurred_at                               AS ts,
    CASE
      WHEN c.error IS NOT NULL THEN 'caldav_sync_error'
      ELSE 'caldav_synced'
    END                                         AS event_type,
    'system'::text                              AS actor,
    COALESCE(u.email, c.user_id::text)          AS subject,
    NULL::uuid                                  AS project_id,
    NULL::text                                  AS title,
    COALESCE(
      c.error,
      format('direction=%s pushed=%s pulled=%s duration_ms=%s',
             c.direction, c.items_pushed, c.items_pulled,
             COALESCE(c.duration_ms, 0))
    )                                           AS description
  FROM paliad.caldav_sync_log c
  LEFT JOIN paliad.users u ON u.id = c.user_id
  WHERE ($1::text IS NULL OR $1 = '' OR $1 = 'caldav_sync_log')
    AND ($2::timestamptz IS NULL OR c.occurred_at >= $2)
    AND ($3::timestamptz IS NULL OR c.occurred_at <= $3)

  UNION ALL

  SELECT
    'reminder_log'::text                        AS source,
    r.id                                        AS id,
    r.sent_at                                   AS ts,
    r.reminder_type                             AS event_type,
    'system'::text                              AS actor,
    COALESCE(u.email, r.user_id::text)          AS subject,
    NULL::uuid                                  AS project_id,
    NULL::text                                  AS title,
    format('slot=%s slot_date=%s',
           COALESCE(r.slot, ''),
           COALESCE(r.slot_date::text, ''))     AS description
  FROM paliad.reminder_log r
  LEFT JOIN paliad.users u ON u.id = r.user_id
  WHERE ($1::text IS NULL OR $1 = '' OR $1 = 'reminder_log')
    AND ($2::timestamptz IS NULL OR r.sent_at >= $2)
    AND ($3::timestamptz IS NULL OR r.sent_at <= $3)

  UNION ALL

  SELECT
    'partner_unit_events'::text                 AS source,
    pue.id                                      AS id,
    pue.created_at                              AS ts,
    pue.event_type                              AS event_type,
    COALESCE(au.email, pue.actor_id::text)      AS actor,
    pue.unit_name                               AS subject,
    NULL::uuid                                  AS project_id,
    NULL::text                                  AS title,
    pue.payload::text                           AS description
  FROM paliad.partner_unit_events pue
  LEFT JOIN paliad.users au ON au.id = pue.actor_id
  WHERE ($1::text IS NULL OR $1 = '' OR $1 = 'partner_unit_events')
    AND ($2::timestamptz IS NULL OR pue.created_at >= $2)
    AND ($3::timestamptz IS NULL OR pue.created_at <= $3)

  UNION ALL

  -- t-paliad-154 — approval-policy CRUD audit. Project-scoped rows carry
  -- project_id (so the timeline filter by project still works on the
  -- /verlauf SELECT — but project_events is the source for that surface,
  -- not policy_audit_log, so no leakage). Description packs the field
  -- transition (entity_type/lifecycle: old → new).
  SELECT
    'policy_audit_log'::text                    AS source,
    pal.id                                      AS id,
    pal.created_at                              AS ts,
    pal.event_type                              AS event_type,
    COALESCE(au.email, pal.actor_id::text)      AS actor,
    pal.scope_name                              AS subject,
    pal.project_id                              AS project_id,
    NULL::text                                  AS title,
    format('%s/%s: %s → %s',
           pal.entity_type,
           pal.lifecycle_event,
           COALESCE(pal.old_required_role, '—'),
           COALESCE(pal.new_required_role, '—'))  AS description
  FROM paliad.policy_audit_log pal
  LEFT JOIN paliad.users au ON au.id = pal.actor_id
  WHERE ($1::text IS NULL OR $1 = '' OR $1 = 'policy_audit_log')
    AND ($2::timestamptz IS NULL OR pal.created_at >= $2)
    AND ($3::timestamptz IS NULL OR pal.created_at <= $3)

  UNION ALL

  -- t-paliad-214 — org-wide / scope-spanning actions. First user is the
  -- data-export audit chain. scope_root is the project_id for
  -- scope='project'; NULL otherwise. project_id forwarded so timeline
  -- filtering by project surfaces project-scope exports too.
  SELECT
    'system_audit_log'::text                    AS source,
    sal.id                                      AS id,
    sal.created_at                              AS ts,
    sal.event_type                              AS event_type,
    sal.actor_email                             AS actor,
    COALESCE(sal.scope, 'system')               AS subject,
    sal.scope_root                              AS project_id,
    NULL::text                                  AS title,
    sal.metadata::text                          AS description
  FROM paliad.system_audit_log sal
  WHERE ($1::text IS NULL OR $1 = '' OR $1 = 'system_audit_log')
    AND ($2::timestamptz IS NULL OR sal.created_at >= $2)
    AND ($3::timestamptz IS NULL OR sal.created_at <= $3)
)
SELECT source, id, ts, event_type, actor, subject, project_id, title, description
  FROM unioned
 WHERE ($4::text IS NULL OR (
          subject     ILIKE '%' || $4 || '%'
       OR COALESCE(description, '') ILIKE '%' || $4 || '%'
       OR COALESCE(title, '')       ILIKE '%' || $4 || '%'
       OR event_type  ILIKE '%' || $4 || '%'
       OR actor       ILIKE '%' || $4 || '%'
     ))
   AND ($5::timestamptz IS NULL OR (ts, id) < ($5, $6::uuid))
 ORDER BY ts DESC, id DESC
 LIMIT $7
`

// ListEntries returns one page of audit rows matching the filter. Caller is
// expected to have already verified the user is a global_admin — the service
// itself does no role enforcement (the gate happens at the route layer via
// auth.RequireAdminFunc).
func (s *AuditService) ListEntries(ctx context.Context, f AuditFilter) ([]AuditEntry, error) {
	limit := f.Limit
	if limit <= 0 {
		limit = DefaultAuditPageLimit
	}
	if limit > MaxAuditPageLimit {
		limit = MaxAuditPageLimit
	}

	// Convert zero-values to typed nulls so the SQL's COALESCE-style guards
	// behave correctly. database/sql treats nil interface{} as NULL, but a
	// zero time.Time would be sent as '0001-01-01' which is a valid value
	// PG would actually compare against — silently shifting all results.
	var sourceArg, searchArg any
	if f.Source != "" {
		sourceArg = f.Source
	}
	if s := strings.TrimSpace(f.Search); s != "" {
		// Escape SQL LIKE metacharacters so a search for "100%" doesn't act
		// as a wildcard. Backslash is the default LIKE escape in PG.
		escaped := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(s)
		searchArg = escaped
	}
	var fromArg, toArg any
	if !f.From.IsZero() {
		fromArg = f.From
	}
	if !f.To.IsZero() {
		toArg = f.To
	}
	var cursorTS, cursorID any
	if f.BeforeTS != nil && f.BeforeID != nil {
		cursorTS = *f.BeforeTS
		cursorID = *f.BeforeID
	}

	var rows []AuditEntry
	if err := s.db.SelectContext(ctx, &rows, auditUnionSQL,
		sourceArg, fromArg, toArg, searchArg, cursorTS, cursorID, limit,
	); err != nil {
		return nil, fmt.Errorf("audit list: %w", err)
	}
	return rows, nil
}
