package com.correx.infrastructure.tools.task

import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.ToolRequest
import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.events.types.TaskId
import com.correx.core.events.types.TaskLinkType
import com.correx.core.events.types.TaskTargetKind
import com.correx.core.events.types.ToolInvocationId
import com.correx.core.tasks.TaskContextAssembler
import com.correx.core.tasks.TaskService
import com.correx.core.tasks.TaskStatus
import com.correx.core.tools.contract.ToolResult
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test

class TaskToolsTest {

    private val service = TaskService(InMemoryEventStore())
    private val create = TaskCreateTool(service)
    private val decompose = TaskDecomposeTool(service)
    private val update = TaskUpdateTool(service)
    private val delete = TaskDeleteTool(service)
    private val search = TaskSearchTool(service)
    private val ready = TaskReadyTool(service)
    private val context = TaskContextTool(TaskContextAssembler(service))

    private var counter = 0
    private fun request(tool: String, params: Map<String, Any>) = ToolRequest(
        invocationId = ToolInvocationId("inv-${counter++}"),
        sessionId = SessionId("s-1"),
        stageId = StageId("stage-1"),
        toolName = tool,
        parameters = params,
    )

    private suspend fun createTask(): String {
        val result = create.execute(
            request("task_create", mapOf("project" to "auth", "title" to "JWT refresh", "goal" to "stay authed")),
        )
        assertTrue(result is ToolResult.Success)
        return (result as ToolResult.Success).metadata.getValue("taskId")
    }

    @Test
    fun `task_create creates a task and returns its id`() = runBlocking {
        val id = createTask()
        assertEquals("auth-1", id)
        assertEquals(TaskStatus.TODO, service.getTask(com.correx.core.events.types.TaskId(id))?.state?.status)
    }

    @Test
    fun `task_create rejects missing required fields`() = runBlocking {
        val result = create.execute(request("task_create", mapOf("project" to "auth")))
        assertTrue(result is ToolResult.Failure)
    }

    // task_decompose receives nested args as JSON strings (the orchestrator flattens non-primitive
    // tool arguments), so the tests pass strings to exercise the real parse path — not structured Lists.
    private val threeTaskGraph = """
        [
          {"ref":"scaffold","title":"Scaffold app","goal":"shell + build","affected_paths":["apps/web/**"]},
          {"ref":"auth","title":"Auth view","goal":"login","depends_on":["scaffold"]},
          {"ref":"dash","title":"Dashboard","goal":"home","depends_on":["scaffold"]}
        ]
    """.trimIndent()

    @Test
    fun `task_decompose builds a DEPENDS_ON graph with one ready root and a blocked epic`() = runBlocking {
        val result = decompose.execute(
            request(
                "task_decompose",
                mapOf(
                    "project" to "webui",
                    "parent" to """{"title":"Frontend web UI","goal":"web ui for correx"}""",
                    "tasks" to threeTaskGraph,
                ),
            ),
        )
        assertTrue(result is ToolResult.Success)
        val meta = (result as ToolResult.Success).metadata
        // parent created first (webui-1), then children in order (webui-2..4).
        assertEquals("webui-2,webui-3,webui-4,webui-1", meta.getValue("taskIds"))
        assertEquals("webui-2", meta.getValue("readyIds"))
        // affected_paths survived the JSON parse (not dropped like listParam would on the flattened arg).
        assertEquals(listOf("apps/web/**"), service.getTask(TaskId("webui-2"))!!.state.affectedPaths)
        // scaffold is the only ready task; the dependents and the epic are blocked.
        assertEquals(
            listOf("webui-2"),
            service.ready(com.correx.core.events.types.ProjectId("webui")).map { it.taskId.value },
        )
        assertEquals(listOf("webui-2"), service.blockers(TaskId("webui-3")).map { it.taskId.value })
        assertEquals(3, service.blockers(TaskId("webui-1")).size)
        // child implements the epic; epic depends on the child.
        assertTrue(
            service.getTask(TaskId("webui-2"))!!.state.links.any {
                it.type == TaskLinkType.IMPLEMENTS && it.targetKind == TaskTargetKind.TASK && it.targetId == "webui-1"
            },
        )
    }

