import { type Evaluation, EvaluationRepository } from "@domain/evaluations"
import {
  type IssueOccurrenceAggregate,
  type IssueOccurrenceBucket,
  type IssueTagsTimeRange,
  type IssueTrendSeries,
  type IssueWindowMetric,
  ScoreAnalyticsRepository,
  type ScoreAnalyticsTimeRange,
} from "@domain/scores"
import {
  type ChSqlClient,
  cuidSchema,
  type FilterCondition,
  type FilterSet,
  type IssueId,
  issueIdSchema,
  OrganizationId,
  ProjectId,
  type RepositoryError,
  type SqlClient,
} from "@domain/shared"
import { pickTraceHistogramBucketSeconds, TraceRepository } from "@domain/spans"
import { Effect } from "effect"
import { z } from "zod"
import { type IssueSource, IssueState } from "../entities/issue.ts"
import { deriveIssueLifecycleStates, getEscalationOccurrenceThreshold } from "../helpers.ts"
import { IssueRepository, type IssueSearchCandidate, type IssueWithLifecycle } from "../ports/issue-repository.ts"

export const issuesLifecycleGroupSchema = z.enum(["active", "archived"])
export type IssuesLifecycleGroup = z.infer<typeof issuesLifecycleGroupSchema>

export const issuesSortFieldSchema = z.enum(["lastSeen", "occurrences", "state"])
export type IssuesSortField = z.infer<typeof issuesSortFieldSchema>

export const issuesSortDirectionSchema = z.enum(["asc", "desc"])
export type IssuesSortDirection = z.infer<typeof issuesSortDirectionSchema>

export const issuesTimeRangeSchema = z.object({
  from: z.date().optional(),
  to: z.date().optional(),
})

export const issueSearchSchema = z.object({
  query: z.string().min(1),
  normalizedEmbedding: z.array(z.number()),
})

const listIssuesInputSchema = z.object({
  organizationId: cuidSchema.transform(OrganizationId),
  projectId: cuidSchema.transform(ProjectId),
  /**
   * Restrict the result to a specific subset of issues. Skips the
   * windowMetrics-driven candidate selection so callers can fetch a known
   * issue even when it had no activity in the selected time range — the
   * use-case returns it with synthetic zero metrics in that case.
   */
  issueIds: z.array(issueIdSchema).optional(),
  lifecycleGroup: issuesLifecycleGroupSchema.optional(),
  search: issueSearchSchema.optional(),
  timeRange: issuesTimeRangeSchema.optional(),
  sort: z
    .object({
      field: issuesSortFieldSchema.default("lastSeen"),
      direction: issuesSortDirectionSchema.default("desc"),
    })
    .default({
      field: "lastSeen",
      direction: "desc",
    }),
  limit: z.number().int().min(1).max(100).default(50),
  offset: z.number().int().min(0).default(0),
  now: z.date().optional(),
})

export type ListIssuesInput = z.input<typeof listIssuesInputSchema>
export type ListIssuesError = RepositoryError

export interface IssueListAnalyticsCounts {
  readonly newIssues: number
  readonly escalatingIssues: number
  readonly ongoingIssues: number
  readonly regressedIssues: number
  readonly resolvedIssues: number
  readonly seenOccurrences: number
}

export interface IssueListAnalytics {
  readonly counts: IssueListAnalyticsCounts
  readonly histogram: readonly IssueOccurrenceBucket[]
  /**
   * Width (seconds) of each `histogram` bucket. Adaptive — picked from the time range so a
   * 6-day default lands at ~3h–4h bars and longer ranges step up to 12h, 1d, 1w. Bucket keys
   * are ISO-8601 UTC timestamps (`YYYY-MM-DDTHH:MM:SS.000Z`).
   */
  readonly histogramBucketSeconds: number
  readonly totalTraces: number
}

