import { OutboxEventWriter } from "@domain/events"
import { type Score, ScoreRepository } from "@domain/scores"
import { type CacheError, IssueId, ProjectId, type RepositoryError, ScoreId, SqlClient } from "@domain/shared"
import { Effect } from "effect"
import { ISSUE_UPDATE_LOCK_KEY, ISSUE_UPDATE_LOCK_TTL_SECONDS } from "../constants.ts"
import type { Issue } from "../entities/issue.ts"
import type { CheckEligibilityError, IssueDiscoveryLockUnavailableError } from "../errors.ts"
import { IssueNotFoundForAssignmentError, ScoreAlreadyOwnedByIssueError } from "../errors.ts"
import { updateIssueCentroid } from "../helpers.ts"
import { IssueDiscoveryLockRepository } from "../ports/issue-discovery-lock-repository.ts"
import { IssueRepository } from "../ports/issue-repository.ts"
import { checkEligibilityUseCase } from "./check-eligibility.ts"

export interface AssignScoreToIssueInput {
  readonly organizationId: string
  readonly projectId: string
  readonly scoreId: string
  readonly issueId: string
  readonly normalizedEmbedding: readonly number[]
}

export type AssignScoreToIssueResult = {
  readonly issueId: string
  readonly action: "assigned" | "already-assigned"
}

export type AssignScoreToIssueError =
  | CacheError
  | CheckEligibilityError
  | IssueDiscoveryLockUnavailableError
  | IssueNotFoundForAssignmentError
  | RepositoryError
  | ScoreAlreadyOwnedByIssueError

type LoadedEligibleScoreResult =
  | {
      readonly action: "ready"
      readonly score: Score
    }
  | {
      readonly action: "already-assigned"
      readonly issueId: string
    }

const loadEligibleScoreOrCurrentOwner = (input: {
  readonly organizationId: string
  readonly projectId: string
  readonly scoreId: string
}) =>
  checkEligibilityUseCase(input).pipe(
    Effect.map((score) => ({ action: "ready", score }) satisfies LoadedEligibleScoreResult),
    Effect.catchTag("ScoreAlreadyOwnedByIssueError", () =>
      Effect.gen(function* () {
        const scoreRepository = yield* ScoreRepository
        const currentScore = yield* scoreRepository
          .findById(ScoreId(input.scoreId))
          .pipe(Effect.catchTag("NotFoundError", () => Effect.succeed(null)))
        const existingIssueId = currentScore?.issueId

        if (existingIssueId != null) {
          return {
            action: "already-assigned",
            issueId: existingIssueId,
          } satisfies LoadedEligibleScoreResult
        }

        return yield* new ScoreAlreadyOwnedByIssueError({ scoreId: input.scoreId })
      }),
    ),
  )

const buildIssueWithAssignedScore = ({
  issue,
  score,
  normalizedEmbedding,
  assignedAt,
}: {
  readonly issue: Issue
  readonly score: Score
  readonly normalizedEmbedding: readonly number[]
  readonly assignedAt: Date
}): { readonly updatedIssue: Issue; readonly isRegression: boolean } => {
  const centroid = updateIssueCentroid({
    centroid: {
      ...issue.centroid,
      clusteredAt: issue.clusteredAt,
    },
    score: {
      embedding: normalizedEmbedding,
      source: score.source,
      createdAt: score.createdAt,
    },
    operation: "add",
    timestamp: assignedAt,
  })

  // Reify regression at write time: a score newer than the issue's resolution
  // means the issue is no longer resolved. Clearing `resolvedAt` here makes
  // "is the issue regressed" a stored fact (and gives the regression event
  // idempotency for free — a subsequent score in the same cycle won't see
  // resolvedAt != null and won't re-emit).
  const isRegression = issue.resolvedAt !== null && score.createdAt.getTime() > issue.resolvedAt.getTime()

  return {
    updatedIssue: {
      ...issue,
      centroid,
      clusteredAt: centroid.clusteredAt,
      resolvedAt: isRegression ? null : issue.resolvedAt,
      updatedAt: assignedAt,
    },
    isRegression,
  }
}

