'use server'

import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { createClient } from '@/lib/supabase/server'
import { getAppOrg } from '@/lib/org'
import { getEffectiveUser, getViewAsImportScope } from '@/lib/effective-user'
import { getEffectiveBilloutRate, getTeamForUser } from '@/lib/teams'
import { guardEntry, guardEntryIds } from '@/lib/view-as-guards'
import { canEditEntry, canManageImports, canTransfer } from '@/lib/permissions'
import { readFiltersFromSearchParams } from '@/lib/filters'

function fail(msg: string, editId?: string): never {
  const params = new URLSearchParams({ error: msg })
  if (editId) params.set('edit', editId)
  redirect(`/entries?${params.toString()}`)
}

function safeReturnUrl(formData: FormData): string {
  const raw = String(formData.get('_return_url') ?? '').trim()
  if (raw && raw.startsWith('/entries')) return raw
  return '/entries'
}

function parseSeconds(raw: FormDataEntryValue | null): number | null {
  if (raw == null || String(raw).trim() === '') return null
  const s = String(raw).trim()
  const hms = s.match(/^(\d+):(\d{2}):(\d{2})$/)
  if (hms) return Number(hms[1]) * 3600 + Number(hms[2]) * 60 + Number(hms[3])
  const hm = s.match(/^(\d+):(\d{2})$/)
  if (hm) return Number(hm[1]) * 3600 + Number(hm[2]) * 60
  const dec = Number(s)
  if (Number.isFinite(dec) && dec >= 0) return Math.round(dec * 3600)
  return null
}

function parseTimestamp(raw: FormDataEntryValue | null): string | null {
  if (raw == null) return null
  const s = String(raw).trim()
  if (!s) return null
  const m = s.match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2})(?::(\d{2}))?$/)
  if (!m) return null
  return `${m[1]} ${m[2]}:${m[3] ?? '00'}`
}

function computeCost(billoutSeconds: number | null, baseRate: number): number | null {
  if (billoutSeconds == null) return null
  return Math.round((billoutSeconds / 3600) * baseRate * 10000) / 10000
}

/**
 * Look up an operator by case-insensitive name; create it if missing.
 * Returns the canonical entity row.
 */
async function resolveOrCreateOperator(
  supabase: SupabaseClient,
  orgId: string,
  name: string,
): Promise<{ id: string; name: string }> {
  const { data: existing } = await supabase
    .from('operators')
    .select('id, name')
    .eq('org_id', orgId)
    .ilike('name', name)
    .maybeSingle<{ id: string; name: string }>()
  if (existing) return existing
  const { data: created, error } = await supabase
    .from('operators')
    .insert({ org_id: orgId, name })
    .select('id, name')
    .single<{ id: string; name: string }>()
  if (error || !created) fail(`Could not create operator "${name}": ${error?.message}`)
  return created
}

async function resolveOrCreateClient(
  supabase: SupabaseClient,
  orgId: string,
  operatorId: string,
  name: string,
): Promise<{ id: string; name: string }> {
  const { data: existing } = await supabase
    .from('clients')
    .select('id, name')
    .eq('operator_id', operatorId)
    .ilike('name', name)
    .maybeSingle<{ id: string; name: string }>()
  if (existing) return existing
  const { data: created, error } = await supabase
    .from('clients')
    .insert({ org_id: orgId, operator_id: operatorId, name })
    .select('id, name')
    .single<{ id: string; name: string }>()
  if (error || !created) fail(`Could not create client "${name}": ${error?.message}`)
  return created
}

async function resolveOrCreateProject(
  supabase: SupabaseClient,
  orgId: string,
  clientId: string,
  name: string,
): Promise<{ id: string; name: string }> {
  const { data: existing } = await supabase
    .from('projects')
    .select('id, name')
    .eq('client_id', clientId)
    .ilike('name', name)
    .maybeSingle<{ id: string; name: string }>()
  if (existing) return existing
  const { data: created, error } = await supabase
    .from('projects')
    .insert({ org_id: orgId, client_id: clientId, name })
    .select('id, name')
    .single<{ id: string; name: string }>()
  if (error || !created) fail(`Could not create project "${name}": ${error?.message}`)
  return created
}

// Forward-declared type so the helpers above can use it. The actual
// SupabaseClient type alias is defined below where the bulk actions
// live. We declare it again here as a local alias for clarity.
type SupabaseClient = Awaited<ReturnType<typeof createClient>>

/**
 * Find (or lazily create) the user's "Manual entries" import batch.
 * Manual single-row inserts attach to this so they show up as a batch
 * in the imports list (and respect importer-scoping). One batch per
 * (org, user) — we accumulate every manual entry into it.
 */
async function getOrCreateManualBatch(
  supabase: SupabaseClient,
  orgId: string,
  userId: string,
): Promise<string> {
  const { data: existing } = await supabase
    .from('clockify_imports')
    .select('id')
    .eq('org_id', orgId)
    .eq('imported_by', userId)
    .eq('source', 'manual')
    .ilike('name', 'Manual entries')
    .maybeSingle<{ id: string }>()
  if (existing) return existing.id
  const { data: created, error } = await supabase
    .from('clockify_imports')
    .insert({
      org_id: orgId,
      imported_by: userId,
      name: 'Manual entries',
      filename: null,
      row_count: 0,
      source: 'manual',
      notes: 'Auto-created bucket for manually-added entries.',
    })
    .select('id')
    .single<{ id: string }>()
  if (error || !created) {
    fail(`Could not create the manual-entries batch: ${error?.message}`)
  }
  return created.id
}

