#!/usr/bin/env node

import chalk from "chalk"
import { execSync } from "child_process"
import * as fs from "fs/promises"
import { globby } from "globby"
import * as path from "path"
import { fileURLToPath } from "url"

const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url))
const ROOT_DIR = path.resolve(SCRIPT_DIR, "..")
const PROTO_DIR = path.join(ROOT_DIR, "proto")
const PY_OUT_DIR = path.join(ROOT_DIR, "src", "generated", "grpc-python")
const PY_CLIENT_DIR = path.join(PY_OUT_DIR, "client")

function hasCommand(cmd) {
	try {
		if (process.platform === "win32") {
			execSync(`where ${cmd}`, { stdio: "pipe" })
		} else {
			execSync(`which ${cmd}`, { stdio: "pipe" })
		}
		return true
	} catch {
		return false
	}
}

function resolvePython() {
	// Allow override via env.PYTHON pointing to a specific interpreter
	const envPy = process.env.PYTHON
	if (envPy) {
		try {
			execSync(`"${envPy}" --version`, { stdio: "pipe" })
			return envPy
		} catch {
			console.warn(chalk.yellow(`Warning: PYTHON override "${envPy}" is not usable, falling back to discovery.`))
		}
	}
	const candidates = ["python3", "python"]
	for (const c of candidates) {
		if (hasCommand(c)) {
			try {
				execSync(`${c} --version`, { stdio: "pipe" })
				return c
			} catch {
				// continue
			}
		}
	}
	return null
}

function checkGrpcTools(pythonExe) {
	try {
		execSync(`"${pythonExe}" -c "import grpc_tools"`, { stdio: "pipe" })
		return true
	} catch {
		return false
	}
}

async function ensureDir(dir) {
	await fs.mkdir(dir, { recursive: true })
}

async function ensureInitPy(dir) {
	try {
		await fs.writeFile(path.join(dir, "__init__.py"), "", { flag: "wx" })
	} catch {
		// exists
	}
}

/**
 * Parse proto files to extract service names with their source file and package.
 * Returns array of:
 * { serviceName: string, serviceKey: string, protoPackage: "cline"|"host", moduleBase: string }
 */
async function parseServicesWithFiles(protoDir, protoFiles) {
	const services = []
	for (const relPath of protoFiles) {
		const full = path.join(protoDir, relPath)
		const content = await fs.readFile(full, "utf8")
		const pkg = relPath.startsWith("host/") ? "host" : "cline"
		const moduleBase = path.basename(relPath, ".proto")
		const serviceRe = /service\s+(\w+Service)\s*\{([\s\S]*?)\}/g
		for (const m of content.matchAll(serviceRe)) {
			const serviceName = m[1] // e.g., TaskService
			const serviceKey = serviceName.replace(/Service$/, "").toLowerCase() // task
			const body = m[2]
			const methodRe = /rpc\s+(\w+)\s*\((stream\s)?([\w.]+)\)\s*returns\s*\((stream\s)?([\w.]+)\)/g
			const methods = []
			for (const mm of body.matchAll(methodRe)) {
				methods.push({
					name: mm[1],
					isRequestStreaming: !!mm[2],
					requestType: mm[3],
					isResponseStreaming: !!mm[4],
					responseType: mm[5],
				})
			}
			services.push({ serviceName, serviceKey, protoPackage: pkg, moduleBase, methods })
		}
	}
	return services
}

function upperFirst(s) {
	return s.length ? s[0].toUpperCase() + s.slice(1) : s
}

async function generateConnectionPy(outDir) {
	const content = `# AUTO-GENERATED FILE - DO NOT MODIFY DIRECTLY
# Generated by scripts/build-python-proto.mjs

import grpc
import time
from typing import Optional

class ConnectionManager:
    def __init__(self, address: str, timeout: float = 30.0):
        self.address = address
        self.timeout = timeout
        self._channel: Optional[grpc.Channel] = None

    def connect(self) -> None:
        if self._channel is not None:
            return
        self._channel = grpc.insecure_channel(self.address)
        # Wait for channel to be ready within timeout
        grpc.channel_ready_future(self._channel).result(timeout=self.timeout)

    def disconnect(self) -> None:
        if self._channel is not None:
            self._channel.close()
            self._channel = None

    @property
    def channel(self) -> Optional[grpc.Channel]:
        return self._channel

    def is_connected(self) -> bool:
        return self._channel is not None
`
	await fs.mkdir(outDir, { recursive: true })
	await fs.writeFile(path.join(outDir, "connection.py"), content)
	await ensureInitPy(outDir)
}

