package com.correx.core.tasks

import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.TaskAcceptanceCriteriaSetEvent
import com.correx.core.events.events.TaskAffectedPathsSetEvent
import com.correx.core.events.events.TaskBlockedEvent
import com.correx.core.events.events.TaskCancelledEvent
import com.correx.core.events.events.TaskClaimedEvent
import com.correx.core.events.events.TaskCompletedEvent
import com.correx.core.events.events.TaskCreatedEvent
import com.correx.core.events.events.TaskDeletedEvent
import com.correx.core.events.events.TaskEditedEvent
import com.correx.core.events.events.TaskEvent
import com.correx.core.events.events.TaskLinkedEvent
import com.correx.core.events.events.TaskNoteAddedEvent
import com.correx.core.events.events.TaskReleasedEvent
import com.correx.core.events.events.TaskReopenedEvent
import com.correx.core.events.events.TaskSubmittedForReviewEvent
import com.correx.core.events.events.TaskUnblockedEvent
import com.correx.core.events.events.TaskUnlinkedEvent
import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.EventId
import com.correx.core.events.types.ProjectId
import com.correx.core.events.types.TaskId
import com.correx.core.events.types.TaskLinkType
import com.correx.core.events.types.TaskNoteAuthor
import com.correx.core.events.types.TaskTargetKind
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
import kotlinx.datetime.Clock
import java.util.UUID

/**
 * Write path for tasks: turns intents into events appended to the shared [EventStore].
 * The event log stays the single source of truth — the board is rebuilt by replay, never
 * stored. All task events for a project live in one stream ([TaskStreams.forProject]).
 *
 * Keys are `<project>-<n>`, so a task's project is recoverable from its id; mutating
 * methods therefore need only the [TaskId]. They return the rebuilt [Task], or null when the
 * task does not exist (so callers — e.g. tools — can report a clean failure). Mutations that
 * are illegal for the current status are still appended but are no-ops on replay (the reducer
 * counts them in `invalidTransitions`).
 */