export interface IssueListItem {
  readonly id: string
  readonly projectId: string
  readonly slug: string
  readonly name: string
  readonly description: string
  readonly source: IssueSource
  readonly states: readonly string[]
  readonly createdAt: Date
  readonly updatedAt: Date
  readonly escalatedAt: Date | null
  readonly resolvedAt: Date | null
  readonly ignoredAt: Date | null
  readonly firstSeenAt: Date
  readonly lastSeenAt: Date
  readonly occurrences: number
  readonly similarityScore: number | null
  readonly affectedTracesPercent: number
  readonly escalationOccurrenceThreshold: number | null
  readonly trend: readonly IssueOccurrenceBucket[]
  readonly evaluations: readonly Evaluation[]
  readonly tags: readonly string[]
}

export interface ListIssuesResult {
  readonly analytics: IssueListAnalytics
  readonly items: readonly IssueListItem[]
  readonly totalCount: number
  readonly hasMore: boolean
  readonly limit: number
  readonly offset: number
  readonly occurrencesSum: number
}

interface AnalyticsCandidate {
  readonly issue: IssueWithLifecycle
  readonly windowMetric: IssueWindowMetric
  readonly lifecycleStates: readonly string[]
  readonly similarityScore: number | null
  readonly firstSeenAt: Date
  readonly lastSeenAt: Date
  readonly escalationOccurrenceThreshold: number | null
}

const toUtcDayStart = (value: Date): Date =>
  new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate(), 0, 0, 0, 0))

const toUtcDayEnd = (value: Date): Date =>
  new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate(), 23, 59, 59, 999))

const resolveHistogramTimeRange = (input: {
  readonly timeRange: z.infer<typeof issuesTimeRangeSchema> | undefined
  readonly now: Date
}): { readonly from: Date; readonly to: Date } => {
  if (input.timeRange?.from && input.timeRange?.to) {
    return {
      from: toUtcDayStart(input.timeRange.from),
      to: toUtcDayEnd(input.timeRange.to),
    }
  }

  if (input.timeRange?.from) {
    return {
      from: toUtcDayStart(input.timeRange.from),
      to: toUtcDayEnd(input.now),
    }
  }

  const end = toUtcDayEnd(input.timeRange?.to ?? input.now)
  const start = new Date(end)
  start.setUTCDate(start.getUTCDate() - 6)
  start.setUTCHours(0, 0, 0, 0)

  return {
    from: start,
    to: end,
  }
}

const resolveTrendTimeRange = (input: {
  readonly timeRange: z.infer<typeof issuesTimeRangeSchema> | undefined
  readonly now: Date
}): { readonly from: Date; readonly to: Date } => {
  const end = toUtcDayEnd(input.timeRange?.to ?? input.timeRange?.from ?? input.now)
  const start = new Date(end)
  start.setUTCDate(start.getUTCDate() - 13)
  start.setUTCHours(0, 0, 0, 0)

  return {
    from: start,
    to: end,
  }
}

/**
 * Tag aggregation needs an explicit lower-bound time so CH scans on `scores`
 * and `traces` stay partition-pruned (see `aggregateTagsByIssues` impl). If
 * the operator selected a range we honor it; otherwise we fall back to ~30
 * days, which captures effectively all currently-relevant tags while still
 * pinning the scan to a small number of monthly partitions.
 *
 * Exported so other call sites that fetch tags for the same issues
 * (e.g. the issue detail handler) can use the same window and stay visually
 * consistent with the list.
 */
export const TAG_AGGREGATION_FALLBACK_DAYS = 30

const resolveTagsTimeRange = (input: {
  readonly timeRange: ScoreAnalyticsTimeRange | undefined
  readonly now: Date
}): IssueTagsTimeRange => {
  if (input.timeRange?.from) {
    return input.timeRange.to ? { from: input.timeRange.from, to: input.timeRange.to } : { from: input.timeRange.from }
  }
  const to = input.timeRange?.to ?? input.now
  const from = new Date(to)
  from.setUTCDate(from.getUTCDate() - TAG_AGGREGATION_FALLBACK_DAYS)
  return { from, to }
}

const toScoreAnalyticsTimeRange = (
  timeRange: z.infer<typeof issuesTimeRangeSchema> | undefined,
): ScoreAnalyticsTimeRange | undefined => {
  const normalized: {
    from?: Date
    to?: Date
  } = {}

  if (timeRange?.from !== undefined) {
    normalized.from = timeRange.from
  }

  if (timeRange?.to !== undefined) {
    normalized.to = timeRange.to
  }

  return Object.keys(normalized).length > 0 ? normalized : undefined
}