    @Test
    fun `task_decompose resolves depends_on by index`() = runBlocking {
        val result = decompose.execute(
            request(
                "task_decompose",
                mapOf(
                    "project" to "webui",
                    "tasks" to """[{"title":"Scaffold","goal":"g"},{"title":"Wire","goal":"g","depends_on":["0"]}]""",
                ),
            ),
        )
        assertTrue(result is ToolResult.Success)
        assertEquals(listOf("webui-1"), service.blockers(TaskId("webui-2")).map { it.taskId.value })
    }

    @Test
    fun `task_decompose rejects a dependency cycle`() = runBlocking {
        val cyclicTasks = """[{"ref":"a","title":"A","goal":"g","depends_on":["b"]},""" +
            """{"ref":"b","title":"B","goal":"g","depends_on":["a"]}]"""
        val result = decompose.execute(
            request(
                "task_decompose",
                mapOf(
                    "project" to "webui",
                    "tasks" to cyclicTasks,
                ),
            ),
        )
        assertTrue(result is ToolResult.Failure)
        assertTrue((result as ToolResult.Failure).reason.contains("cycle", ignoreCase = true))
    }

    @Test
    fun `task_decompose rejects an unresolved dependency reference`() = runBlocking {
        val result = decompose.execute(
            request(
                "task_decompose",
                mapOf("project" to "webui", "tasks" to """[{"title":"A","goal":"g","depends_on":["ghost"]}]"""),
            ),
        )
        assertTrue(result is ToolResult.Failure)
    }

    @Test
    fun `task_decompose rejects a task missing title or goal`() = runBlocking {
        val result = decompose.execute(
            request("task_decompose", mapOf("project" to "webui", "tasks" to """[{"title":"A"}]""")),
        )
        assertTrue(result is ToolResult.Failure)
    }

    @Test
    fun `task_decompose rejects an empty task list`() = runBlocking {
        val result = decompose.execute(request("task_decompose", mapOf("project" to "webui", "tasks" to "[]")))
        assertTrue(result is ToolResult.Failure)
    }

    @Test
    fun `task_decompose blocks a duplicate title and force needs a recorded reason`() = runBlocking {
        create.execute(request("task_create", mapOf("project" to "webui", "title" to "Scaffold app", "goal" to "g")))
        val dup = mapOf("project" to "webui", "tasks" to """[{"title":"Scaffold app","goal":"again"}]""")

        assertTrue(decompose.execute(request("task_decompose", dup)) is ToolResult.Failure)
        assertTrue(decompose.execute(request("task_decompose", dup + ("force" to true))) is ToolResult.Failure)

        val forcedArgs = dup + ("force" to true) + ("force_reason" to "intentional rebuild")
        val forced = decompose.execute(request("task_decompose", forcedArgs))
        assertTrue(forced is ToolResult.Success)
        val id = (forced as ToolResult.Success).metadata.getValue("taskIds").substringBefore(",")
        assertTrue(service.getTask(TaskId(id))!!.state.notes.any { it.body.startsWith("[force]") })
    }

    @Test
    fun `task_update applies status action, edits, link and note`() = runBlocking {
        val id = createTask()
        val taskId = com.correx.core.events.types.TaskId(id)

        val result = update.execute(
            request(
                "task_update",
                mapOf(
                    "id" to id,
                    "action" to "claim",
                    "claimant" to "claude-opus",
                    "title" to "JWT refresh flow",
                    "link_target" to "adr-7",
                    "link_type" to "implements",
                    "note" to "starting",
                ),
            ),
        )

        assertTrue(result is ToolResult.Success)
        val state = service.getTask(taskId)!!.state
        assertEquals(TaskStatus.IN_PROGRESS, state.status)
        assertEquals("claude-opus", state.claimant)
        assertEquals("JWT refresh flow", state.title)
        assertTrue(state.links.any { it.targetId == "adr-7" && it.type == TaskLinkType.IMPLEMENTS })
        assertEquals("starting", state.notes.single().body)
    }

