import path from "path"
import { spawnSync } from "child_process"
import { connect } from "net"
import { EventBus } from "../events/bus"
import { ConfigStore } from "../config/store"
import { BinaryRegistry } from "../config/binaries"
import { FileSystemBrowser } from "../filesystem/browser"
import { searchWorkspaceFiles, WorkspaceFileSearchOptions } from "../filesystem/search"
import { clearWorkspaceSearchCache } from "../filesystem/search-cache"
import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../api-types"
import { WorkspaceRuntime, ProcessExitInfo } from "./runtime"
import { Logger } from "../logger"
import { ensureWorkspaceOpencodeConfig } from "../opencode-config"
import { getContextEngineService } from "../context-engine"

const STARTUP_STABILITY_DELAY_MS = 1500

interface WorkspaceManagerOptions {
  rootDir: string
  configStore: ConfigStore
  binaryRegistry: BinaryRegistry
  eventBus: EventBus
  logger: Logger
}

interface WorkspaceRecord extends WorkspaceDescriptor { }

export class WorkspaceManager {
  private readonly workspaces = new Map<string, WorkspaceRecord>()
  private readonly runtime: WorkspaceRuntime

  constructor(private readonly options: WorkspaceManagerOptions) {
    this.runtime = new WorkspaceRuntime(this.options.eventBus, this.options.logger)
  }

  list(): WorkspaceDescriptor[] {
    return Array.from(this.workspaces.values())
  }

  get(id: string): WorkspaceDescriptor | undefined {
    return this.workspaces.get(id)
  }

  getInstancePort(id: string): number | undefined {
    return this.workspaces.get(id)?.port
  }

  /**
   * Generate a deterministic workspace ID based on folder path
   * This ensures the same folder always gets the same workspace ID,
   * allowing sessions to persist across app restarts
   */
  private generateDeterministicId(folderPath: string): string {
    // Normalize the path for consistent hashing across platforms
    const normalizedPath = folderPath.replace(/\\/g, '/').toLowerCase()

    // Simple hash function to create a short, deterministic ID
    let hash = 0
    for (let i = 0; i < normalizedPath.length; i++) {
      const char = normalizedPath.charCodeAt(i)
      hash = ((hash << 5) - hash) + char
      hash = hash & hash // Convert to 32bit integer
    }

    // Convert to base36 and ensure positive
    const hashStr = Math.abs(hash).toString(36)

    // Return a short but unique ID
    return hashStr.padStart(8, '0')
  }


  listFiles(workspaceId: string, relativePath = "."): FileSystemEntry[] {
    const workspace = this.requireWorkspace(workspaceId)
    const browser = new FileSystemBrowser({ rootDir: workspace.path })
    return browser.list(relativePath)
  }

  searchFiles(workspaceId: string, query: string, options?: WorkspaceFileSearchOptions): FileSystemEntry[] {
    const workspace = this.requireWorkspace(workspaceId)
    return searchWorkspaceFiles(workspace.path, query, options)
  }

  readFile(workspaceId: string, relativePath: string): WorkspaceFileResponse {
    const workspace = this.requireWorkspace(workspaceId)
    const browser = new FileSystemBrowser({ rootDir: workspace.path })
    const contents = browser.readFile(relativePath)
    return {
      workspaceId,
      relativePath,
      contents,
    }
  }

  async create(folder: string, name?: string): Promise<WorkspaceDescriptor> {
    // Special constant for Native mode (no OpenCode binary)
    const NATIVE_MODE_PATH = "__nomadarch_native__"

    const binary = this.options.binaryRegistry.resolveDefault()
    const resolvedBinaryPath = this.resolveBinaryPath(binary.path)
    const workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder)

    // Generate a deterministic workspace ID based on the folder path
    // This ensures the same folder always gets the same ID, allowing sessions to persist
    const id = this.generateDeterministicId(workspacePath)

    // Check if workspace already exists - if so, return the existing one
    const existingWorkspace = this.workspaces.get(id)
    if (existingWorkspace && existingWorkspace.status === "ready") {
      this.options.logger.info({ workspaceId: id }, "Reusing existing workspace")
      return existingWorkspace
    }

    clearWorkspaceSearchCache(workspacePath)

    // Check if we're in native mode
    const isNativeMode = resolvedBinaryPath === NATIVE_MODE_PATH || binary.path === NATIVE_MODE_PATH

    this.options.logger.info({
      workspaceId: id,
      folder: workspacePath,
      binary: resolvedBinaryPath,
      isNativeMode
    }, "Creating workspace")

    const proxyPath = `/workspaces/${id}/instance`