/**
 * Daily UTC scaffold producing `YYYY-MM-DD` keys. Used by the table mini-bar trend (always 14
 * days, daily) — kept as-is so the row-level trend stays compact and readable.
 */
const buildBucketScaffold = (input: { readonly from: Date; readonly to: Date }): readonly string[] => {
  const buckets: string[] = []
  const cursor = new Date(input.from)
  cursor.setUTCHours(0, 0, 0, 0)

  while (cursor.getTime() <= input.to.getTime()) {
    buckets.push(cursor.toISOString().slice(0, 10))
    cursor.setUTCDate(cursor.getUTCDate() + 1)
  }

  return buckets
}

/**
 * Sub-day-aware scaffold aligned to UTC bucket boundaries. Emits ISO-8601 UTC timestamps
 * (`YYYY-MM-DDTHH:MM:SS.000Z`) — same shape the CH layer returns for the analytics histogram.
 * The scaffold starts at `floor(from / bucketWidth) * bucketWidth` and steps by `bucketSeconds`
 * up to and including the bucket containing `to`.
 */
const buildHistogramBucketScaffold = (input: {
  readonly from: Date
  readonly to: Date
  readonly bucketSeconds: number
}): readonly string[] => {
  const widthMs = input.bucketSeconds * 1000
  if (widthMs <= 0) return []
  const startMs = Math.floor(input.from.getTime() / widthMs) * widthMs
  const endMs = input.to.getTime()
  const out: string[] = []
  for (let cursor = startMs; cursor <= endMs; cursor += widthMs) {
    out.push(new Date(cursor).toISOString())
  }
  return out
}

const fillBuckets = (input: {
  readonly scaffold: readonly string[]
  readonly buckets: readonly IssueOccurrenceBucket[]
}): readonly IssueOccurrenceBucket[] => {
  const countsByBucket = new Map(input.buckets.map((bucket) => [bucket.bucket, bucket.count] as const))

  return input.scaffold.map((bucket) => ({
    bucket,
    count: countsByBucket.get(bucket) ?? 0,
  }))
}

const matchesLifecycleGroup = (
  candidate: AnalyticsCandidate,
  lifecycleGroup: IssuesLifecycleGroup | undefined,
): boolean => {
  if (lifecycleGroup === undefined) {
    return true
  }

  const isArchived = candidate.issue.ignoredAt !== null || candidate.lifecycleStates.includes(IssueState.Resolved)
  return lifecycleGroup === "archived" ? isArchived : !isArchived
}

const compareDesc = (left: number, right: number): number => right - left
const compareAsc = (left: number, right: number): number => left - right

const LIFECYCLE_STATE_PRIORITY: Record<string, number> = {
  [IssueState.Regressed]: 0,
  [IssueState.Escalating]: 1,
  [IssueState.New]: 2,
  [IssueState.Ongoing]: 3,
  [IssueState.Resolved]: 4,
  [IssueState.Ignored]: 5,
}

const UNKNOWN_STATE_PRIORITY = 99

const getPrimaryStatePriority = (states: readonly string[]): number => {
  let best = UNKNOWN_STATE_PRIORITY
  for (const state of states) {
    const priority = LIFECYCLE_STATE_PRIORITY[state]
    if (priority !== undefined && priority < best) {
      best = priority
    }
  }
  return best
}