/**
 * Manually create one time entry. Same shape as updateEntry but
 * insert-instead-of-update, with sensible defaults: start_at = date at
 * 00:00:00 (or with the optional start_time), end_at = start_at +
 * duration, source_user_email = current user's email (lookups go
 * against the user's team, not Adi's).
 *
 * Form fields (all required unless noted):
 *   - date          YYYY-MM-DD
 *   - start_time    HH:MM[:SS]  (optional, default 00:00:00)
 *   - duration      HH:MM:SS or decimal hours
 *   - operator, client, project
 *   - description
 *   - source_user_email  (optional, defaults to current user's email)
 */
export async function createEntry(formData: FormData) {
  const supabase = await createClient()
  const org = await getAppOrg(supabase)
  if (!org) fail('Organization not found.')

  const eu = await getEffectiveUser(supabase, org.id)
  if (!eu || !canManageImports(eu)) {
    fail('You do not have permission to add entries.')
  }

  // Parse + validate inputs.
  const dateRaw = String(formData.get('date') ?? '').trim()
  const dateMatch = dateRaw.match(/^(\d{4})-(\d{2})-(\d{2})$/)
  if (!dateMatch) fail('Date must be YYYY-MM-DD.')

  const startTimeRaw = String(formData.get('start_time') ?? '').trim()
  let startTime = '00:00:00'
  if (startTimeRaw) {
    const m = startTimeRaw.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/)
    if (!m) fail('Start time must be HH:MM or HH:MM:SS.')
    const h = Number(m[1])
    const mi = Number(m[2])
    const s = m[3] ? Number(m[3]) : 0
    if (h > 23 || mi > 59 || s > 59) fail('Start time out of range.')
    startTime = `${h.toString().padStart(2, '0')}:${mi.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
  }

  const durationSeconds = parseSeconds(formData.get('duration'))
  if (durationSeconds == null || durationSeconds < 0) {
    fail('Duration is required (HH:MM:SS or decimal hours).')
  }

  const startAt = `${dateRaw} ${startTime}`
  // End time = start + duration. Naive local time; if it crosses
  // midnight we just let the next day's date land on end_at.
  function addSeconds(naive: string, seconds: number): string {
    // naive: 'YYYY-MM-DD HH:MM:SS'
    const m = naive.match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/)
    if (!m) return naive
    const y = Number(m[1])
    const mo = Number(m[2])
    const d = Number(m[3])
    const hh = Number(m[4])
    const mi = Number(m[5])
    const ss = Number(m[6])
    // Build a Date in UTC just for the arithmetic; format back as naive local.
    const t = Date.UTC(y, mo - 1, d, hh, mi, ss) + seconds * 1000
    const dt = new Date(t)
    const Y = dt.getUTCFullYear()
    const M = (dt.getUTCMonth() + 1).toString().padStart(2, '0')
    const D = dt.getUTCDate().toString().padStart(2, '0')
    const H = dt.getUTCHours().toString().padStart(2, '0')
    const Mi = dt.getUTCMinutes().toString().padStart(2, '0')
    const Ss = dt.getUTCSeconds().toString().padStart(2, '0')
    return `${Y}-${M}-${D} ${H}:${Mi}:${Ss}`
  }
  const endAt = addSeconds(startAt, durationSeconds)

  const operatorIn = String(formData.get('operator') ?? '').trim() || null
  const clientIn = String(formData.get('client') ?? '').trim() || null
  const projectIn = String(formData.get('project') ?? '').trim() || null
  const description = String(formData.get('description') ?? '').trim()
  if (!description) fail('Description is required.')

  // Team member is picked explicitly via the dropdown. The picked
  // member's row is the canonical source of truth for
  // source_user_email / source_user_name / converted_user / cost rate /
  // billout rate / rate_proportion. This replaces the old indirect
  // model of "edit source_user_email → re-resolve".
  const teamMemberId =
    String(formData.get('team_member_id') ?? '').trim() || null
  if (!teamMemberId) {
    fail('Pick a team member — entries must be billable to someone.')
  }
  const { data: member } = await supabase
    .from('team_members')
    .select(
      'id, email, display_name, rate_proportion, consolidate_as, cost_rate_usd, billout_rate_usd, team_id',
    )
    .eq('org_id', org.id)
    .eq('id', teamMemberId)
    .maybeSingle<{
      id: string
      email: string
      display_name: string
      rate_proportion: number
      consolidate_as: string | null
      cost_rate_usd: number | string | null
      billout_rate_usd: number | string | null
      team_id: string
    }>()
  if (!member) {
    fail('Selected team member not found (or not visible to you).')
  }

  // Stamp the audit fields from the picked team_member so historical
  // identity (source_*) and consolidated identity (converted_user)
  // stay aligned for newly-created rows.
  const sourceEmail = member.email
  const sourceUserName = member.display_name

  // Resolve / create the entity chain (operator → client → project)
  // so the new row is linked the same way as imports + per-row edits.
  let projectId: string | null = null
  let operatorOut: string | null = operatorIn
  let clientOut: string | null = clientIn
  let projectOut: string | null = projectIn
  if (operatorIn && clientIn && projectIn) {
    const op = await resolveOrCreateOperator(supabase, org.id, operatorIn)
    operatorOut = op.name
    const cl = await resolveOrCreateClient(supabase, org.id, op.id, clientIn)
    clientOut = cl.name
    const pr = await resolveOrCreateProject(supabase, org.id, cl.id, projectIn)
    projectOut = pr.name
    projectId = pr.id
  }

  const convertedUser = member
    ? member.consolidate_as ?? member.display_name
    : null
  // converted_duration_seconds is kept for the Toggl CSV export only.
  const convertedSeconds = member
    ? Math.round(durationSeconds * Number(member.rate_proportion))
    : null
  // Cost = source × per-member cost_rate. team.base_rate is vestigial.
  const memberCostRate =
    member?.cost_rate_usd == null ? null : Number(member.cost_rate_usd)
  const billoutCost =
    memberCostRate == null
      ? null
      : Math.round((durationSeconds / 3600) * memberCostRate * 100) / 100
  // Effective billout rate (override-aware) for the resolved project.
  const memberBilloutRate = member
    ? await getEffectiveBilloutRate(supabase, member.id, projectId)
    : null
  // Billout = source × effective rate (per-source-hour after the
  // eliminate-proportion migration).
  const billoutAmount =
    memberBilloutRate != null
      ? Math.round((durationSeconds / 3600) * memberBilloutRate * 100) / 100
      : null

  const batchId = await getOrCreateManualBatch(
    supabase,
    org.id,
    eu.effective_user_id,
  )

  const { data: inserted, error } = await supabase
    .from('time_entries')
    .insert({
      org_id: org.id,
      import_id: batchId,
      project_id: projectId,
      source_user_name: sourceUserName,
      source_user_email: sourceEmail,
      operator: operatorOut,
      client: clientOut,
      project: projectOut,
      description,
      billable: true,
      start_at: startAt,
      end_at: endAt,
      duration_seconds: durationSeconds,
      team_member_id: member?.id ?? null,
      converted_user: convertedUser,
      converted_duration_seconds: convertedSeconds,
      billout_cost_usd: billoutCost,
      billout_amount_usd: billoutAmount,
    })
    .select('id')
    .single<{ id: string }>()
  if (error || !inserted) {
    fail(`Could not create entry: ${error?.message}`)
  }

  revalidatePath('/entries')
  revalidatePath('/projects')
  revalidatePath('/transfer')
  revalidatePath('/')
  // Stay on /entries so the user can quickly add another. Pass an info
  // banner with the new entry's id so the page can highlight it.
  const ret = String(formData.get('_return_url') ?? '').trim() || '/entries'
  const url = new URL(ret, 'http://x')
  url.searchParams.set('info', `Created entry ${inserted.id.slice(0, 8)}…`)
  redirect(url.pathname + (url.search || ''))
}

export async function updateEntry(formData: FormData) {
  const id = String(formData.get('id') ?? '')
  if (!id) fail('Missing entry id.')

  const supabase = await createClient()
  const org = await getAppOrg(supabase)
  if (!org) fail('Organization not found.', id)

  const eu = await getEffectiveUser(supabase, org.id)

  // Check permission against the entry's transferred status, and grab
  // team_member_id / import_id so we can look up the right team rate
  // when re-computing billout_cost_usd.
  const { data: existing } = await supabase
    .from('time_entries')
    .select('transferred_at, invoice_id, team_member_id, import_id')
    .eq('org_id', org.id)
    .eq('id', id)
    .maybeSingle<{
      transferred_at: string | null
      invoice_id: string | null
      team_member_id: string | null
      import_id: string | null
    }>()
  if (!existing) fail('Entry not found.', id)
  if (!canEditEntry(eu, existing)) {
    fail('This entry is attached to an invoice and locked. Only an owner can edit it.', id)
  }
  const vasError = await guardEntry(supabase, eu, org.id, id)
  if (vasError) fail(vasError, id)

  // Team member is now picked explicitly via the dropdown on the edit
  // form. We look up the picked member's row to derive cost / billout /
  // converted_user. Existing.source_user_email + source_user_name are
  // audit history and are preserved as-is (the form no longer offers
  // them as editable inputs).
  const teamMemberIdRaw =
    String(formData.get('team_member_id') ?? '').trim() || null
  let member: {
    id: string
    display_name: string
    rate_proportion: number
    consolidate_as: string | null
    cost_rate_usd: number | string | null
  } | null = null
  if (teamMemberIdRaw) {
    const { data } = await supabase
      .from('team_members')
      .select(
        'id, display_name, rate_proportion, consolidate_as, cost_rate_usd',
      )
      .eq('org_id', org.id)
      .eq('id', teamMemberIdRaw)
      .maybeSingle<{
        id: string
        display_name: string
        rate_proportion: number
        consolidate_as: string | null
        cost_rate_usd: number | string | null
      }>()
    if (!data) {
      fail('Selected team member not found (or not visible to you).', id)
    }
    member = data
  }
  const newTeamMemberId = member?.id ?? null
  const costRate =
    member?.cost_rate_usd == null ? null : Number(member.cost_rate_usd)
  const convertedUser = member
    ? member.consolidate_as ?? member.display_name
    : null

  const startAt = parseTimestamp(formData.get('start_at'))
  if (!startAt) fail('Start date/time is required.', id)

  // Source duration drives both cost and billout after the
  // eliminate-proportion migration (rates are now per-source-hour).
  // converted_duration_seconds is re-derived from the picked member's
  // rate_proportion so the Toggl export reflects the current member.
  const durationSec = parseSeconds(formData.get('duration_seconds'))
  const convertedSec =
    member != null && durationSec != null
      ? Math.round(durationSec * Number(member.rate_proportion))
      : null

  const operatorIn = String(formData.get('operator') ?? '').trim() || null
  const clientIn = String(formData.get('client') ?? '').trim() || null
  const projectIn = String(formData.get('project') ?? '').trim() || null

  // Resolve (or auto-create) the entity chain so this entry stays
  // properly linked. If the user typed a new operator / client /
  // project, this creates the entity now. The canonical names that
  // come back from the DB go into the text-column cache.
  let projectId: string | null = null
  let operatorOut = operatorIn
  let clientOut = clientIn
  let projectOut = projectIn
  if (operatorIn && clientIn && projectIn) {
    const { id: opId, name: opName } = await resolveOrCreateOperator(
      supabase,
      org.id,
      operatorIn,
    )
    operatorOut = opName
    const { id: clId, name: clName } = await resolveOrCreateClient(
      supabase,
      org.id,
      opId,
      clientIn,
    )
    clientOut = clName
    const { id: prId, name: prName } = await resolveOrCreateProject(
      supabase,
      org.id,
      clId,
      projectIn,
    )
    projectOut = prName
    projectId = prId
  }

  // Effective billout rate: project-override-aware. Uses the picked
  // team_member (which might differ from the existing one).
  const billoutRate = newTeamMemberId
    ? await getEffectiveBilloutRate(supabase, newTeamMemberId, projectId)
    : null

  const payload = {
    team_member_id: newTeamMemberId,
    converted_user: convertedUser,
    operator: operatorOut,
    client: clientOut,
    project: projectOut,
    project_id: projectId,
    description: String(formData.get('description') ?? '').trim() || null,
    billable: formData.get('billable') === 'on',
    start_at: startAt,
    end_at: parseTimestamp(formData.get('end_at')),
    duration_seconds: durationSec,
    converted_duration_seconds: convertedSec,
    billout_cost_usd:
      costRate != null && durationSec != null
        ? Math.round((durationSec / 3600) * costRate * 100) / 100
        : null,
    billout_amount_usd:
      billoutRate != null && durationSec != null
        ? Math.round((durationSec / 3600) * billoutRate * 100) / 100
        : null,
  }

  const { error } = await supabase
    .from('time_entries')
    .update(payload)
    .eq('org_id', org.id)
    .eq('id', id)

  if (error) fail(error.message, id)

  revalidatePath('/entries')
  revalidatePath('/transfer')
  revalidatePath('/')
  redirect(safeReturnUrl(formData))
}

/**
 * Re-apply the team_member conversion using the entry's current source_user_email
 * and the org's CURRENT base rate. Cost is re-locked to the current values.
 */
export async function resolveEntry(formData: FormData) {
  const id = String(formData.get('id') ?? '')
  if (!id) fail('Missing entry id.')

  const supabase = await createClient()
  const org = await getAppOrg(supabase)
  if (!org) fail('Organization not found.')

  const eu = await getEffectiveUser(supabase, org.id)

  const { data: entry } = await supabase
    .from('time_entries')
    .select('source_user_email, duration_seconds, transferred_at, invoice_id, import_id, project_id')
    .eq('org_id', org.id)
    .eq('id', id)
    .maybeSingle<{
      source_user_email: string | null
      duration_seconds: number | null
      transferred_at: string | null
      invoice_id: string | null
      import_id: string | null
      project_id: string | null
    }>()
  if (!entry) fail('Entry not found.')
  if (!canEditEntry(eu, entry)) {
    fail('This entry is attached to an invoice and locked. Only an owner can re-resolve it.')
  }
  const vasError = await guardEntry(supabase, eu, org.id, id)
  if (vasError) fail(vasError)

  // The team to look up against is the team owned by whoever uploaded
  // this entry's batch. (Adi's CSV resolves against Adi's roster;
  // Rian's CSV resolves against Rian's.) Post eliminate-proportion the
  // team's base_rate is vestigial — cost is per-member cost_rate_usd.
  let lookupTeamId: string | null = null
  if (entry.import_id) {
    const { data: imp } = await supabase
      .from('clockify_imports')
      .select('imported_by')
      .eq('id', entry.import_id)
      .maybeSingle<{ imported_by: string | null }>()
    if (imp?.imported_by) {
      const t = await getTeamForUser(supabase, org.id, imp.imported_by)
      if (t) {
        lookupTeamId = t.id
      }
    }
  }
  if (!lookupTeamId) {
    fail("Couldn't find the team for this entry's import. Open a comment for support.")
  }

  const ret = safeReturnUrl(formData)
  const retWithInfo = (msg: string) => {
    const url = new URL(ret, 'http://x')
    url.searchParams.set('info', msg)
    return url.pathname + (url.search || '')
  }

  if (!entry.source_user_email) {
    await supabase
      .from('time_entries')
      .update({
        team_member_id: null,
        converted_user: null,
        converted_duration_seconds: null,
        billout_cost_usd: null,
      })
      .eq('org_id', org.id)
      .eq('id', id)
    revalidatePath('/entries')
    redirect(retWithInfo('Cleared conversion — no source email.'))
  }

  const { data: member } = await supabase
    .from('team_members')
    .select(
      'id, display_name, rate_proportion, consolidate_as, cost_rate_usd, billout_rate_usd',
    )
    .eq('team_id', lookupTeamId)
    .eq('email', entry.source_user_email)
    .maybeSingle<{
      id: string
      display_name: string
      rate_proportion: number
      consolidate_as: string | null
      cost_rate_usd: number | string | null
      billout_rate_usd: number | string | null
    }>()

  if (!member) {
    fail(
      `No team member with email ${entry.source_user_email} in this batch's team. Add them on the Team page first.`,
    )
  }

  const convertedUser = member.consolidate_as ?? member.display_name
  // convert_duration_seconds is preserved for the Toggl-export legacy
  // (proportioned hours). Cost + billout are computed off source.
  const convertedSeconds =
    entry.duration_seconds == null
      ? null
      : Math.round(entry.duration_seconds * Number(member.rate_proportion))
  const memberCostRate =
    member.cost_rate_usd == null ? null : Number(member.cost_rate_usd)
  const cost =
    memberCostRate != null && entry.duration_seconds != null
      ? Math.round((entry.duration_seconds / 3600) * memberCostRate * 100) / 100
      : null
  // Effective billout rate (override-aware). The entry already has a
  // project_id we can use to find any per-project override.
  const memberBilloutRate = await getEffectiveBilloutRate(
    supabase,
    member.id,
    entry.project_id,
  )
  const billoutAmount =
    memberBilloutRate != null && entry.duration_seconds != null
      ? Math.round((entry.duration_seconds / 3600) * memberBilloutRate * 100) / 100
      : null

  const { error } = await supabase
    .from('time_entries')
    .update({
      team_member_id: member.id,
      converted_user: convertedUser,
      converted_duration_seconds: convertedSeconds,
      billout_cost_usd: cost,
      billout_amount_usd: billoutAmount,
    })
    .eq('org_id', org.id)
    .eq('id', id)
  if (error) fail(error.message)

  revalidatePath('/entries')
  revalidatePath('/transfer')
  redirect(retWithInfo('Re-resolved.'))
}