    @Test
    fun `task_create and claim auto-link the agent session as CONTEXT`() = runBlocking {
        val id = createTask() // session s-1
        val taskId = TaskId(id)

        // Created from s-1 → one CONTEXT/SESSION edge.
        val afterCreate = service.getTask(taskId)!!.state.links.single()
        assertEquals("s-1", afterCreate.targetId)
        assertEquals(TaskTargetKind.SESSION, afterCreate.targetKind)
        assertEquals(TaskLinkType.CONTEXT, afterCreate.type)

        // Claiming from the same session dedupes to the same single edge.
        update.execute(request("task_update", mapOf("id" to id, "action" to "claim")))
        assertEquals(1, service.getTask(taskId)!!.state.links.count { it.targetId == "s-1" })
    }

    @Test
    fun `task_update rejects an unknown action`() = runBlocking {
        val id = createTask()
        val result = update.execute(request("task_update", mapOf("id" to id, "action" to "frobnicate")))
        assertTrue(result is ToolResult.Failure)
    }

    @Test
    fun `task_update on a missing task fails`() = runBlocking {
        val result = update.execute(request("task_update", mapOf("id" to "auth-999", "title" to "x")))
        assertTrue(result is ToolResult.Failure)
    }

    @Test
    fun `task_context returns a rendered bundle with dependencies`() = runBlocking {
        val id = createTask()
        update.execute(
            request("task_update", mapOf("id" to id, "link_target" to "adr-7", "link_type" to "implements")),
        )

        val result = context.execute(request("task_context", mapOf("id" to id)))

        assertTrue(result is ToolResult.Success)
        val output = (result as ToolResult.Success).output
        assertTrue(output.startsWith("task $id [TODO]"))
        assertTrue(output.contains("adr-7 (IMPLEMENTS)"))
    }

    @Test
    fun `task_context on a missing task fails`() = runBlocking {
        assertTrue(context.execute(request("task_context", mapOf("id" to "auth-999"))) is ToolResult.Failure)
    }

    @Test
    fun `task_search ranks matches across projects and reports misses`() = runBlocking {
        create.execute(
            request("task_create", mapOf("project" to "auth", "title" to "JWT refresh", "goal" to "stay authed")),
        )
        create.execute(
            request(
                "task_create",
                mapOf("project" to "auth", "title" to "Rotate keys", "goal" to "rotate signing keys"),
            ),
        )

        val hit = search.execute(request("task_search", mapOf("query" to "jwt")))
        assertTrue(hit is ToolResult.Success)
        val out = (hit as ToolResult.Success).output
        assertTrue(out.contains("auth-1"))
        assertFalse(out.contains("auth-2"))

        val miss = search.execute(request("task_search", mapOf("query" to "zebra")))
        assertTrue((miss as ToolResult.Success).output.contains("no tasks match"))
    }

    @Test
    fun `task_ready lists unblocked TODO tasks only`() = runBlocking {
        create.execute(request("task_create", mapOf("project" to "auth", "title" to "A", "goal" to "g"))) // auth-1
        create.execute(request("task_create", mapOf("project" to "auth", "title" to "B", "goal" to "g"))) // auth-2
        // auth-2 depends on auth-1 (still TODO) → auth-2 not ready.
        update.execute(
            request(
                "task_update",
                mapOf("id" to "auth-2", "link_target" to "auth-1", "link_type" to "depends_on", "link_kind" to "task"),
            ),
        )

        val out = (ready.execute(request("task_ready", emptyMap())) as ToolResult.Success).output
        assertTrue(out.contains("auth-1"))
        assertFalse(out.contains("auth-2"))
    }