@Suppress("TooManyFunctions")
class TaskService(
    private val eventStore: EventStore,
    private val clock: Clock = Clock.System,
) {
    private val boardReplayer =
        DefaultEventReplayer(eventStore, TaskBoardProjector(DefaultTaskReducer()))

    fun board(projectId: ProjectId): TaskBoard =
        boardReplayer.rebuild(TaskStreams.forProject(projectId))

    fun list(projectId: ProjectId): List<Task> =
        board(projectId).filterValues { !it.deleted }.map { (id, state) -> Task(id, state) }

    /**
     * Every task across every project, for a cross-project board. Enumerates the per-project task
     * streams ([TaskStreams.PREFIX]) and rebuilds each — there is no global task stream, so this is
     * the union of the per-project boards.
     */
    fun listAll(): List<Task> =
        eventStore.allSessionIds()
            .filter { it.value.startsWith(TaskStreams.PREFIX) }
            .flatMap { stream -> list(ProjectId(stream.value.removePrefix(TaskStreams.PREFIX))) }

    fun getTask(taskId: TaskId): Task? {
        val state = board(projectOf(taskId))[taskId] ?: return null
        return if (state.deleted) null else Task(taskId, state)
    }

    /**
     * A task's own events in order — the lifecycle audit trail straight from the log (survives
     * soft-delete, unlike [getTask]). Empty when the task never existed. Render with [TaskHistory].
     */
    fun history(taskId: TaskId): List<StoredEvent> =
        eventStore.read(TaskStreams.forProject(projectOf(taskId)))
            .filter { (it.payload as? TaskEvent)?.taskId == taskId }

    /** Ranked text search over one project (when given) or the whole cross-project board. */
    fun search(query: String, projectId: ProjectId? = null): List<Task> =
        TaskSearch.search(projectId?.let { list(it) } ?: listAll(), query)

    /**
     * Tasks ready to be picked up: TODO with every dependency satisfied (see [TaskGraph]). Surfaces
     * workable tasks for an agent or human to claim — it never assigns. Scoped to one project when
     * given, else the whole cross-project board.
     */
    fun ready(projectId: ProjectId? = null): List<Task> =
        TaskGraph.ready(projectId?.let { list(it) } ?: listAll())

    /**
     * The project a session is working in, inferred from the tasks it created/touched: any task
     * carrying a CONTEXT/SESSION link back to [sessionId] (recorded by the task tools at create and
     * claim time). Robust to whatever free-form `project` key the agent passed — it reads back the
     * project the tasks were actually filed under, so the execution-loop predicate and claim stage
     * agree without trusting the agent. Picks the earliest such task's project (lowest key sequence)
     * if a session ever spans projects. Null when the session has created no tasks yet.
     *
     * ponytail: scans the whole cross-project board (listAll) per call; fine for local single-project
     * use. Index by session link if a deployment ever runs many projects in one store.
     */
    fun projectForSession(sessionId: String): ProjectId? =
        listAll()
            .filter { t -> t.state.links.any(linkToSession(sessionId)) }
            .minByOrNull(::keySeq)
            ?.state?.projectId

    /** Next ready task for the session's project, in deterministic claim order (creation sequence, then id). */
    fun nextReadyForSession(sessionId: String): Task? =
        projectForSession(sessionId)?.let { ready(it) }.orEmpty()
            .minWithOrNull(compareBy({ keySeq(it) }, { it.taskId.value }))

    /** The task this session currently has claimed — its in-flight unit of work — or null. */
    fun activeClaim(sessionId: String): Task? =
        projectForSession(sessionId)?.let { list(it) }.orEmpty()
            .firstOrNull { it.state.status == TaskStatus.IN_PROGRESS && it.state.claimant == sessionId }

    private fun linkToSession(sessionId: String): (TaskLink) -> Boolean = { link ->
        link.type == TaskLinkType.CONTEXT &&
            link.targetKind == TaskTargetKind.SESSION &&
            link.targetId == sessionId
    }

    private fun keySeq(task: Task): Int =
        task.taskId.value.substringAfterLast('-').toIntOrNull() ?: Int.MAX_VALUE

    /** Unfinished tasks that must complete before [taskId] can proceed (resolved within its project). */
    fun blockers(taskId: TaskId): List<Task> {
        val task = getTask(taskId) ?: return emptyList()
        return TaskGraph.unmetBlockers(task, list(projectOf(taskId)))
    }

    /** Tasks that [taskId] is holding up — what completing it would unblock (within its project). */
    fun blocking(taskId: TaskId): List<Task> {
        val task = getTask(taskId) ?: return emptyList()
        return TaskGraph.blocking(task, list(projectOf(taskId)))
    }

    /**
     * Active (non-terminal) tasks in [projectId] whose title matches [title] after normalization
     * (case- and whitespace-insensitive) — the backstop against an agent re-creating a task it
     * should have found by search. A blank title never matches; a DONE/CANCELLED look-alike is not
     * a duplicate (that work may legitimately recur).
     */
    fun findDuplicates(projectId: ProjectId, title: String): List<Task> {
        val needle = normalizeTitle(title)
        if (needle.isEmpty()) return emptyList()
        return list(projectId).filter {
            it.state.status !in TERMINAL_STATUSES && normalizeTitle(it.state.title.orEmpty()) == needle
        }
    }

    private fun normalizeTitle(title: String): String =
        title.trim().lowercase().replace(WHITESPACE, " ")

    suspend fun createTask(
        projectId: ProjectId,
        title: String,
        goal: String,
        acceptanceCriteria: List<String> = emptyList(),
        affectedPaths: List<String> = emptyList(),
    ): Task {
        val key = nextKey(projectId)
        val taskId = TaskId(key)
        append(
            projectId,
            TaskCreatedEvent(
                taskId = taskId,
                projectId = projectId,
                key = key,
                title = title,
                goal = goal,
                acceptanceCriteria = acceptanceCriteria,
                affectedPaths = affectedPaths,
            ),
        )
        return requireNotNull(getTask(taskId)) { "task $key missing after creation" }
    }

    suspend fun edit(taskId: TaskId, title: String? = null, goal: String? = null): Task? =
        mutate(taskId) { TaskEditedEvent(it, title = title, goal = goal) }

    suspend fun setAcceptanceCriteria(taskId: TaskId, criteria: List<String>): Task? =
        mutate(taskId) { TaskAcceptanceCriteriaSetEvent(it, criteria) }

    suspend fun setAffectedPaths(taskId: TaskId, paths: List<String>): Task? =
        mutate(taskId) { TaskAffectedPathsSetEvent(it, paths) }

    suspend fun claim(taskId: TaskId, claimant: String): Task? =
        mutate(taskId) { TaskClaimedEvent(it, claimant) }

    suspend fun release(taskId: TaskId): Task? =
        mutate(taskId) { TaskReleasedEvent(it) }

    suspend fun block(taskId: TaskId, reason: String): Task? =
        mutate(taskId) { TaskBlockedEvent(it, reason) }

    suspend fun unblock(taskId: TaskId): Task? =
        mutate(taskId) { TaskUnblockedEvent(it) }

    suspend fun submitForReview(taskId: TaskId): Task? =
        mutate(taskId) { TaskSubmittedForReviewEvent(it) }

    suspend fun complete(taskId: TaskId): Task? =
        mutate(taskId) { TaskCompletedEvent(it) }

    suspend fun reopen(taskId: TaskId): Task? =
        mutate(taskId) { TaskReopenedEvent(it) }

    suspend fun cancel(taskId: TaskId, reason: String? = null): Task? =
        mutate(taskId) { TaskCancelledEvent(it, reason) }

    suspend fun link(taskId: TaskId, targetId: String, type: TaskLinkType, targetKind: TaskTargetKind): Task? =
        mutate(taskId) { TaskLinkedEvent(it, targetId, type, targetKind) }

    suspend fun unlink(taskId: TaskId, targetId: String, type: TaskLinkType): Task? =
        mutate(taskId) { TaskUnlinkedEvent(it, targetId, type) }

    suspend fun addNote(taskId: TaskId, author: TaskNoteAuthor, body: String): Task? =
        mutate(taskId) { TaskNoteAddedEvent(it, author, body) }

    /** Soft delete: appends a tombstone. After this, [getTask] returns null and [list] omits it. */
    suspend fun delete(taskId: TaskId): Boolean {
        if (getTask(taskId) == null) return false
        append(projectOf(taskId), TaskDeletedEvent(taskId))
        return true
    }

    private suspend fun mutate(taskId: TaskId, event: (TaskId) -> EventPayload): Task? {
        if (getTask(taskId) == null) return null
        append(projectOf(taskId), event(taskId))
        return getTask(taskId)
    }

    private fun nextKey(projectId: ProjectId): String {
        val count = DefaultEventReplayer(eventStore, TaskCounterProjection(projectId.value))
            .rebuild(TaskStreams.forProject(projectId)).count
        return "${projectId.value}-${count + 1}"
    }

    private fun projectOf(taskId: TaskId): ProjectId =
        ProjectId(taskId.value.substringBeforeLast('-'))

    private suspend fun append(projectId: ProjectId, payload: EventPayload) {
        eventStore.append(
            NewEvent(
                metadata = EventMetadata(
                    eventId = EventId(UUID.randomUUID().toString()),
                    sessionId = TaskStreams.forProject(projectId),
                    timestamp = clock.now(),
                    schemaVersion = 1,
                    causationId = null,
                    correlationId = null,
                ),
                payload = payload,
            ),
        )
    }

    private companion object {
        val TERMINAL_STATUSES = setOf(TaskStatus.DONE, TaskStatus.CANCELLED)
        val WHITESPACE = Regex("\\s+")
    }
}