/**
 * Reconstruct the filter bounds from the `filter_*` form fields written by
 * EntriesTable in "select all matching" mode. Mirrors the page's filter logic.
 */
function readBulkFilter(formData: FormData) {
  const sp: Record<string, string> = {}
  for (const k of [
    'date',
    'start',
    'end',
    'status',
    'batch',
    'q',
    'project',
    'user',
    'source_email',
    'client',
    'operator',
    'imported_by',
  ]) {
    const v = formData.get(`filter_${k}`)
    if (v != null) sp[k] = String(v)
  }
  // CRITICAL: pass the same defaults /entries/page.tsx uses. Otherwise
  // when the URL has no `date` param, the page treats it as `all` but
  // readFiltersFromSearchParams' built-in fallback is `this-month` —
  // and the bulk action ends up filtering to a different window than
  // the user is looking at. Same for status. See ADR #034.
  return readFiltersFromSearchParams(sp, {
    date: 'all',
    status: 'pending',
  })
}

// Columns required to be non-NULL for a row to count as "pending" / eligible
// for invoice application. Mirrors the SQL predicate in
// `entries_filter_totals` / `entries_filter_options` / `project_summary`
// and the page's applyStatus. The Toggl-export-only converted_*
// columns are no longer in the list — they're not needed for invoices.
const ELIGIBILITY_COLUMNS = [
  'team_member_id',
  'operator',
  'client',
  'project',
  'description',
  'end_at',
  'source_user_email',
] as const