    @Test
    fun `task_create blocks a duplicate title and force needs a recorded reason`() = runBlocking {
        create.execute(request("task_create", mapOf("project" to "auth", "title" to "JWT refresh", "goal" to "g")))

        val dup = create.execute(
            request("task_create", mapOf("project" to "auth", "title" to "jwt refresh", "goal" to "g")),
        )
        assertTrue(dup is ToolResult.Failure)
        assertTrue((dup as ToolResult.Failure).reason.contains("duplicate", ignoreCase = true))

        // force without a reason is rejected.
        val noReason = create.execute(
            request(
                "task_create",
                mapOf("project" to "auth", "title" to "jwt refresh", "goal" to "g", "force" to true),
            ),
        )
        assertTrue(noReason is ToolResult.Failure)
        assertTrue((noReason as ToolResult.Failure).reason.contains("force_reason"))

        // force with a reason creates, and records the reason as a note.
        val forced = create.execute(
            request(
                "task_create",
                mapOf("project" to "auth", "title" to "jwt refresh", "goal" to "g",
                    "force" to true, "force_reason" to "separate refresh path for mobile"),
            ),
        )
        assertTrue(forced is ToolResult.Success)
        val id = (forced as ToolResult.Success).metadata.getValue("taskId")
        val note = service.getTask(TaskId(id))!!.state.notes.single()
        assertTrue(note.body.contains("[force]"))
        assertTrue(note.body.contains("separate refresh path for mobile"))
    }

    @Test
    fun `task_update blocks claiming over unmet blockers, force overrides with a warning`() = runBlocking {
        create.execute(request("task_create", mapOf("project" to "auth", "title" to "A", "goal" to "g"))) // auth-1
        create.execute(request("task_create", mapOf("project" to "auth", "title" to "B", "goal" to "g"))) // auth-2
        update.execute(
            request(
                "task_update",
                mapOf("id" to "auth-2", "link_target" to "auth-1", "link_type" to "depends_on", "link_kind" to "task"),
            ),
        )

        // Plain claim is blocked — auth-1 is unfinished.
        val blocked = update.execute(request("task_update", mapOf("id" to "auth-2", "action" to "claim")))
        assertTrue(blocked is ToolResult.Failure)
        assertTrue((blocked as ToolResult.Failure).reason.contains("blocker", ignoreCase = true))
        assertEquals(TaskStatus.TODO, service.getTask(TaskId("auth-2"))!!.state.status) // not mutated

        // force without a reason is rejected.
        val noReason = update.execute(
            request("task_update", mapOf("id" to "auth-2", "action" to "claim", "force" to true)),
        )
        assertTrue(noReason is ToolResult.Failure)
        assertTrue((noReason as ToolResult.Failure).reason.contains("force_reason"))
        assertEquals(TaskStatus.TODO, service.getTask(TaskId("auth-2"))!!.state.status)

        // force with a reason claims, records the reason as a note, and warns.
        val forced = update.execute(
            request(
                "task_update",
                mapOf(
                    "id" to "auth-2",
                    "action" to "claim",
                    "force" to true,
                    "force_reason" to "prep work, auth-1 lands soon",
                ),
            ),
        )
        assertTrue((forced as ToolResult.Success).output.contains("WARNING"))
        assertEquals(TaskStatus.IN_PROGRESS, service.getTask(TaskId("auth-2"))!!.state.status)
        assertTrue(
            service.getTask(TaskId("auth-2"))!!.state.notes.any {
                it.body.contains("[force]") && it.body.contains("prep work")
            },
        )
    }

    @Test
    fun `claiming a task records the session working it`() = runBlocking {
        create.execute(
            request(
                "task_create",
                mapOf("project" to "auth", "title" to "A", "goal" to "g", "affected_paths" to listOf("core/auth/**")),
            ),
        )
        val captured = mutableListOf<Triple<String, String, List<String>>>()
        val recorder = SessionFactRecorder { sid, tid, paths -> captured.add(Triple(sid.value, tid.value, paths)) }

        TaskUpdateTool(service, recorder).execute(request("task_update", mapOf("id" to "auth-1", "action" to "claim")))

        assertEquals(1, captured.size)
        assertEquals("auth-1", captured.single().second)
        assertEquals(listOf("core/auth/**"), captured.single().third)
    }