    const descriptor: WorkspaceRecord = {
      id,
      path: workspacePath,
      name,
      status: "starting",
      proxyPath,
      binaryId: resolvedBinaryPath,
      binaryLabel: isNativeMode ? "NomadArch Native" : binary.label,
      binaryVersion: isNativeMode ? "Native" : binary.version,
      createdAt: new Date().toISOString(),
      updatedAt: new Date().toISOString(),
    }

    // Native mode doesn't need binary version detection
    if (!isNativeMode && !descriptor.binaryVersion) {
      descriptor.binaryVersion = this.detectBinaryVersion(resolvedBinaryPath)
    }

    this.workspaces.set(id, descriptor)


    this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor })

    // In native mode, we don't launch a binary - the workspace is immediately ready
    // and uses native session management APIs instead
    if (isNativeMode) {
      this.options.logger.info({ workspaceId: id }, "Starting native mode workspace (no binary)")

      // Native mode is immediately ready - no process to launch
      descriptor.status = "ready"
      descriptor.updatedAt = new Date().toISOString()
      // No pid or port for native mode - it uses the server's own APIs

      this.options.eventBus.publish({ type: "workspace.started", workspace: descriptor })
      this.options.logger.info({ workspaceId: id }, "Native mode workspace ready")

      // Trigger Context-Engine indexing (non-blocking)
      const contextEngine = getContextEngineService()
      if (contextEngine) {
        contextEngine.indexPath(workspacePath).catch((error) => {
          this.options.logger.warn({ workspaceId: id, error }, "Context-Engine indexing failed")
        })
      }

      return descriptor
    }

    // SDK/binary mode - launch the OpenCode process
    const preferences = this.options.configStore.get().preferences ?? {}
    const userEnvironment = preferences.environmentVariables ?? {}
    const opencodeConfigDir = ensureWorkspaceOpencodeConfig(id)
    const environment = {
      ...userEnvironment,
      OPENCODE_CONFIG_DIR: opencodeConfigDir,
    }

    try {
      const { pid, port, exitPromise, getLastOutput } = await this.runtime.launch({
        workspaceId: id,
        folder: workspacePath,
        binaryPath: resolvedBinaryPath,
        environment,
        onExit: (info) => this.handleProcessExit(info.workspaceId, info),
      })

      await this.waitForWorkspaceReadiness({ workspaceId: id, port, exitPromise, getLastOutput })

      descriptor.pid = pid
      descriptor.port = port
      descriptor.status = "ready"
      descriptor.updatedAt = new Date().toISOString()
      this.options.eventBus.publish({ type: "workspace.started", workspace: descriptor })
      this.options.logger.info({ workspaceId: id, port }, "Workspace ready")

      // Trigger Context-Engine indexing (non-blocking)
      const contextEngine = getContextEngineService()
      if (contextEngine) {
        contextEngine.indexPath(workspacePath).catch((error) => {
          this.options.logger.warn({ workspaceId: id, error }, "Context-Engine indexing failed")
        })
      }

      return descriptor
    } catch (error) {
      descriptor.status = "error"
      let errorMessage = error instanceof Error ? error.message : String(error)

      // Check for common OpenCode issues
      if (errorMessage.includes('ENOENT') || errorMessage.includes('command not found')) {
        errorMessage = `OpenCode binary not found at '${resolvedBinaryPath}'. Please install OpenCode CLI from https://opencode.ai/ and ensure it's in your PATH.`
      } else if (errorMessage.includes('health check')) {
        errorMessage = `Workspace health check failed. OpenCode started but is not responding correctly. Check OpenCode logs for details.`
      }

      descriptor.error = errorMessage
      descriptor.updatedAt = new Date().toISOString()
      this.options.eventBus.publish({ type: "workspace.error", workspace: descriptor })
      this.options.logger.error({ workspaceId: id, err: error }, "Workspace failed to start")
      throw new Error(errorMessage)
    }
  }

  async delete(id: string): Promise<WorkspaceDescriptor | undefined> {
    const workspace = this.workspaces.get(id)
    if (!workspace) return undefined

    this.options.logger.info({ workspaceId: id }, "Stopping workspace")
    const wasRunning = Boolean(workspace.pid)
    if (wasRunning) {
      await this.runtime.stop(id).catch((error) => {
        this.options.logger.warn({ workspaceId: id, err: error }, "Failed to stop workspace process cleanly")
      })
    }

    this.workspaces.delete(id)
    clearWorkspaceSearchCache(workspace.path)
    if (!wasRunning) {
      this.options.eventBus.publish({ type: "workspace.stopped", workspaceId: id })
    }
    return workspace
  }

  async shutdown() {
    this.options.logger.info("Shutting down all workspaces")
    for (const [id, workspace] of this.workspaces) {
      if (workspace.pid) {
        this.options.logger.info({ workspaceId: id }, "Stopping workspace during shutdown")
        await this.runtime.stop(id).catch((error) => {
          this.options.logger.error({ workspaceId: id, err: error }, "Failed to stop workspace during shutdown")
        })
      } else {
        this.options.logger.debug({ workspaceId: id }, "Workspace already stopped")
      }
    }
    this.workspaces.clear()
    this.options.logger.info("All workspaces cleared")
  }

  private requireWorkspace(id: string): WorkspaceRecord {
    const workspace = this.workspaces.get(id)
    if (!workspace) {
      throw new Error("Workspace not found")
    }
    return workspace
  }

  private resolveBinaryPath(identifier: string): string {
    if (!identifier) {
      return identifier
    }

    const looksLikePath = identifier.includes("/") || identifier.includes("\\") || identifier.startsWith(".")
    if (path.isAbsolute(identifier) || looksLikePath) {
      return identifier
    }

    const locator = process.platform === "win32" ? "where" : "which"

    try {
      const result = spawnSync(locator, [identifier], { encoding: "utf8" })
      if (result.status === 0 && result.stdout) {
        const resolved = result.stdout
          .split(/\r?\n/)
          .map((line) => line.trim())
          .find((line) => line.length > 0)

        if (resolved) {
          this.options.logger.debug({ identifier, resolved }, "Resolved binary path from system PATH")
          return resolved
        }
      } else if (result.error) {
        this.options.logger.warn({ identifier, err: result.error }, "Failed to resolve binary path via locator command")
      }
    } catch (error) {
      this.options.logger.warn({ identifier, err: error }, "Failed to resolve binary path from system PATH")
    }

    return identifier
  }

  private detectBinaryVersion(resolvedPath: string): string | undefined {
    if (!resolvedPath) {
      return undefined
    }

    try {
      const result = spawnSync(resolvedPath, ["--version"], { encoding: "utf8" })
      if (result.status === 0 && result.stdout) {
        const line = result.stdout.split(/\r?\n/).find((entry) => entry.trim().length > 0)
        if (line) {
          const normalized = line.trim()
          const versionMatch = normalized.match(/([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/)
          if (versionMatch) {
            const version = versionMatch[1]
            this.options.logger.debug({ binary: resolvedPath, version }, "Detected binary version")
            return version
          }
          this.options.logger.debug({ binary: resolvedPath, reported: normalized }, "Binary reported version string")
          return normalized
        }
      } else if (result.error) {
        this.options.logger.warn({ binary: resolvedPath, err: result.error }, "Failed to read binary version")
      }
    } catch (error) {
      this.options.logger.warn({ binary: resolvedPath, err: error }, "Failed to detect binary version")
    }

    return undefined
  }

  private async waitForWorkspaceReadiness(params: {
    workspaceId: string
    port: number
    exitPromise: Promise<ProcessExitInfo>
    getLastOutput: () => string
  }) {

    await Promise.race([
      this.waitForPortAvailability(params.port),
      params.exitPromise.then((info) => {
        throw this.buildStartupError(
          params.workspaceId,
          "exited before becoming ready",
          info,
          params.getLastOutput(),
        )
      }),
    ])

    await this.waitForInstanceHealth(params)

    await Promise.race([
      this.delay(STARTUP_STABILITY_DELAY_MS),
      params.exitPromise.then((info) => {
        throw this.buildStartupError(
          params.workspaceId,
          "exited shortly after start",
          info,
          params.getLastOutput(),
        )
      }),
    ])
  }

  private async waitForInstanceHealth(params: {
    workspaceId: string
    port: number
    exitPromise: Promise<ProcessExitInfo>
    getLastOutput: () => string
  }) {
    const probeResult = await Promise.race([
      this.probeInstance(params.workspaceId, params.port),
      params.exitPromise.then((info) => {
        throw this.buildStartupError(
          params.workspaceId,
          "exited during health checks",
          info,
          params.getLastOutput(),
        )
      }),
    ])

    if (probeResult.ok) {
      return
    }

    const latestOutput = params.getLastOutput().trim()
    const outputDetails = latestOutput ? ` Last output: ${latestOutput}` : ""
    const reason = probeResult.reason ?? "Health check failed"
    throw new Error(`Workspace ${params.workspaceId} failed health check: ${reason}.${outputDetails}`)
  }

  private async probeInstance(workspaceId: string, port: number): Promise<{ ok: boolean; reason?: string }> {
    // Try multiple possible health check endpoints
    const endpoints = [
      `/project/current`,
      `/health`,
      `/status`,
      `/`,
      `/api/health`
    ]

    this.options.logger.info({ workspaceId, port, endpoints }, "Starting health check probe")

    for (const endpoint of endpoints) {
      const url = `http://127.0.0.1:${port}${endpoint}`

      try {
        const controller = new AbortController()
        const timeoutId = setTimeout(() => controller.abort(), 10000) // 10 second timeout

        const response = await fetch(url, {
          method: 'GET',
          headers: {
            'User-Agent': 'NomadArch-HealthCheck/1.0'
          },
          signal: controller.signal
        })

        clearTimeout(timeoutId)

        this.options.logger.debug({ workspaceId, status: response.status, url, endpoint }, "Health probe response received")

        if (response.ok) {
          this.options.logger.info({ workspaceId, port, endpoint }, "Health check passed")
          return { ok: true }
        } else {
          this.options.logger.debug({ workspaceId, status: response.status, url, endpoint }, "Health probe endpoint returned error")
        }
      } catch (error) {
        this.options.logger.debug({ workspaceId, port, err: error, url, endpoint }, "Health probe endpoint failed")
        // Continue to next endpoint
      }
    }

    // All endpoints failed
    const reason = `OpenCode server started but is not responding to any known health endpoints (/project/current, /health, /status, /, /api/health)`
    this.options.logger.error({ workspaceId, port }, "All health check endpoints failed")
    return { ok: false, reason }
  }

  private buildStartupError(
    workspaceId: string,
    phase: string,
    exitInfo: ProcessExitInfo,
    lastOutput: string,
  ): Error {
    const exitDetails = this.describeExit(exitInfo)
    const trimmedOutput = lastOutput.trim()
    const outputDetails = trimmedOutput ? ` Last output: ${trimmedOutput}` : ""
    return new Error(`Workspace ${workspaceId} ${phase} (${exitDetails}).${outputDetails}`)
  }

  private waitForPortAvailability(port: number, timeoutMs = 5000): Promise<void> {
    this.options.logger.info({ port, timeoutMs }, "Waiting for port availability - STARTING")

    return new Promise((resolve, reject) => {
      const deadline = Date.now() + timeoutMs
      let settled = false
      let retryTimer: NodeJS.Timeout | null = null
      let attemptCount = 0

      const cleanup = () => {
        settled = true
        if (retryTimer) {
          clearTimeout(retryTimer)
          retryTimer = null
        }
      }

      const tryConnect = () => {
        if (settled) {
          return
        }

        attemptCount++
        this.options.logger.debug({ port, attempt: attemptCount, timeRemaining: Math.max(0, deadline - Date.now()) }, "Attempting to connect to workspace port")

        const socket = connect({ port, host: "127.0.0.1" }, () => {
          this.options.logger.info({ port, attempt: attemptCount }, "Port is available - SUCCESS")
          cleanup()
          socket.end()
          resolve()
        })
        socket.once("error", (error) => {
          this.options.logger.debug({ port, attempt: attemptCount, err: error instanceof Error ? error.message : String(error) }, "Port connection failed - retrying")
          socket.destroy()
          if (settled) {
            return
          }
          if (Date.now() >= deadline) {
            this.options.logger.error({ port, attempt: attemptCount, timeoutMs }, "Port did not become available - TIMEOUT")
            cleanup()
            reject(new Error(`Workspace port ${port} did not become ready within ${timeoutMs}ms`))
          } else {
            retryTimer = setTimeout(() => {
              retryTimer = null
              tryConnect()
            }, 100)
          }
        })
      }

      tryConnect()
    })
  }

  private delay(durationMs: number): Promise<void> {
    if (durationMs <= 0) {
      return Promise.resolve()
    }
    return new Promise((resolve) => setTimeout(resolve, durationMs))
  }

  private describeExit(info: ProcessExitInfo): string {
    if (info.signal) {
      return `signal ${info.signal}`
    }
    if (info.code !== null) {
      return `code ${info.code}`
    }
    return "unknown reason"
  }

  private handleProcessExit(workspaceId: string, info: { code: number | null; requested: boolean }) {
    const workspace = this.workspaces.get(workspaceId)
    if (!workspace) return

    this.options.logger.info({ workspaceId, ...info }, "Workspace process exited")

    workspace.pid = undefined
    workspace.port = undefined
    workspace.updatedAt = new Date().toISOString()

    if (info.requested || info.code === 0) {
      workspace.status = "stopped"
      workspace.error = undefined
      this.options.eventBus.publish({ type: "workspace.stopped", workspaceId })
    } else {
      workspace.status = "error"
      workspace.error = `Process exited with code ${info.code}`
      this.options.eventBus.publish({ type: "workspace.error", workspace })
    }
  }
}