/**
 * Returns a wrapper object so callers can `await` without JS double-unwrapping
 * the PostgREST builder (which is itself thenable, so a bare `Promise<Builder>`
 * fires the underlying HTTP request when awaited).
 *
 * Caller pattern:
 *   const { q: filtered } = await applyBulkFilter(supabase, org.id, q, filter)
 *   q = filtered
 *
 * If you reassign with `q = await applyBulkFilter(...)`, `q` will be the
 * QUERY RESPONSE, not the builder — and the UPDATE will have already executed.
 */
async function applyBulkFilter(
  supabase: SupabaseClient,
  orgId: string,
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  query: any,
  filter: ReturnType<typeof readBulkFilter>,
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
): Promise<{ q: any }> {
  let q = query

  // View-as scope: when a super-admin is acting as a non-owner, restrict
  // every bulk write to that effective user's batches. RLS only enforces
  // this for the real JWT user, so we mirror it here.
  const eu = await getEffectiveUser(supabase, orgId)
  const viewAsScope = await getViewAsImportScope(supabase, orgId, eu)
  if (viewAsScope) q = q.in('import_id', viewAsScope)

  if (filter.startBound) q = q.gte('start_at', filter.startBound)
  if (filter.endBound) q = q.lt('start_at', filter.endBound)
  if (filter.batch) q = q.eq('import_id', filter.batch)
  if (filter.q) {
    const like = `%${filter.q}%`
    q = q.or(
      `project.ilike.${like},description.ilike.${like},source_user_email.ilike.${like},client.ilike.${like}`,
    )
  }
  if (filter.project) q = q.ilike('project', `%${filter.project}%`)
  if (filter.user) q = q.ilike('converted_user', `%${filter.user}%`)
  if (filter.source_email)
    q = q.ilike('source_user_email', `%${filter.source_email}%`)
  if (filter.client) q = q.ilike('client', `%${filter.client}%`)
  if (filter.operator) q = q.ilike('operator', `%${filter.operator}%`)
  if (filter.imported_by) {
    const { data } = await supabase
      .from('clockify_imports')
      .select('id')
      .eq('org_id', orgId)
      .eq('imported_by', filter.imported_by)
      .returns<{ id: string }[]>()
    const ids = (data ?? []).map((r) => r.id)
    if (ids.length === 0) {
      // No matching imports → ensure no rows match.
      q = q.eq('id', '00000000-0000-0000-0000-000000000000')
    } else {
      q = q.in('import_id', ids)
    }
  }

  if (filter.status === 'transferred' || filter.status === 'applied') {
    q = q.or('invoice_id.not.is.null,transferred_at.not.is.null')
  } else if (filter.status === 'pending') {
    q = q.is('invoice_id', null).is('transferred_at', null)
    for (const col of ELIGIBILITY_COLUMNS) {
      q = q.not(col, 'is', null)
    }
  } else if (filter.status === 'blocked') {
    q = q.is('invoice_id', null).is('transferred_at', null)
    q = q.or(ELIGIBILITY_COLUMNS.map((c) => `${c}.is.null`).join(','))
  }
  return { q }
}