    @Test
    fun `task_update blocks completing a task with no writes under its affected_paths`() = runBlocking {
        create.execute(
            request(
                "task_create",
                mapOf("project" to "auth", "title" to "A", "goal" to "g", "affected_paths" to listOf("core/auth/**")),
            ),
        )
        val id = "auth-1"
        update.execute(request("task_update", mapOf("id" to id, "action" to "claim")))
        update.execute(request("task_update", mapOf("id" to id, "action" to "submit_for_review")))

        // No matching write this session → complete is blocked, task stays IN_REVIEW.
        val noWrites = TaskUpdateTool(service, sessionWrites = SessionWrites { emptySet() })
        val blocked = noWrites.execute(request("task_update", mapOf("id" to id, "action" to "complete")))
        assertTrue(blocked is ToolResult.Failure)
        assertEquals(TaskStatus.IN_REVIEW, service.getTask(TaskId(id))!!.state.status)

        // A write under affected_paths → completes.
        val withWrites = TaskUpdateTool(service, sessionWrites = SessionWrites { setOf("core/auth/Login.kt") })
        val ok = withWrites.execute(request("task_update", mapOf("id" to id, "action" to "complete")))
        assertTrue(ok is ToolResult.Success)
        assertEquals(TaskStatus.DONE, service.getTask(TaskId(id))!!.state.status)
    }

    @Test
    fun `task_delete tombstones the task`() = runBlocking {
        val id = createTask()
        val deleted = delete.execute(request("task_delete", mapOf("id" to id)))

        assertTrue(deleted is ToolResult.Success)
        assertNull(service.getTask(com.correx.core.events.types.TaskId(id)))
        // Deleting again now fails: it is gone from active views.
        assertTrue(delete.execute(request("task_delete", mapOf("id" to id))) is ToolResult.Failure)
    }

    @Test
    fun `propose_scope unions new paths into the claimed task and keeps existing`() = runBlocking {
        val id = createTask()
        update.execute(request("task_update", mapOf("id" to id, "affected_paths" to listOf("src/a.kt"))))
        update.execute(request("task_update", mapOf("id" to id, "action" to "claim")))

        val result = ProposeScopeTool(service).execute(
            request("propose_scope", mapOf("paths" to listOf("src/b.kt"), "reason" to "shared helper")),
        )

        assertTrue(result is ToolResult.Success)
        assertEquals(listOf("src/a.kt", "src/b.kt"), service.getTask(TaskId(id))?.state?.affectedPaths)
    }

    @Test
    fun `propose_scope fails when the session has no claimed task`() = runBlocking {
        createTask() // exists but unclaimed
        val result = ProposeScopeTool(service).execute(
            request("propose_scope", mapOf("paths" to listOf("src/b.kt"))),
        )
        assertTrue(result is ToolResult.Failure)
    }
}

/** Minimal append-capable in-memory store for tool tests. */
private class InMemoryEventStore : EventStore {
    private val events = mutableListOf<StoredEvent>()
    private var global = 0L
    private val perSession = mutableMapOf<SessionId, Long>()

    override suspend fun append(event: NewEvent): StoredEvent {
        global += 1
        val seq = (perSession[event.metadata.sessionId] ?: 0L) + 1
        perSession[event.metadata.sessionId] = seq
        val stored = StoredEvent(event.metadata, global, seq, event.payload)
        events += stored
        return stored
    }

    override suspend fun appendAll(events: List<NewEvent>): List<StoredEvent> = events.map { append(it) }

    override fun read(sessionId: SessionId): List<StoredEvent> =
        events.filter { it.metadata.sessionId == sessionId }.sortedBy { it.sessionSequence }

    override fun readFrom(sessionId: SessionId, fromSequence: Long): List<StoredEvent> =
        read(sessionId).filter { it.sessionSequence >= fromSequence }

    override fun lastSequence(sessionId: SessionId): Long? = read(sessionId).maxOfOrNull { it.sessionSequence }

    override fun subscribe(sessionId: SessionId): Flow<StoredEvent> = emptyFlow()

    override fun subscribeAll(): Flow<StoredEvent> = emptyFlow()

    override suspend fun lastGlobalSequence(): Long = global

    override fun allEvents(): Sequence<StoredEvent> = events.asSequence()

    override fun allSessionIds(): Set<SessionId> = events.map { it.metadata.sessionId }.toSet()
}