async function generateClineClientPy(outDir, services) {
	// Import per-service wrapper clients
	const importLines = []
	const seen = new Set()
	for (const s of services) {
		const fileBase = `${s.serviceKey}_client`
		const className = `${s.serviceName.replace(/Service$/, "")}Client`
		const importKey = `${fileBase}:${className}`
		if (!seen.has(importKey)) {
			importLines.push(`from .services.${fileBase} import ${className}`)
			seen.add(importKey)
		}
	}

	// Build wrapper initializations on connect (like Go New<Service>Client)
	const initLines = services.map((s) => {
		const shortName = s.serviceName.replace(/Service$/, "") // Task
		const className = `${shortName}Client`
		return `        self.${shortName} = ${className}(self._conn.channel)`
	})

	// Build attribute resets on disconnect
	const nilLines = services.map((s) => {
		const shortName = s.serviceName.replace(/Service$/, "")
		return `        self.${shortName} = None`
	})

	const content = `# AUTO-GENERATED FILE - DO NOT MODIFY DIRECTLY
# Generated by scripts/build-python-proto.mjs

from typing import Optional

import grpc
from .connection import ConnectionManager
${importLines.join("\n")}

class ClineClient:
    """
    Unified Python client analogous to src/generated/grpc-go/client/ClineClient.

    Usage:
        client = ClineClient("localhost:17611")
        client.connect()
        # Call wrappers, e.g.: client.Task.SomeRpc(...)
        client.disconnect()
    """

    def __init__(self, address: str, timeout: float = 30.0):
        self._conn = ConnectionManager(address, timeout=timeout)
        self._connected = False

${services.map((s) => `        self.${s.serviceName.replace(/Service$/, "")}: Optional[object] = None`).join("\n")}

    def connect(self) -> None:
        if self._connected:
            return
        self._conn.connect()
${initLines.join("\n")}
        self._connected = True

    def disconnect(self) -> None:
        if not self._connected:
            return
        self._conn.disconnect()
${nilLines.join("\n")}
        self._connected = False

    def is_connected(self) -> bool:
        return self._connected

    @property
    def channel(self) -> Optional[grpc.Channel]:
        return self._conn.channel
`
	const clientDir = outDir
	await fs.mkdir(clientDir, { recursive: true })
	await fs.writeFile(path.join(clientDir, "cline_client.py"), content)
}

async function generatePythonClient(protoDir, pyOutDir, clientDir, protoFiles) {
	// Ensure package structure for client
	await fs.mkdir(clientDir, { recursive: true })
	await ensureInitPy(pyOutDir)
	await ensureInitPy(clientDir)

	const services = await parseServicesWithFiles(protoDir, protoFiles)

	// connection.py
	await generateConnectionPy(clientDir)

	// services/ per-service wrappers (mirror Go client/services)
	const servicesDir = path.join(clientDir, "services")
	await fs.mkdir(servicesDir, { recursive: true })
	await ensureInitPy(servicesDir)
	await generateServiceClientsPy(servicesDir, services)

	// cline_client.py (unified that composes service wrappers)
	await generateClineClientPy(clientDir, services)
}

async function generateServiceClientsPy(outDir, services) {
	await fs.mkdir(outDir, { recursive: true })
	await ensureInitPy(outDir)

	for (const s of services) {
		const shortName = s.serviceName.replace(/Service$/, "") // Task
		const className = `${shortName}Client`
		const fileName = `${s.serviceKey}_client.py`

		const aliasPb2 = `${s.protoPackage}_${s.moduleBase}_pb2`
		const aliasGrpc = `${s.protoPackage}_${s.moduleBase}_pb2_grpc`

		const methodLines = s.methods
			.map((m) => {
				const reqTypeName = m.requestType.split(".").pop()
				const respTypeName = m.responseType.split(".").pop()
				if (m.isResponseStreaming) {
					return `
    def ${m.name}(self, req):
        """
        Server-streaming RPC.
        :param req: ${aliasPb2}.${reqTypeName}
        :return: iterator of ${aliasPb2}.${respTypeName}
        """
        return self._stub.${m.name}(req)`
				} else {
					return `
    def ${m.name}(self, req):
        """
        Unary RPC.
        :param req: ${aliasPb2}.${reqTypeName}
        :return: ${aliasPb2}.${respTypeName}
        """
        return self._stub.${m.name}(req)`
				}
			})
			.join("\n")

		const content = `# AUTO-GENERATED FILE - DO NOT MODIFY DIRECTLY
# Generated by scripts/build-python-proto.mjs

import grpc
from ${s.protoPackage} import ${s.moduleBase}_pb2 as ${aliasPb2}
from ${s.protoPackage} import ${s.moduleBase}_pb2_grpc as ${aliasGrpc}

class ${className}:
    def __init__(self, channel: grpc.Channel):
        self._stub = ${aliasGrpc}.${s.serviceName}Stub(channel)
${methodLines}
`
		await fs.writeFile(path.join(outDir, fileName), content)
	}
}