export async function bulkMarkTransferred(formData: FormData) {
  const supabase = await createClient()
  const org = await getAppOrg(supabase)
  if (!org) fail('Organization not found.')

  const eu = await getEffectiveUser(supabase, org.id)
  if (!canTransfer(eu)) {
    fail('Only an organization owner can mark entries as transferred.')
  }

  const batchId = crypto.randomUUID()
  const now = new Date().toISOString()
  const updatePayload = {
    transferred_at: now,
    transferred_by: eu?.real_user_id ?? null,
    transfer_batch_id: batchId,
  }

  let countUpdated = 0

  if (formData.get('all_matching') === '1') {
    const filter = readBulkFilter(formData)
    let q = supabase
      .from('time_entries')
      .update(updatePayload)
      .eq('org_id', org.id)
      .is('transferred_at', null)
      .not('team_member_id', 'is', null)
      .not('converted_duration_seconds', 'is', null)
      .not('converted_user', 'is', null)
      .not('client', 'is', null)
      .not('project', 'is', null)
      .not('description', 'is', null)
      .not('end_at', 'is', null)
      .not('source_user_email', 'is', null)
    ;({ q } = await applyBulkFilter(supabase, org.id, q, filter))
    const { data, error } = await q.select('id')
    if (error) fail(error.message)
    countUpdated = data?.length ?? 0
  } else {
    const ids = formData.getAll('id').map(String).filter(Boolean)
    if (ids.length === 0) fail('No entries selected.')
    const { data, error } = await supabase
      .from('time_entries')
      .update(updatePayload)
      .eq('org_id', org.id)
      .in('id', ids)
      .is('transferred_at', null)
      .not('team_member_id', 'is', null)
      .not('converted_duration_seconds', 'is', null)
      .not('converted_user', 'is', null)
      .not('client', 'is', null)
      .not('project', 'is', null)
      .not('description', 'is', null)
      .not('end_at', 'is', null)
      .not('source_user_email', 'is', null)
      .select('id')
    if (error) fail(error.message)
    countUpdated = data?.length ?? 0
  }

  revalidatePath('/entries')
  revalidatePath('/transfer')
  revalidatePath('/')
  redirect(`/entries?marked=${batchId}&count=${countUpdated}`)
}