export const assignScoreToIssueUseCase = (input: AssignScoreToIssueInput) =>
  Effect.gen(function* () {
    yield* Effect.annotateCurrentSpan("scoreId", input.scoreId)
    yield* Effect.annotateCurrentSpan("issueId", input.issueId)
    yield* Effect.annotateCurrentSpan("projectId", input.projectId)
    const sqlClient = yield* SqlClient
    const lockRepository = yield* IssueDiscoveryLockRepository
    const scoreResult = yield* loadEligibleScoreOrCurrentOwner(input)

    if (scoreResult.action === "already-assigned") {
      return {
        action: "already-assigned",
        issueId: scoreResult.issueId,
      } satisfies AssignScoreToIssueResult
    }

    const score = scoreResult.score

    return yield* lockRepository.withLock(
      {
        organizationId: input.organizationId,
        projectId: ProjectId(input.projectId),
        lockKey: ISSUE_UPDATE_LOCK_KEY(input.issueId),
        ttlSeconds: ISSUE_UPDATE_LOCK_TTL_SECONDS,
      },
      Effect.gen(function* () {
        const assignment = yield* sqlClient.transaction(
          Effect.gen(function* () {
            const issueRepository = yield* IssueRepository
            const outboxEventWriter = yield* OutboxEventWriter
            const scoreRepository = yield* ScoreRepository
            const issue = yield* issueRepository
              .findByIdForUpdate(IssueId(input.issueId))
              .pipe(
                Effect.catchTag("NotFoundError", () =>
                  Effect.fail(new IssueNotFoundForAssignmentError({ issueId: input.issueId })),
                ),
              )

            if (issue.projectId !== score.projectId) {
              return yield* new IssueNotFoundForAssignmentError({ issueId: input.issueId })
            }

            const assignedAt = new Date()
            const { updatedIssue, isRegression } = buildIssueWithAssignedScore({
              issue,
              score,
              normalizedEmbedding: input.normalizedEmbedding,
              assignedAt,
            })

            const claimed = yield* scoreRepository.assignIssueIfUnowned({
              scoreId: score.id,
              issueId: issue.id,
              updatedAt: assignedAt,
            })

            if (!claimed) {
              const currentScore = yield* scoreRepository
                .findById(score.id)
                .pipe(Effect.catchTag("NotFoundError", () => Effect.succeed(null)))
              if (currentScore && currentScore.issueId !== null) {
                return {
                  action: "already-assigned",
                  issueId: currentScore.issueId,
                } satisfies AssignScoreToIssueResult
              }

              return yield* new ScoreAlreadyOwnedByIssueError({ scoreId: score.id })
            }

            yield* issueRepository.save(updatedIssue)
            yield* outboxEventWriter.write({
              eventName: "ScoreAssignedToIssue",
              aggregateType: "score",
              aggregateId: score.id,
              organizationId: score.organizationId,
              payload: {
                organizationId: score.organizationId,
                projectId: score.projectId,
                issueId: issue.id,
              },
            })

            if (isRegression) {
              yield* outboxEventWriter.write({
                eventName: "IssueRegressed",
                aggregateType: "issue",
                aggregateId: updatedIssue.id,
                organizationId: updatedIssue.organizationId,
                payload: {
                  organizationId: updatedIssue.organizationId,
                  projectId: updatedIssue.projectId,
                  issueId: updatedIssue.id,
                  regressedAt: score.createdAt.toISOString(),
                  triggerScoreId: score.id,
                },
              })
            }

            return {
              action: "assigned",
              issueId: issue.id,
            } satisfies AssignScoreToIssueResult
          }),
        )

        return assignment
      }),
    )
  }).pipe(Effect.withSpan("issues.assignScoreToIssue")) as Effect.Effect<
    AssignScoreToIssueResult,
    AssignScoreToIssueError
  >