const sortCandidates = (
  candidates: readonly AnalyticsCandidate[],
  input: {
    readonly field: IssuesSortField
    readonly direction: IssuesSortDirection
    readonly hasSearch: boolean
  },
): readonly AnalyticsCandidate[] =>
  [...candidates].sort((left, right) => {
    if (input.field === "occurrences") {
      const occurrencesComparison =
        input.direction === "asc"
          ? compareAsc(left.windowMetric.occurrences, right.windowMetric.occurrences)
          : compareDesc(left.windowMetric.occurrences, right.windowMetric.occurrences)
      if (occurrencesComparison !== 0) {
        return occurrencesComparison
      }
    } else if (input.field === "state") {
      // Priority numbers go low → high in importance order
      // (`Regressed = 0`, `Escalating = 1`, …, `Ignored = 5`). The user-
      // facing convention is "sort by state descending = highest-priority
      // first", so `desc` keeps the smaller priority number on top and
      // `asc` flips that — opposite to the raw numeric comparison.
      const leftPriority = getPrimaryStatePriority(left.lifecycleStates)
      const rightPriority = getPrimaryStatePriority(right.lifecycleStates)
      const stateComparison =
        input.direction === "asc" ? compareDesc(leftPriority, rightPriority) : compareAsc(leftPriority, rightPriority)
      if (stateComparison !== 0) {
        return stateComparison
      }
    } else {
      const lastSeenComparison =
        input.direction === "asc"
          ? compareAsc(left.lastSeenAt.getTime(), right.lastSeenAt.getTime())
          : compareDesc(left.lastSeenAt.getTime(), right.lastSeenAt.getTime())
      if (lastSeenComparison !== 0) {
        return lastSeenComparison
      }

      const occurrencesComparison = compareDesc(left.windowMetric.occurrences, right.windowMetric.occurrences)
      if (occurrencesComparison !== 0) {
        return occurrencesComparison
      }
    }

    const lastSeenComparison = compareDesc(left.lastSeenAt.getTime(), right.lastSeenAt.getTime())
    if (lastSeenComparison !== 0) {
      return lastSeenComparison
    }

    if (input.hasSearch) {
      const similarityComparison = compareDesc(left.similarityScore ?? 0, right.similarityScore ?? 0)
      if (similarityComparison !== 0) {
        return similarityComparison
      }
    }

    return left.issue.id.localeCompare(right.issue.id)
  })

const toCandidate = (input: {
  readonly issue: IssueWithLifecycle
  readonly windowMetric: IssueWindowMetric
  readonly occurrence: IssueOccurrenceAggregate | null
  readonly similarityScore: number | null
  readonly now: Date
}): AnalyticsCandidate => {
  const lifecycleStates = deriveIssueLifecycleStates({
    issue: input.issue,
    isEscalating: input.issue.lifecycle.isEscalating,
    isRegressed: input.issue.lifecycle.isRegressed,
    now: input.now,
  })

  return {
    issue: input.issue,
    windowMetric: input.windowMetric,
    lifecycleStates,
    similarityScore: input.similarityScore,
    firstSeenAt: input.occurrence?.firstSeenAt ?? input.windowMetric.firstSeenAt ?? input.issue.createdAt,
    lastSeenAt: input.occurrence?.lastSeenAt ?? input.windowMetric.lastSeenAt ?? input.issue.createdAt,
    escalationOccurrenceThreshold:
      input.occurrence !== null ? getEscalationOccurrenceThreshold(input.occurrence.baselineAvgOccurrences) : null,
  }
}

const toAnalyticsCounts = (candidates: readonly AnalyticsCandidate[]): IssueListAnalyticsCounts => ({
  newIssues: candidates.filter((candidate) => candidate.lifecycleStates.includes(IssueState.New)).length,
  escalatingIssues: candidates.filter((candidate) => candidate.lifecycleStates.includes(IssueState.Escalating)).length,
  ongoingIssues: candidates.filter((candidate) => candidate.lifecycleStates.includes(IssueState.Ongoing)).length,
  regressedIssues: candidates.filter((candidate) => candidate.lifecycleStates.includes(IssueState.Regressed)).length,
  resolvedIssues: candidates.filter((candidate) => candidate.lifecycleStates.includes(IssueState.Resolved)).length,
  seenOccurrences: candidates.reduce((sum, candidate) => sum + candidate.windowMetric.occurrences, 0),
})

export const listIssuesUseCase = (
  input: ListIssuesInput,
): Effect.Effect<
  ListIssuesResult,
  ListIssuesError,
  ChSqlClient | EvaluationRepository | IssueRepository | ScoreAnalyticsRepository | SqlClient | TraceRepository