/**
 * Bulk-apply selected entries to an invoice (or "select all matching"
 * the current filter). Replaces bulkMarkTransferred as the primary
 * lock action. Only entries that are NOT already attached to an invoice
 * are updated — silent skip for already-attached rows.
 *
 * The eligibility check (team_member_id, project, client, description,
 * end_at, source_user_email all non-null) matches what the old
 * bulkMarkTransferred required. Rows missing those still show up as
 * "blocked" in the status filter and need to be fixed first.
 */
export async function bulkApplyToInvoice(formData: FormData) {
  const supabase = await createClient()
  const org = await getAppOrg(supabase)
  if (!org) fail('Organization not found.')

  const eu = await getEffectiveUser(supabase, org.id)
  if (!canTransfer(eu)) {
    fail('Only an organization owner can apply entries to invoices.')
  }

  const invoiceId = String(formData.get('invoice_id') ?? '').trim()
  if (!invoiceId) fail('Pick an invoice first.')

  // Validate the invoice belongs to this org. RLS handles cross-org
  // visibility but a stale form could still post a UUID from another
  // org — guard against that explicitly.
  const { data: invoice } = await supabase
    .from('invoices')
    .select('id')
    .eq('org_id', org.id)
    .eq('id', invoiceId)
    .maybeSingle<{ id: string }>()
  if (!invoice) fail('Invoice not found.')

  const now = new Date().toISOString()
  const updatePayload = {
    invoice_id: invoiceId,
    invoice_applied_at: now,
    invoice_applied_by: eu?.real_user_id ?? null,
  }

  let countUpdated = 0

  if (formData.get('all_matching') === '1') {
    const filter = readBulkFilter(formData)
    let q = supabase
      .from('time_entries')
      .update(updatePayload)
      .eq('org_id', org.id)
      .is('invoice_id', null)
      .not('team_member_id', 'is', null)
      .not('client', 'is', null)
      .not('project', 'is', null)
      .not('description', 'is', null)
      .not('end_at', 'is', null)
      .not('source_user_email', 'is', null)
    ;({ q } = await applyBulkFilter(supabase, org.id, q, filter))
    const { data, error } = await q.select('id')
    if (error) fail(error.message)
    countUpdated = data?.length ?? 0
  } else {
    const ids = formData.getAll('id').map(String).filter(Boolean)
    if (ids.length === 0) fail('No entries selected.')
    const { data, error } = await supabase
      .from('time_entries')
      .update(updatePayload)
      .eq('org_id', org.id)
      .in('id', ids)
      .is('invoice_id', null)
      .not('team_member_id', 'is', null)
      .not('client', 'is', null)
      .not('project', 'is', null)
      .not('description', 'is', null)
      .not('end_at', 'is', null)
      .not('source_user_email', 'is', null)
      .select('id')
    if (error) fail(error.message)
    countUpdated = data?.length ?? 0
  }

  revalidatePath('/entries')
  revalidatePath('/invoices')
  revalidatePath(`/invoices/${invoiceId}`)
  revalidatePath('/')
  redirect(`/invoices/${invoiceId}?info=Applied+${countUpdated}+entries.`)
}

export async function bulkUnmarkTransferred(formData: FormData) {
  const supabase = await createClient()
  const org = await getAppOrg(supabase)
  if (!org) fail('Organization not found.')

  const eu = await getEffectiveUser(supabase, org.id)
  if (!canTransfer(eu)) {
    fail('Only an organization owner can unmark entries.')
  }

  const updatePayload = {
    transferred_at: null,
    transferred_by: null,
    transfer_batch_id: null,
  }

  let countUpdated = 0

  if (formData.get('all_matching') === '1') {
    const filter = readBulkFilter(formData)
    let q = supabase
      .from('time_entries')
      .update(updatePayload)
      .eq('org_id', org.id)
      .not('transferred_at', 'is', null)
    ;({ q } = await applyBulkFilter(supabase, org.id, q, filter))
    const { data, error } = await q.select('id')
    if (error) fail(error.message)
    countUpdated = data?.length ?? 0
  } else {
    const ids = formData.getAll('id').map(String).filter(Boolean)
    if (ids.length === 0) fail('No entries selected.')
    const { data, error } = await supabase
      .from('time_entries')
      .update(updatePayload)
      .eq('org_id', org.id)
      .in('id', ids)
      .not('transferred_at', 'is', null)
      .select('id')
    if (error) fail(error.message)
    countUpdated = data?.length ?? 0
  }

  revalidatePath('/entries')
  revalidatePath('/transfer')
  revalidatePath('/')
  redirect(`/entries?unmarked=1&count=${countUpdated}`)
}

