package com.correx.infrastructure.tools.web

import com.correx.core.approvals.Tier
import com.correx.core.events.events.ToolRequest
import com.correx.core.events.types.ToolInvocationId
import com.correx.core.tools.contract.ParamRole
import com.correx.core.tools.contract.Tool
import com.correx.core.tools.contract.ToolCapability
import com.correx.core.tools.contract.ToolExecutor
import com.correx.core.tools.contract.ToolResult
import com.correx.core.tools.contract.ValidationResult
import com.correx.infrastructure.tools.extract.ExtractionQuality
import com.correx.infrastructure.tools.extract.HtmlMarkdownExtractor
import io.ktor.client.HttpClient
import io.ktor.client.request.prepareGet
import io.ktor.client.statement.bodyAsChannel
import io.ktor.http.HttpHeaders
import io.ktor.http.isSuccess
import io.ktor.utils.io.readAvailable
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject
import java.security.MessageDigest

/**
 * Fetches an external URL and returns its main content as clean markdown
 * (research-workflow-spec §3/§5). The first tool that punches through the sandbox to the network,
 * so it is [Tier.T2] and declares [ToolCapability.NETWORK_ACCESS] with the URL marked
 * [ParamRole.NETWORK_TARGET] — egress is enforced by the harness ([NetworkHostRule], plane 2),
 * not by this tool's courtesy.
 *
 * Header-first guards (§3): content-type is checked before the body is read (binary/media rejected
 * at the header) and the declared size is capped; the read itself is bounded so a server that lies
 * about Content-Length cannot blow the cap. Extraction (§5) runs before anything leaves the tool;
 * the result carries the content hash + pinned extractor version for citation lineage and replay.
 */
class WebFetchTool(
    private val httpClient: HttpClient,
    private val extractor: HtmlMarkdownExtractor = HtmlMarkdownExtractor(),
    private val maxBytes: Long = DEFAULT_MAX_BYTES,
) : Tool, ToolExecutor {

    // Do NOT follow redirects. Plane-2 egress (NetworkHostRule) validates the REQUESTED host before
    // this tool runs; ktor's default redirect-following would then let a server 302 the fetch to an
    // internal host that was never validated (SSRF). With redirects off, a 3xx never opens that
    // connection — the model must re-issue web_fetch for the new URL, which re-triggers egress
    // validation. Derived client shares the injected engine (no separate resource to close).
    private val client: HttpClient = httpClient.config { followRedirects = false }

    override val name: String = "web_fetch"
    override val description: String = "Fetch a URL and return its main content as clean markdown."
    override val tier: Tier = Tier.T2
    override val requiredCapabilities: Set<ToolCapability> = setOf(ToolCapability.NETWORK_ACCESS)
    override val paramRoles: Map<String, ParamRole> = mapOf("url" to ParamRole.NETWORK_TARGET)
    override val parametersSchema: JsonObject = buildJsonObject {
        put("type", "object")
        putJsonObject("properties") {
            putJsonObject("url") {
                put("type", "string")
                put("description", "Absolute http(s) URL to fetch.")
            }
        }
        put("required", buildJsonArray { add(JsonPrimitive("url")) })
    }

    override fun validateRequest(request: ToolRequest): ValidationResult {
        val url = request.parameters["url"] as? String
        return when {
            url.isNullOrBlank() -> ValidationResult.Invalid("Missing 'url' parameter.")
            !isHttpUrl(url) -> ValidationResult.Invalid("Only http(s) URLs are supported: $url")
            else -> ValidationResult.Valid
        }
    }

    override suspend fun execute(request: ToolRequest): ToolResult {
        val invalid = validateRequest(request) as? ValidationResult.Invalid
        if (invalid != null) return ToolResult.Failure(request.invocationId, invalid.reason, recoverable = false)
        val url = request.parameters["url"] as String
        return runCatching { fetch(request.invocationId, url) }
            .getOrElse { ToolResult.Failure(request.invocationId, "fetch failed: ${it.message}", recoverable = true) }
    }

    private suspend fun fetch(invocationId: ToolInvocationId, url: String): ToolResult =
        client.prepareGet(url).execute { response ->
            val contentType = response.headers[HttpHeaders.ContentType]
            val declaredLength = response.headers[HttpHeaders.ContentLength]?.toLongOrNull()
            when {
                response.status.value in HTTP_REDIRECT_MIN..HTTP_REDIRECT_MAX ->
                    fail(
                        invocationId,
                        "URL redirected (HTTP ${response.status.value}) to " +
                            "${response.headers[HttpHeaders.Location] ?: "an unspecified location"}. Redirects are " +
                            "not followed automatically — re-issue web_fetch with that URL so egress is validated " +
                            "for its host.",
                        recoverable = true,
                    )
                !response.status.isSuccess() ->
                    fail(invocationId, "HTTP ${response.status.value} for $url", recoverable = true)
                declaredLength != null && declaredLength > maxBytes ->
                    fail(invocationId, "content too large: $declaredLength bytes (cap $maxBytes)")
                !extractor.supports(contentType) ->
                    fail(invocationId, "unsupported content-type: ${contentType ?: "unknown"}")
                else -> readAndExtract(invocationId, url, contentType, response.bodyAsChannel())
            }
        }

    private suspend fun readAndExtract(
        invocationId: ToolInvocationId,
        url: String,
        contentType: String?,
        channel: io.ktor.utils.io.ByteReadChannel,
    ): ToolResult {
        val bytes = readBounded(channel)
            ?: return fail(invocationId, "content exceeded cap of $maxBytes bytes")
        val result = extractor.extract(bytes.decodeToString(), contentType)
        return if (result.quality == ExtractionQuality.REJECTED) {
            fail(invocationId, result.reason ?: "rejected content")
        } else {
            ToolResult.Success(
                invocationId = invocationId,
                output = result.markdown,
                metadata = mapOf(
                    "url" to url,
                    "content_type" to (contentType ?: ""),
                    "content_sha256" to sha256(bytes),
                    "fetched_bytes" to bytes.size.toString(),
                    "extractor_version" to result.extractorVersion,
                    "quality" to result.quality.name,
                ),
            )
        }
    }

    private fun fail(invocationId: ToolInvocationId, reason: String, recoverable: Boolean = false): ToolResult.Failure =
        ToolResult.Failure(invocationId, reason, recoverable)

    /** Reads the channel up to [maxBytes]; returns null if the body exceeds the cap. */
    private suspend fun readBounded(channel: io.ktor.utils.io.ByteReadChannel): ByteArray? {
        val out = java.io.ByteArrayOutputStream()
        val buffer = ByteArray(READ_CHUNK)
        while (true) {
            val read = channel.readAvailable(buffer, 0, buffer.size)
            if (read == -1) break
            if (out.size() + read > maxBytes) return null
            out.write(buffer, 0, read)
        }
        return out.toByteArray()
    }

    private fun isHttpUrl(url: String): Boolean =
        runCatching { java.net.URI(url).scheme?.lowercase() in setOf("http", "https") }.getOrDefault(false)

    private fun sha256(bytes: ByteArray): String =
        MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) }

    companion object {
        const val DEFAULT_MAX_BYTES: Long = 10_000_000 // 10 MB — a 200MB "page" is an attack or a mistake
        private const val READ_CHUNK = 8_192
        private const val HTTP_REDIRECT_MIN = 300
        private const val HTTP_REDIRECT_MAX = 399
    }
}