> =>
  Effect.gen(function* () {
    const parsed = listIssuesInputSchema.parse(input)
    yield* Effect.annotateCurrentSpan("projectId", String(parsed.projectId))
    const issueRepository = yield* IssueRepository
    const now = parsed.now ?? new Date()
    const selectedTimeRange = toScoreAnalyticsTimeRange(parsed.timeRange)

    if (!parsed.issueIds) {
      const firstIssuePage = yield* issueRepository.list({
        projectId: parsed.projectId,
        limit: 1,
        offset: 0,
      })

      if (firstIssuePage.items.length === 0) {
        const histogramTimeRange = resolveHistogramTimeRange({
          timeRange: parsed.timeRange,
          now,
        })
        const histogramBucketSeconds = pickTraceHistogramBucketSeconds(
          histogramTimeRange.from.getTime(),
          histogramTimeRange.to.getTime(),
        )
        const histogramScaffold = buildHistogramBucketScaffold({
          from: histogramTimeRange.from,
          to: histogramTimeRange.to,
          bucketSeconds: histogramBucketSeconds,
        })

        return {
          analytics: {
            counts: {
              newIssues: 0,
              escalatingIssues: 0,
              ongoingIssues: 0,
              regressedIssues: 0,
              resolvedIssues: 0,
              seenOccurrences: 0,
            },
            histogram: fillBuckets({ scaffold: histogramScaffold, buckets: [] }),
            histogramBucketSeconds,
            totalTraces: 0,
          },
          items: [],
          totalCount: 0,
          hasMore: false,
          limit: parsed.limit,
          offset: parsed.offset,
          occurrencesSum: 0,
        } satisfies ListIssuesResult
      }
    }

    const scoreAnalyticsRepository = yield* ScoreAnalyticsRepository
    const evaluationRepository = yield* EvaluationRepository
    const traceRepository = yield* TraceRepository

    const windowMetricsEffect = scoreAnalyticsRepository.listIssueWindowMetrics({
      organizationId: parsed.organizationId,
      projectId: parsed.projectId,
      ...(selectedTimeRange ? { timeRange: selectedTimeRange } : {}),
      ...(parsed.issueIds ? { issueIds: parsed.issueIds } : {}),
    })

    const searchCandidatesEffect = parsed.search
      ? issueRepository.hybridSearch({
          projectId: parsed.projectId,
          query: parsed.search.query,
          normalizedEmbedding: parsed.search.normalizedEmbedding,
        })
      : Effect.succeed([] satisfies readonly IssueSearchCandidate[])

    // The denominator for `affectedTracesPercent` must be counted against
    // `traces.start_time` (not `scores.created_at`) so the percentage stays
    // consistent with what the web table shows and with the totals other
    // trace-side endpoints report. Async scoring can produce `scores` whose
    // `created_at` falls outside the trace's `start_time` window, which would
    // otherwise drift the numerator and denominator out of sync.
    const startTimeConditions: FilterCondition[] = []
    if (parsed.timeRange?.from) {
      startTimeConditions.push({ op: "gte", value: parsed.timeRange.from.toISOString() })
    }
    if (parsed.timeRange?.to) {
      startTimeConditions.push({ op: "lte", value: parsed.timeRange.to.toISOString() })
    }
    const traceCountFilters: FilterSet | undefined =
      startTimeConditions.length > 0 ? { startTime: startTimeConditions } : undefined

    const [windowMetrics, searchCandidates, totalTraces] = yield* Effect.all([
      windowMetricsEffect,
      searchCandidatesEffect,
      traceRepository.countByProjectId({
        organizationId: parsed.organizationId,
        projectId: parsed.projectId,
        ...(traceCountFilters ? { filters: traceCountFilters } : {}),
      }),
    ])

    const windowMetricsByIssueId = new Map(windowMetrics.map((metric) => [metric.issueId, metric] as const))
    const baseCandidateIds = parsed.search
      ? searchCandidates.map((candidate) => candidate.issueId).filter((issueId) => windowMetricsByIssueId.has(issueId))
      : windowMetrics.map((metric) => metric.issueId)
    // When the caller passed an explicit `issueIds` filter, include those
    // issues in the candidate set even if they had no activity in the
    // window — they get synthesized zero `windowMetric` rows below so the
    // analytics pipeline still produces a stable shape.
    const candidateIssueIds = parsed.issueIds
      ? Array.from(new Set<IssueId>([...baseCandidateIds, ...parsed.issueIds]))
      : baseCandidateIds
    const histogramTimeRange = resolveHistogramTimeRange({
      timeRange: parsed.timeRange,
      now,
    })
    // Pick a "nice" bucket width adaptively so a 6-day default window lands at ~3h–4h bars while
    // longer user-selected ranges step up to 6h, 12h, 1d, etc. Same helper the Traces histogram
    // uses, keeping the two views visually consistent.
    const histogramBucketSeconds = pickTraceHistogramBucketSeconds(
      histogramTimeRange.from.getTime(),
      histogramTimeRange.to.getTime(),
    )
    const histogramScaffold = buildHistogramBucketScaffold({
      from: histogramTimeRange.from,
      to: histogramTimeRange.to,
      bucketSeconds: histogramBucketSeconds,
    })

    if (candidateIssueIds.length === 0) {
      return {
        analytics: {
          counts: {
            newIssues: 0,
            escalatingIssues: 0,
            ongoingIssues: 0,
            regressedIssues: 0,
            resolvedIssues: 0,
            seenOccurrences: 0,
          },
          histogram: fillBuckets({ scaffold: histogramScaffold, buckets: [] }),
          histogramBucketSeconds,
          totalTraces,
        },
        items: [],
        totalCount: 0,
        hasMore: false,
        limit: parsed.limit,
        offset: parsed.offset,
        occurrencesSum: 0,
      } satisfies ListIssuesResult
    }

    const searchScoresByIssueId = new Map(
      searchCandidates.map((candidate) => [candidate.issueId, candidate.score] as const),
    )
    const forceIncludeIssueIds = parsed.issueIds ? new Set<string>(parsed.issueIds) : null
    const canonicalIssues = yield* issueRepository.findByIds({
      projectId: parsed.projectId,
      issueIds: candidateIssueIds,
    })

    const matchedIssueIds = canonicalIssues.map((issue) => issue.id)
    const fullHistoryOccurrences =
      matchedIssueIds.length === 0
        ? []
        : yield* scoreAnalyticsRepository.aggregateByIssues({
            organizationId: parsed.organizationId,
            projectId: parsed.projectId,
            issueIds: matchedIssueIds,
          })
    const occurrencesByIssueId = new Map(
      fullHistoryOccurrences.map((occurrence) => [occurrence.issueId, occurrence] as const),
    )

    const analyticsCandidates = canonicalIssues
      .map((issue) => {
        const windowMetric = windowMetricsByIssueId.get(issue.id) ?? null
        // Issues without window metrics are normally filtered out — but when
        // the caller passed `issueIds`, force-include them with a synthesized
        // zero-activity metric so a point-lookup over a known issue id still
        // returns a stable analytics shape.
        if (!windowMetric && !forceIncludeIssueIds?.has(issue.id)) {
          return null
        }

        return toCandidate({
          issue,
          windowMetric: windowMetric ?? ({ issueId: issue.id, occurrences: 0 } as unknown as IssueWindowMetric),
          occurrence: occurrencesByIssueId.get(issue.id) ?? null,
          similarityScore: searchScoresByIssueId.get(issue.id) ?? null,
          now,
        })
      })
      .filter((candidate): candidate is AnalyticsCandidate => candidate !== null)

    const analyticsHistogram =
      matchedIssueIds.length === 0
        ? []
        : yield* scoreAnalyticsRepository.histogramByIssues({
            organizationId: parsed.organizationId,
            projectId: parsed.projectId,
            issueIds: matchedIssueIds,
            timeRange: histogramTimeRange,
            bucketSeconds: histogramBucketSeconds,
          })

    const tableCandidates = sortCandidates(
      analyticsCandidates.filter((candidate) => matchesLifecycleGroup(candidate, parsed.lifecycleGroup)),
      {
        field: parsed.sort.field,
        direction: parsed.sort.direction,
        hasSearch: parsed.search !== undefined,
      },
    )

    const occurrencesSum = tableCandidates.reduce((sum, candidate) => sum + candidate.windowMetric.occurrences, 0)
    const pageCandidates = tableCandidates.slice(parsed.offset, parsed.offset + parsed.limit)
    const pageIssueIds = pageCandidates.map((candidate) => candidate.issue.id)
    const trendTimeRange = resolveTrendTimeRange({
      timeRange: parsed.timeRange,
      now,
    })
    const trendScaffold = buildBucketScaffold(trendTimeRange)
    const tagsTimeRange = resolveTagsTimeRange({ timeRange: selectedTimeRange, now })

    const [evaluationPage, trendSeries, tagsAggregates] = yield* Effect.all([
      pageIssueIds.length === 0
        ? Effect.succeed({
            items: [] as readonly Evaluation[],
            hasMore: false,
            limit: 0,
            offset: 0,
          })
        : evaluationRepository.listByIssueIds({
            projectId: parsed.projectId,
            issueIds: pageIssueIds,
            options: {
              lifecycle: "active",
              limit: 1000,
            },
          }),
      pageIssueIds.length === 0
        ? Effect.succeed([] satisfies readonly IssueTrendSeries[])
        : scoreAnalyticsRepository.trendByIssues({
            organizationId: parsed.organizationId,
            projectId: parsed.projectId,
            issueIds: pageIssueIds,
            timeRange: trendTimeRange,
          }),
      pageIssueIds.length === 0
        ? Effect.succeed([])
        : scoreAnalyticsRepository.aggregateTagsByIssues({
            organizationId: parsed.organizationId,
            projectId: parsed.projectId,
            issueIds: pageIssueIds,
            timeRange: tagsTimeRange,
          }),
    ])

    const evaluationsByIssueId = new Map<string, Evaluation[]>()
    for (const evaluation of evaluationPage.items) {
      const evaluations = evaluationsByIssueId.get(evaluation.issueId) ?? []
      evaluations.push(evaluation)
      evaluationsByIssueId.set(evaluation.issueId, evaluations)
    }

    const trendByIssueId = new Map(
      trendSeries.map((series) => [
        series.issueId,
        fillBuckets({
          scaffold: trendScaffold,
          buckets: series.buckets,
        }),
      ]),
    )

    const tagsByIssueId = new Map(tagsAggregates.map((entry) => [entry.issueId, entry.tags] as const))

    return {
      analytics: {
        counts: toAnalyticsCounts(analyticsCandidates),
        histogram: fillBuckets({
          scaffold: histogramScaffold,
          buckets: analyticsHistogram,
        }),
        histogramBucketSeconds,
        totalTraces,
      },
      items: pageCandidates.map((candidate) => ({
        id: candidate.issue.id,
        projectId: candidate.issue.projectId,
        slug: candidate.issue.slug,
        name: candidate.issue.name,
        description: candidate.issue.description,
        source: candidate.issue.source,
        states: candidate.lifecycleStates,
        createdAt: candidate.issue.createdAt,
        updatedAt: candidate.issue.updatedAt,
        escalatedAt: candidate.issue.escalatedAt,
        resolvedAt: candidate.issue.resolvedAt,
        ignoredAt: candidate.issue.ignoredAt,
        firstSeenAt: candidate.firstSeenAt,
        lastSeenAt: candidate.lastSeenAt,
        occurrences: candidate.windowMetric.occurrences,
        similarityScore: candidate.similarityScore,
        affectedTracesPercent: totalTraces === 0 ? 0 : Math.min(candidate.windowMetric.occurrences / totalTraces, 1),
        escalationOccurrenceThreshold: candidate.escalationOccurrenceThreshold,
        trend: trendByIssueId.get(candidate.issue.id) ?? fillBuckets({ scaffold: trendScaffold, buckets: [] }),
        evaluations: evaluationsByIssueId.get(candidate.issue.id) ?? [],
        tags: tagsByIssueId.get(candidate.issue.id) ?? [],
      })),
      totalCount: tableCandidates.length,
      hasMore: parsed.offset + parsed.limit < tableCandidates.length,
      limit: parsed.limit,
      offset: parsed.offset,
      occurrencesSum,
    } satisfies ListIssuesResult
  }).pipe(Effect.withSpan("issues.listIssues"))