/**
 * Bulk-recalculate billout_cost_usd + billout_amount_usd on a set of
 * entries using the CURRENT team_member rates and project rate
 * overrides. The math runs in a single SQL UPDATE via the
 * `restamp_billout_for_entry_ids` function.
 *
 * Typical flow: user changes a cost_rate or billout_rate or rate
 * override, unmarks the affected entries as transferred, then bulk-
 * recalculates them so the stored dollar amounts reflect the new
 * rates. (Transferred entries can be recalculated too, but that's an
 * owner-only path so we don't restrict it here — the per-row guards
 * still apply at edit time.)
 *
 * Does NOT re-resolve team_member_id from source_user_email. Use the
 * per-row Re-resolve button for that case.
 */
export async function bulkRecalculate(formData: FormData) {
  const supabase = await createClient()
  const org = await getAppOrg(supabase)
  if (!org) fail('Organization not found.')

  const eu = await getEffectiveUser(supabase, org.id)
  if (!canManageImports(eu)) {
    fail('You do not have permission to recalculate entries.')
  }

  // Build the list of entry IDs we'll re-stamp. For the explicit-
  // selection path it's just the form's `id` fields. For the
  // all-matching path we run the same filter the page uses (via
  // applyBulkFilter), grab the matching IDs, then pass them to the SQL
  // function in one shot. Bulk re-stamps over thousands of rows are
  // cheap because the SQL function does it in a single UPDATE.
  let ids: string[] = []

  if (formData.get('all_matching') === '1') {
    const filter = readBulkFilter(formData)
    let q = supabase
      .from('time_entries')
      .select('id')
      .eq('org_id', org.id)
    ;({ q } = await applyBulkFilter(supabase, org.id, q, filter))
    const { data, error } = await q.returns<{ id: string }[]>()
    if (error) fail(error.message)
    ids = (data ?? []).map((r) => r.id)
  } else {
    ids = formData.getAll('id').map(String).filter(Boolean)
    // View-as scope: same guard as bulkUpdateFields.
    const vasError = await guardEntryIds(supabase, eu, org.id, ids)
    if (vasError) fail(vasError)
  }

  if (ids.length === 0) {
    fail('No entries selected to recalculate.')
  }

  const { data: count, error } = await supabase.rpc(
    'restamp_billout_for_entry_ids',
    { p_org_id: org.id, p_entry_ids: ids },
  )
  if (error) fail(error.message)

  const updated = Number(count ?? 0)
  revalidatePath('/entries')
  revalidatePath('/transfer')
  revalidatePath('/projects')
  revalidatePath('/')
  redirect(`/entries?recalculated=${updated}`)
}

/**
 * Bulk **reassign** selected entries to an existing project and/or
 * team_member. NOT a free-text rewrite — the old free-text path was
 * removed in favor of entity-aware reassignment (ADR #042).
 *
 * Form inputs:
 *   - `bulk_project_id` (UUID, optional) — when set, look up the
 *     project + its client + operator chain, and update each row's
 *     `project_id` + the text-column cache (`operator`, `client`,
 *     `project`). No drift possible because we resolve from the entity.
 *   - `bulk_team_member_id` (UUID, optional) — when set, update
 *     `team_member_id`, `converted_user`, `converted_duration_seconds`
 *     via the `set_team_member_for_entry_ids` SQL function, then
 *     re-stamp cost + billout via `restamp_billout_for_entry_ids`
 *     so the dollar amounts reflect the new member's rates.
 *
 * At least one of the two must be set. Both may be set (project +
 * member changed in one shot).
 *
 * Renames live elsewhere: use `/projects/manage` to rename an entity
 * (operator / client / project). Bulk Edit fields can only re-point
 * entries to entities that already exist.
 *
 * For non-owners (Adi), the update is constrained to non-locked rows
 * (entries attached to an invoice or pre-invoice `transferred_at` are
 * locked).
 */