async function generatePyproject(outDir) {
	const content = `[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"

[project]
name = "cline-grpc-python"
version = "0.1.0"
description = "Generated Python gRPC stubs and client wrappers for Cline protos"
license = { text: "Apache-2.0" }
requires-python = ">=3.9"
dependencies = [
  "grpcio>=1.56.0",
  "protobuf>=4.21.0"
]

[tool.setuptools.packages.find]
where = ["."]
`
	await fs.writeFile(path.join(outDir, "pyproject.toml"), content)
}

async function main() {
	console.log(chalk.cyan("Starting Python protobuf code generation..."))

	// Verify proto dir exists
	try {
		const stat = await fs.stat(PROTO_DIR)
		if (!stat.isDirectory()) {
			console.error(chalk.red(`Proto directory is not a folder: ${PROTO_DIR}`))
			process.exit(1)
		}
	} catch {
		console.error(chalk.red(`Proto directory not found: ${PROTO_DIR}`))
		process.exit(1)
	}

	// Resolve Python
	const python = resolvePython()
	if (!python) {
		console.error(
			chalk.red("Python not found on PATH. Please install Python 3 and ensure it is available (python3 or python)."),
		)
		process.exit(1)
	}
	console.log(chalk.green(`✓ Using Python executable: ${python}`))

	// Check grpcio-tools
	if (!checkGrpcTools(python)) {
		console.error(chalk.red("Missing dependency: grpcio-tools"))
		console.log(chalk.yellow("Install with:"))
		console.log(chalk.yellow(`  ${python} -m pip install grpcio-tools --user --break-system-packages`))
		process.exit(1)
	}
	console.log(chalk.green("✓ grpcio-tools available"))

	// Discover proto files
	const protoFiles = await globby("**/*.proto", { cwd: PROTO_DIR })
	if (!protoFiles.length) {
		console.error(chalk.red("No .proto files found under ./proto"))
		process.exit(1)
	}
	console.log(chalk.cyan(`Found ${protoFiles.length} proto files`))

	// Ensure output directory
	await ensureDir(PY_OUT_DIR)

	// Build and run protoc command via grpc_tools
	const quoted = (s) => `"${s}"`
	const pythonCmd = quoted(python)
	const cmd =
		`${pythonCmd} -m grpc_tools.protoc ` +
		`-I ${quoted(PROTO_DIR)} ` +
		`--python_out=${quoted(PY_OUT_DIR)} ` +
		`--grpc_python_out=${quoted(PY_OUT_DIR)} ` +
		protoFiles.map((f) => quoted(f)).join(" ")

	try {
		console.log(chalk.cyan(`Generating Python code into ${PY_OUT_DIR}...`))
		execSync(cmd, { cwd: ROOT_DIR, stdio: "inherit", env: process.env })
	} catch (error) {
		console.error(chalk.red("Error generating Python code:"), error?.message || error)
		process.exit(1)
	}

	// Ensure package structure (__init__.py) for imports
	await ensureInitPy(PY_OUT_DIR)
	try {
		const clineDir = path.join(PY_OUT_DIR, "cline")
		const hostDir = path.join(PY_OUT_DIR, "host")
		// These may or may not exist depending on which protos are present
		await fs
			.stat(clineDir)
			.then(() => ensureInitPy(clineDir))
			.catch(() => {})
		await fs
			.stat(hostDir)
			.then(() => ensureInitPy(hostDir))
			.catch(() => {})
	} catch {
		// ignore
	}

	// Generate Python client structure analogous to src/generated/grpc-go/client
	await generatePythonClient(PROTO_DIR, PY_OUT_DIR, PY_CLIENT_DIR, protoFiles)

	// Generate a minimal pyproject.toml in the generated output so it can be pip-installed if desired
	await generatePyproject(PY_OUT_DIR)

	console.log(chalk.green("✓ Python protobuf and client code generation completed successfully!"))
	console.log(chalk.cyan(`Output directory: ${PY_OUT_DIR}`))
	console.log(chalk.cyan(`Client directory: ${PY_CLIENT_DIR}`))
	console.log(chalk.cyan(`PyProject: ${path.join(PY_OUT_DIR, "pyproject.toml")}`))
	console.log(chalk.gray("Note: To import, add the output dir to your PYTHONPATH or pip install -e src/generated/grpc-python"))
}

if (import.meta.url === `file://${process.argv[1]}`) {
	main().catch((err) => {
		console.error(chalk.red("Unexpected error in build-python-proto.mjs:"), err)
		process.exit(1)
	})
}