export async function bulkUpdateFields(formData: FormData) {
  const supabase = await createClient()
  const org = await getAppOrg(supabase)
  if (!org) fail('Organization not found.')

  const eu = await getEffectiveUser(supabase, org.id)
  if (!canManageImports(eu)) fail('You do not have permission to bulk edit.')

  const newProjectId =
    String(formData.get('bulk_project_id') ?? '').trim() || null
  const newTeamMemberId =
    String(formData.get('bulk_team_member_id') ?? '').trim() || null

  if (!newProjectId && !newTeamMemberId) {
    fail('Pick a project or a team member to reassign.')
  }

  // Resolve the picked project's chain up-front (so we can fail fast
  // if the user submitted a stale id and we don't update partial state).
  let projectPayload: {
    project_id: string
    operator: string
    client: string
    project: string
  } | null = null
  if (newProjectId) {
    const { data: project, error: prErr } = await supabase
      .from('projects')
      .select(
        'id, name, client:clients(name, operator:operators(name))',
      )
      .eq('org_id', org.id)
      .eq('id', newProjectId)
      .maybeSingle<{
        id: string
        name: string
        client: {
          name: string
          operator: { name: string } | null
        } | null
      }>()
    if (prErr) fail(prErr.message)
    if (!project || !project.client?.operator?.name) {
      fail('Selected project is missing its client / operator chain.')
    }
    projectPayload = {
      project_id: project.id,
      operator: project.client.operator.name,
      client: project.client.name,
      project: project.name,
    }
  }

  // Validate the picked team member exists in the org.
  if (newTeamMemberId) {
    const { data: member } = await supabase
      .from('team_members')
      .select('id')
      .eq('org_id', org.id)
      .eq('id', newTeamMemberId)
      .maybeSingle<{ id: string }>()
    if (!member) fail('Selected team member not found.')
  }

  const restrictToUntransferred = !canTransfer(eu)

  // Build the explicit list of entry IDs we'll touch. For all_matching
  // we run the filter to get IDs (so the team_member-change path can
  // call the RPC with a UUID array). For explicit selection we trust
  // the form's `id` fields.
  let ids: string[] = []
  if (formData.get('all_matching') === '1') {
    const filter = readBulkFilter(formData)
    const expected = Number(formData.get('expected_count') ?? 0)

    let preQuery = supabase
      .from('time_entries')
      .select('id', { count: 'exact', head: true })
      .eq('org_id', org.id)
    if (restrictToUntransferred) {
      preQuery = preQuery.is('invoice_id', null).is('transferred_at', null)
    }
    ;({ q: preQuery } = await applyBulkFilter(supabase, org.id, preQuery, filter))
    const { count: preCount, error: preErr } = await preQuery
    if (preErr) fail(preErr.message)

    console.log(
      '[bulkUpdateFields] all_matching=1',
      'expected=', expected,
      'preCount=', preCount,
      'filter=', JSON.stringify(filter),
    )

    if (expected > 0 && (preCount ?? 0) !== expected) {
      fail(
        `Safety stop: the filter currently matches ${preCount} entries, but you confirmed ${expected}. Nothing was changed. Refresh /entries to see the current state, then retry.`,
      )
    }

    let idsQuery = supabase
      .from('time_entries')
      .select('id')
      .eq('org_id', org.id)
    if (restrictToUntransferred) {
      idsQuery = idsQuery.is('invoice_id', null).is('transferred_at', null)
    }
    ;({ q: idsQuery } = await applyBulkFilter(
      supabase,
      org.id,
      idsQuery,
      filter,
    ))
    const { data, error } = await idsQuery.returns<{ id: string }[]>()
    if (error) fail(error.message)
    ids = (data ?? []).map((r) => r.id)
  } else {
    ids = formData.getAll('id').map(String).filter(Boolean)
    if (ids.length === 0) fail('No entries selected.')
    const vasError = await guardEntryIds(supabase, eu, org.id, ids)
    if (vasError) fail(vasError)
    // For managers, scope to non-locked rows server-side via a pre-fetch.
    if (restrictToUntransferred) {
      const { data: editable } = await supabase
        .from('time_entries')
        .select('id')
        .eq('org_id', org.id)
        .in('id', ids)
        .is('invoice_id', null)
        .is('transferred_at', null)
        .returns<{ id: string }[]>()
      ids = (editable ?? []).map((r) => r.id)
    }
  }

  if (ids.length === 0) {
    fail('No entries left to reassign after applying the lock guard.')
  }

  let countUpdated = 0

  // Project reassignment: simple PostgREST UPDATE with the resolved chain.
  if (projectPayload) {
    const { data, error } = await supabase
      .from('time_entries')
      .update(projectPayload)
      .eq('org_id', org.id)
      .in('id', ids)
      .select('id')
    if (error) fail(error.message)
    countUpdated = Math.max(countUpdated, data?.length ?? 0)
  }

  // Team-member reassignment: SQL function stamps identity columns,
  // then restamp_billout_for_entry_ids re-stamps cost + billout using
  // the new member's rates (and the entry's project_id, which may
  // have just been updated by the project step above).
  if (newTeamMemberId) {
    const { error: setErr } = await supabase.rpc(
      'set_team_member_for_entry_ids',
      {
        p_org_id: org.id,
        p_team_member_id: newTeamMemberId,
        p_entry_ids: ids,
      },
    )
    if (setErr) fail(setErr.message)
    const { data: rec, error: recErr } = await supabase.rpc(
      'restamp_billout_for_entry_ids',
      { p_org_id: org.id, p_entry_ids: ids },
    )
    if (recErr) fail(recErr.message)
    countUpdated = Math.max(countUpdated, Number(rec ?? 0))
  }

  revalidatePath('/entries')
  revalidatePath('/projects')
  revalidatePath('/invoices')
  revalidatePath('/')
  redirect(`/entries?bulk_edited=${countUpdated}`)
}

export async function deleteEntry(formData: FormData) {
  const id = String(formData.get('id') ?? '')
  if (!id) fail('Missing entry id.')

  const supabase = await createClient()
  const org = await getAppOrg(supabase)
  if (!org) fail('Organization not found.')

  const eu = await getEffectiveUser(supabase, org.id)

  const { data: existing } = await supabase
    .from('time_entries')
    .select('transferred_at, invoice_id')
    .eq('org_id', org.id)
    .eq('id', id)
    .maybeSingle<{
      transferred_at: string | null
      invoice_id: string | null
    }>()
  if (!existing) fail('Entry not found.')
  if (!canEditEntry(eu, existing)) {
    fail('This entry is attached to an invoice and locked. Only an owner can delete it.')
  }
  const vasError = await guardEntry(supabase, eu, org.id, id)
  if (vasError) fail(vasError)

  const { error } = await supabase
    .from('time_entries')
    .delete()
    .eq('org_id', org.id)
    .eq('id', id)
  if (error) fail(error.message)

  revalidatePath('/entries')
  revalidatePath('/transfer')
  revalidatePath('/')
  redirect('/entries')
}
