import type { Manifest } from '@types'
import { loadGitignore } from '@gitignore'
import { computeHash, generateManifest } from '%sync'
import color from 'kleur'
import { diffLines } from 'diff'
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, unlinkSync, watch, writeFileSync } from 'fs'
import { dirname, join } from 'path'
import { del, download, get, getManifest, handleError, makeUrl, post, put } from '../http'
import { confirm, prompt } from '../prompts'
import { getAppName, isApp, resolveAppName } from '../name'

interface ManifestDiff {
  changed: string[]
  localOnly: string[]
  remoteOnly: string[]
  localManifest: Manifest
  remoteManifest: Manifest | null
}

export async function getApp(name: string) {
  console.log(`Fetching ${color.bold(name)} from server...`)

  const manifest: Manifest | undefined = await get(`/api/sync/apps/${name}/manifest`)
  if (!manifest) {
    console.error(`App not found: ${name}`)
    return
  }

  const appPath = join(process.cwd(), name)
  if (existsSync(appPath)) {
    console.error(`Directory already exists: ${name}`)
    return
  }

  mkdirSync(appPath, { recursive: true })

  const files = Object.keys(manifest.files)
  console.log(`Downloading ${files.length} files...`)

  for (const file of files) {
    const content = await download(`/api/sync/apps/${name}/files/${file}`)
    if (!content) {
      console.error(`Failed to download: ${file}`)
      continue
    }

    const fullPath = join(appPath, file)
    const dir = dirname(fullPath)

    if (!existsSync(dir)) {
      mkdirSync(dir, { recursive: true })
    }

    writeFileSync(fullPath, content)
  }

  console.log(color.green(`✓ Downloaded ${name}`))
}

export async function pushApp() {
  if (!isApp()) {
    console.error('Not a toes app. Use `toes get <app>` to grab one.')
    return
  }

  const appName = getAppName()

  const localManifest = generateManifest(process.cwd(), appName)
  const result = await getManifest(appName)

  if (result === null) {
    // Connection error - already printed
    return
  }

  if (!result.exists) {
    const ok = await confirm(`App ${color.bold(appName)} doesn't exist on server. Create it?`)
    if (!ok) return
  }

  const localFiles = new Set(Object.keys(localManifest.files))
  const remoteFiles = new Set(Object.keys(result.manifest?.files ?? {}))

  // Files to upload (new or changed)
  const toUpload: string[] = []
  for (const file of localFiles) {
    const local = localManifest.files[file]!
    const remote = result.manifest?.files[file]
    if (!remote || local.hash !== remote.hash) {
      toUpload.push(file)
    }
  }

  // Note: We don't delete files in versioned deployments - new version is separate directory

  if (toUpload.length === 0) {
    console.log('Already up to date')
    return
  }

  console.log(`Pushing ${color.bold(appName)} to server...`)

  // 1. Request new deployment version
  type DeployResponse = { ok: boolean, version: string }
  const deployRes = await post<DeployResponse>(`/api/sync/apps/${appName}/deploy`)
  if (!deployRes?.ok) {
    console.error('Failed to start deployment')
    return
  }

  const version = deployRes.version
  console.log(`Deploying version ${color.bold(version)}...`)

  // 2. Upload changed files to new version
  if (toUpload.length > 0) {
    console.log(`Uploading ${toUpload.length} files...`)
    let failedUploads = 0

    for (const file of toUpload) {
      const content = readFileSync(join(process.cwd(), file))
      const success = await put(`/api/sync/apps/${appName}/files/${file}?version=${version}`, content)
      if (success) {
        console.log(`  ${color.green('↑')} ${file}`)
      } else {
        console.log(`  ${color.red('✗')} ${file}`)
        failedUploads++
      }
    }

    if (failedUploads > 0) {
      console.error(`Failed to upload ${failedUploads} file(s). Deployment aborted.`)
      console.error(`Incomplete version ${version} left on server (not activated).`)
      return
    }
  }

  // 3. Activate new version (updates symlink and restarts app)
  type ActivateResponse = { ok: boolean }
  const activateRes = await post<ActivateResponse>(`/api/sync/apps/${appName}/activate?version=${version}`)
  if (!activateRes?.ok) {
    console.error('Failed to activate new version')
    return
  }

  console.log(color.green(`✓ Deployed and activated version ${version}`))
}

export async function pullApp(options: { force?: boolean } = {}) {
  if (!isApp()) {
    console.error('Not a toes app. Use `toes get <app>` to grab one.')
    return
  }

  const appName = getAppName()
  const diff = await getManifestDiff(appName)

  if (diff === null) {
    return
  }

  const { changed, localOnly, remoteOnly, remoteManifest } = diff

  if (!remoteManifest) {
    console.error('App not found on server')
    return
  }

  // Check for local changes that would be overwritten
  const wouldOverwrite = changed.length > 0 || localOnly.length > 0
  if (wouldOverwrite && !options.force) {
    console.error('Cannot pull: you have local changes that would be overwritten')
    console.error('  Use `toes status` and `toes diff` to see differences')
    console.error('  Use `toes pull --force` to overwrite local changes')
    return
  }

  // Files to download: changed + remoteOnly
  const toDownload = [...changed, ...remoteOnly]

  // Files to delete: localOnly
  const toDelete = localOnly

  if (toDownload.length === 0 && toDelete.length === 0) {
    console.log('Already up to date')
    return
  }

  console.log(`Pulling ${color.bold(appName)} from server...`)

  if (toDownload.length > 0) {
    console.log(`Downloading ${toDownload.length} files...`)
    for (const file of toDownload) {
      const content = await download(`/api/sync/apps/${appName}/files/${file}`)
      if (!content) {
        console.log(`  ${color.red('✗')} ${file}`)
        continue
      }

      const fullPath = join(process.cwd(), file)
      const dir = dirname(fullPath)

      if (!existsSync(dir)) {
        mkdirSync(dir, { recursive: true })
      }

      writeFileSync(fullPath, content)
      console.log(`  ${color.green('↓')} ${file}`)
    }
  }

  if (toDelete.length > 0) {
    console.log(`Deleting ${toDelete.length} local files...`)
    for (const file of toDelete) {
      const fullPath = join(process.cwd(), file)
      unlinkSync(fullPath)
      console.log(`  ${color.red('✗')} ${file}`)
    }
  }

  console.log(color.green('✓ Pull complete'))
}

export async function diffApp() {
  if (!isApp()) {
    console.error('Not a toes app. Use `toes get <app>` to grab one.')
    return
  }

  const appName = getAppName()
  const diff = await getManifestDiff(appName)

  if (diff === null) {
    return
  }

  const { changed, localOnly, remoteOnly } = diff

  if (changed.length === 0 && localOnly.length === 0 && remoteOnly.length === 0) {
    // console.log(color.green('✓ No differences'))
    return
  }

  // Fetch all changed files in parallel
  const remoteContents = await Promise.all(
    changed.map(file => download(`/api/sync/apps/${appName}/files/${file}`))
  )

  // Show diffs for changed files
  for (let i = 0; i < changed.length; i++) {
    const file = changed[i]!
    const remoteContent = remoteContents[i]
    const localContent = readFileSync(join(process.cwd(), file), 'utf-8')

    if (!remoteContent) {
      console.log(color.red(`Failed to fetch remote version of ${file}`))
      continue
    }

    const remoteText = new TextDecoder().decode(remoteContent)

    console.log(color.bold(`\n${file}`))
    console.log(color.gray('─'.repeat(60)))
    showDiff(remoteText, localContent)
  }

  // Show local-only files
  for (const file of localOnly) {
    console.log(color.green('\nNew file (local only)'))
    console.log(color.bold(`${file}`))
    console.log(color.gray('─'.repeat(60)))
    const content = readFileSync(join(process.cwd(), file), 'utf-8')
    const lines = content.split('\n')
    for (let i = 0; i < Math.min(lines.length, 10); i++) {
      console.log(color.green(`+ ${lines[i]}`))
    }
    if (lines.length > 10) {
      console.log(color.gray(`... ${lines.length - 10} more lines`))
    }
  }

  // Fetch all remote-only files in parallel
  const remoteOnlyContents = await Promise.all(
    remoteOnly.map(file => download(`/api/sync/apps/${appName}/files/${file}`))
  )

  // Show remote-only files
  for (let i = 0; i < remoteOnly.length; i++) {
    const file = remoteOnly[i]!
    const content = remoteOnlyContents[i]

    console.log(color.bold(`\n${file}`))
    console.log(color.gray('─'.repeat(60)))
    console.log(color.red('Remote only (would be deleted on push)'))
    if (content) {
      const text = new TextDecoder().decode(content)
      const lines = text.split('\n')
      for (let i = 0; i < Math.min(lines.length, 10); i++) {
        console.log(color.red(`- ${lines[i]}`))
      }
      if (lines.length > 10) {
        console.log(color.gray(`... ${lines.length - 10} more lines`))
      }
    }
  }

  console.log()
}


export async function statusApp() {
  if (!isApp()) {
    console.error('Not a toes app. Use `toes get <app>` to grab one.')
    return
  }

  const appName = getAppName()
  const diff = await getManifestDiff(appName)

  if (diff === null) {
    return
  }

  const { changed, localOnly, remoteOnly, localManifest, remoteManifest } = diff

  // toPush = changed + localOnly (new or modified locally)
  const toPush = [...changed, ...localOnly]

  // Local changes block pull
  const hasLocalChanges = toPush.length > 0

  // Display status
  // console.log(`Status for ${color.bold(appName)}:\n`)

  if (!remoteManifest) {
    console.log(color.yellow('App does not exist on server'))
    const localFileCount = Object.keys(localManifest.files).length
    console.log(`\nWould create new app with ${localFileCount} files on push\n`)
    return
  }

  // Push status
  if (toPush.length > 0 || remoteOnly.length > 0) {
    console.log(color.bold('Changes to push:'))
    for (const file of toPush) {
      console.log(`  ${color.green('↑')} ${file}`)
    }
    for (const file of remoteOnly) {
      console.log(`  ${color.red('✗')} ${file}`)
    }
    console.log()
  }

  // Pull status (only show if no local changes blocking)
  if (!hasLocalChanges && remoteOnly.length > 0) {
    console.log(color.bold('Changes to pull:'))
    for (const file of remoteOnly) {
      console.log(`  ${color.green('↓')} ${file}`)
    }
    console.log()
  }

  // Summary
  if (toPush.length === 0 && remoteOnly.length === 0) {
    console.log(color.green('✓ In sync with server'))
  }
}

export async function syncApp() {
  if (!isApp()) {
    console.error('Not a toes app. Use `toes get <app>` to grab one.')
    return
  }

  const appName = getAppName()
  const gitignore = loadGitignore(process.cwd())
  const localHashes = new Map<string, string>()

  // Initialize local hashes
  const manifest = generateManifest(process.cwd(), appName)
  for (const [path, info] of Object.entries(manifest.files)) {
    localHashes.set(path, info.hash)
  }

  console.log(`Syncing ${color.bold(appName)}...`)

  // Watch local files
  const watcher = watch(process.cwd(), { recursive: true }, async (_event, filename) => {
    if (!filename || gitignore.shouldExclude(filename)) return

    const fullPath = join(process.cwd(), filename)

    if (existsSync(fullPath) && statSync(fullPath).isFile()) {
      const content = readFileSync(fullPath)
      const hash = computeHash(content)
      if (localHashes.get(filename) !== hash) {
        localHashes.set(filename, hash)
        await put(`/api/sync/apps/${appName}/files/${filename}`, content)
        console.log(`  ${color.green('↑')} ${filename}`)
      }
    } else if (!existsSync(fullPath)) {
      localHashes.delete(filename)
      await del(`/api/sync/apps/${appName}/files/${filename}`)
      console.log(`  ${color.red('✗')} ${filename}`)
    }
  })

  // Connect to SSE for remote changes
  const url = makeUrl(`/api/sync/apps/${appName}/watch`)
  let res: Response
  try {
    res = await fetch(url)
    if (!res.ok) {
      console.error(`Failed to connect to server: ${res.status} ${res.statusText}`)
      watcher.close()
      return
    }
  } catch (error) {
    handleError(error)
    watcher.close()
    return
  }

  if (!res.body) {
    console.error('No response body from server')
    watcher.close()
    return
  }

  console.log(`  Connected to server, watching for changes...`)

  const reader = res.body.getReader()
  const decoder = new TextDecoder()
  let buffer = ''

  try {
    while (true) {
      const { done, value } = await reader.read()
      if (done) break

      buffer += decoder.decode(value, { stream: true })
      const lines = buffer.split('\n\n')
      buffer = lines.pop() ?? ''

      for (const line of lines) {
        if (!line.startsWith('data: ')) continue
        const event = JSON.parse(line.slice(6)) as { type: 'change' | 'delete', path: string, hash?: string }

        if (event.type === 'change') {
          // Skip if we already have this version (handles echo from our own changes)
          if (localHashes.get(event.path) === event.hash) continue
          const content = await download(`/api/sync/apps/${appName}/files/${event.path}`)
          if (content) {
            const fullPath = join(process.cwd(), event.path)
            mkdirSync(dirname(fullPath), { recursive: true })
            writeFileSync(fullPath, content)
            localHashes.set(event.path, event.hash!)
            console.log(`  ${color.green('↓')} ${event.path}`)
          }
        } else if (event.type === 'delete') {
          const fullPath = join(process.cwd(), event.path)
          if (existsSync(fullPath)) {
            unlinkSync(fullPath)
            localHashes.delete(event.path)
            console.log(`  ${color.red('✗')} ${event.path} (remote)`)
          }
        }
      }
    }
  } finally {
    watcher.close()
  }
}

export async function versionsApp(name?: string) {
  const appName = resolveAppName(name)
  if (!appName) return

  const result = await getVersions(appName)

  if (!result) return

  if (result.versions.length === 0) {
    console.log(`No versions found for ${color.bold(appName)}`)
    return
  }

  console.log(`Versions for ${color.bold(appName)}:\n`)

  for (const version of result.versions) {
    const isCurrent = version === result.current
    const date = formatVersion(version)

    if (isCurrent) {
      console.log(`  ${color.green('→')} ${color.bold(version)}  ${color.gray(date)}  ${color.green('(current)')}`)
    } else {
      console.log(`    ${version}  ${color.gray(date)}`)
    }
  }

  console.log()
}

export async function rollbackApp(name?: string, version?: string) {
  const appName = resolveAppName(name)
  if (!appName) return

  // Get available versions
  const result = await getVersions(appName)

  if (!result) return

  if (result.versions.length === 0) {
    console.error(`No versions found for ${color.bold(appName)}`)
    return
  }

  // Filter out current version for rollback candidates
  const candidates = result.versions.filter(v => v !== result.current)

  if (candidates.length === 0) {
    console.error('No previous versions to rollback to')
    return
  }

  let targetVersion: string | undefined = version

  if (!targetVersion) {
    // Show available versions and prompt
    console.log(`Available versions for ${color.bold(appName)}:\n`)

    for (let i = 0; i < candidates.length; i++) {
      const v = candidates[i]!
      const date = formatVersion(v)
      console.log(`  ${color.cyan(String(i + 1))}. ${v}  ${color.gray(date)}`)
    }

    console.log()
    const answer = await prompt('Enter version number or name: ')

    // Check if it's a number (index) or version name
    const index = parseInt(answer, 10)
    if (!isNaN(index) && index >= 1 && index <= candidates.length) {
      targetVersion = candidates[index - 1]!
    } else if (candidates.includes(answer)) {
      targetVersion = answer
    } else {
      console.error('Invalid selection')
      return
    }
  }

  // Validate version exists (handles both user-provided and selected versions)
  if (!targetVersion || !result.versions.includes(targetVersion)) {
    console.error(`Version ${color.bold(targetVersion ?? 'unknown')} not found`)
    console.error(`Available versions: ${result.versions.join(', ')}`)
    return
  }

  if (targetVersion === result.current) {
    console.log(`Version ${color.bold(targetVersion)} is already active`)
    return
  }

  const ok = await confirm(`Rollback ${color.bold(appName)} to version ${color.bold(targetVersion)}?`)
  if (!ok) return

  console.log(`Rolling back to ${color.bold(targetVersion)}...`)

  type ActivateResponse = { ok: boolean }
  const activateRes = await post<ActivateResponse>(`/api/sync/apps/${appName}/activate?version=${targetVersion}`)

  if (!activateRes?.ok) {
    console.error('Failed to activate version')
    return
  }

  console.log(color.green(`✓ Rolled back to version ${targetVersion}`))
}

const STASH_BASE = '/tmp/toes-stash'

export async function stashApp() {
  if (!isApp()) {
    console.error('Not a toes app. Use `toes get <app>` to grab one.')
    return
  }

  const appName = getAppName()
  const diff = await getManifestDiff(appName)

  if (diff === null) {
    return
  }

  const { changed, localOnly } = diff
  const toStash = [...changed, ...localOnly]

  if (toStash.length === 0) {
    console.log('No local changes to stash')
    return
  }

  const stashDir = join(STASH_BASE, appName)

  // Check if stash already exists
  if (existsSync(stashDir)) {
    console.error('Stash already exists. Use `toes stash-pop` first.')
    return
  }

  mkdirSync(stashDir, { recursive: true })

  // Save stash metadata
  const metadata = {
    app: appName,
    timestamp: new Date().toISOString(),
    files: toStash,
    changed,
    localOnly,
  }
  writeFileSync(join(stashDir, 'metadata.json'), JSON.stringify(metadata, null, 2))

  // Copy files to stash
  for (const file of toStash) {
    const srcPath = join(process.cwd(), file)
    const destPath = join(stashDir, 'files', file)
    const destDir = dirname(destPath)

    if (!existsSync(destDir)) {
      mkdirSync(destDir, { recursive: true })
    }

    const content = readFileSync(srcPath)
    writeFileSync(destPath, content)
    console.log(`  ${color.yellow('→')} ${file}`)
  }

  // Restore changed files from server
  if (changed.length > 0) {
    console.log(`\nRestoring ${changed.length} changed files from server...`)
    for (const file of changed) {
      const content = await download(`/api/sync/apps/${appName}/files/${file}`)
      if (content) {
        writeFileSync(join(process.cwd(), file), content)
      }
    }
  }

  // Delete local-only files
  if (localOnly.length > 0) {
    console.log(`Removing ${localOnly.length} local-only files...`)
    for (const file of localOnly) {
      unlinkSync(join(process.cwd(), file))
    }
  }

  console.log(color.green(`\n✓ Stashed ${toStash.length} file(s)`))
}

export async function stashListApp() {
  if (!existsSync(STASH_BASE)) {
    console.log('No stashes')
    return
  }

  const entries = readdirSync(STASH_BASE, { withFileTypes: true })
  const stashes = entries.filter(e => e.isDirectory())

  if (stashes.length === 0) {
    console.log('No stashes')
    return
  }

  console.log('Stashes:\n')
  for (const stash of stashes) {
    const metadataPath = join(STASH_BASE, stash.name, 'metadata.json')
    if (existsSync(metadataPath)) {
      const metadata = JSON.parse(readFileSync(metadataPath, 'utf-8')) as {
        timestamp: string
        files: string[]
      }
      const date = new Date(metadata.timestamp).toLocaleString()
      console.log(`  ${color.bold(stash.name)}  ${color.gray(date)}  ${color.gray(`(${metadata.files.length} files)`)}`)
    } else {
      console.log(`  ${color.bold(stash.name)}  ${color.gray('(invalid)')}`)
    }
  }
  console.log()
}

export async function stashPopApp() {
  if (!isApp()) {
    console.error('Not a toes app. Use `toes get <app>` to grab one.')
    return
  }

  const appName = getAppName()
  const stashDir = join(STASH_BASE, appName)

  if (!existsSync(stashDir)) {
    console.error(`No stash found for ${color.bold(appName)}`)
    return
  }

  // Read metadata
  const metadataPath = join(stashDir, 'metadata.json')
  if (!existsSync(metadataPath)) {
    console.error('Invalid stash: missing metadata')
    return
  }

  const metadata = JSON.parse(readFileSync(metadataPath, 'utf-8')) as {
    app: string
    timestamp: string
    files: string[]
    changed: string[]
    localOnly: string[]
  }

  console.log(`Restoring stash from ${new Date(metadata.timestamp).toLocaleString()}...\n`)

  // Restore files from stash
  for (const file of metadata.files) {
    const srcPath = join(stashDir, 'files', file)
    const destPath = join(process.cwd(), file)
    const destDir = dirname(destPath)

    if (!existsSync(srcPath)) {
      console.log(`  ${color.red('✗')} ${file} (missing from stash)`)
      continue
    }

    if (!existsSync(destDir)) {
      mkdirSync(destDir, { recursive: true })
    }

    const content = readFileSync(srcPath)
    writeFileSync(destPath, content)
    console.log(`  ${color.green('←')} ${file}`)
  }

  // Remove stash directory
  rmSync(stashDir, { recursive: true })

  console.log(color.green(`\n✓ Restored ${metadata.files.length} file(s)`))
}

export async function cleanApp(options: { force?: boolean, dryRun?: boolean } = {}) {
  if (!isApp()) {
    console.error('Not a toes app. Use `toes get <app>` to grab one.')
    return
  }

  const appName = getAppName()
  const diff = await getManifestDiff(appName)

  if (diff === null) {
    return
  }

  const { localOnly } = diff

  if (localOnly.length === 0) {
    console.log('Nothing to clean')
    return
  }

  if (options.dryRun) {
    console.log('Would remove:')
    for (const file of localOnly) {
      console.log(`  ${color.red('✗')} ${file}`)
    }
    return
  }

  if (!options.force) {
    console.log('Files not on server:')
    for (const file of localOnly) {
      console.log(`  ${color.red('✗')} ${file}`)
    }
    console.log()
    const ok = await confirm(`Remove ${localOnly.length} file(s)?`)
    if (!ok) return
  }

  for (const file of localOnly) {
    const fullPath = join(process.cwd(), file)
    unlinkSync(fullPath)
    console.log(`  ${color.red('✗')} ${file}`)
  }

  console.log(color.green(`✓ Removed ${localOnly.length} file(s)`))
}

interface VersionsResponse {
  current: string | null
  versions: string[]
}

async function getVersions(appName: string): Promise<VersionsResponse | null> {
  try {
    const res = await fetch(makeUrl(`/api/sync/apps/${appName}/versions`))
    if (res.status === 404) {
      console.error(`App not found: ${appName}`)
      return null
    }
    if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
    return await res.json()
  } catch (error) {
    handleError(error)
    return null
  }
}

function formatVersion(version: string): string {
  // Parse YYYYMMDD-HHMMSS format
  const match = version.match(/^(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})$/)
  if (!match) return ''

  const date = new Date(
    parseInt(match[1]!, 10),
    parseInt(match[2]!, 10) - 1,
    parseInt(match[3]!, 10),
    parseInt(match[4]!, 10),
    parseInt(match[5]!, 10),
    parseInt(match[6]!, 10)
  )

  return date.toLocaleString()
}

async function getManifestDiff(appName: string): Promise<ManifestDiff | null> {
  const localManifest = generateManifest(process.cwd(), appName)
  const result = await getManifest(appName)

  if (result === null) {
    // Connection error - already printed
    return null
  }

  const localFiles = new Set(Object.keys(localManifest.files))
  const remoteFiles = new Set(Object.keys(result.manifest?.files ?? {}))

  // Files that differ
  const changed: string[] = []
  for (const file of localFiles) {
    if (remoteFiles.has(file)) {
      const local = localManifest.files[file]!
      const remote = result.manifest!.files[file]!
      if (local.hash !== remote.hash) {
        changed.push(file)
      }
    }
  }

  // Files only in local
  const localOnly: string[] = []
  for (const file of localFiles) {
    if (!remoteFiles.has(file)) {
      localOnly.push(file)
    }
  }

  // Files only in remote
  const remoteOnly: string[] = []
  for (const file of remoteFiles) {
    if (!localFiles.has(file)) {
      remoteOnly.push(file)
    }
  }

  return {
    changed,
    localOnly,
    remoteOnly,
    localManifest,
    remoteManifest: result.manifest ?? null,
  }
}

function showDiff(remote: string, local: string) {
  const changes = diffLines(remote, local)
  let lineCount = 0
  const maxLines = 50
  const contextLines = 3

  for (let i = 0; i < changes.length; i++) {
    const part = changes[i]!
    const lines = part.value.replace(/\n$/, '').split('\n')

    if (part.added) {
      for (const line of lines) {
        if (lineCount >= maxLines) {
          console.log(color.gray('... diff truncated'))
          return
        }
        console.log(color.green(`+ ${line}`))
        lineCount++
      }
    } else if (part.removed) {
      for (const line of lines) {
        if (lineCount >= maxLines) {
          console.log(color.gray('... diff truncated'))
          return
        }
        console.log(color.red(`- ${line}`))
        lineCount++
      }
    } else {
      // Context: show lines near changes
      const prevHasChange = i > 0 && (changes[i - 1]!.added || changes[i - 1]!.removed)
      const nextHasChange = i < changes.length - 1 && (changes[i + 1]!.added || changes[i + 1]!.removed)

      if (prevHasChange && nextHasChange && lines.length <= contextLines * 2) {
        // Small gap between changes - show all
        for (const line of lines) {
          if (lineCount >= maxLines) {
            console.log(color.gray('... diff truncated'))
            return
          }
          console.log(color.gray(`  ${line}`))
          lineCount++
        }
      } else {
        // Show context before next change
        if (nextHasChange) {
          const start = Math.max(0, lines.length - contextLines)
          if (start > 0) {
            console.log(color.gray('  ...'))
            lineCount++
          }
          for (let j = start; j < lines.length; j++) {
            if (lineCount >= maxLines) {
              console.log(color.gray('... diff truncated'))
              return
            }
            console.log(color.gray(`  ${lines[j]}`))
            lineCount++
          }
        }
        // Show context after previous change
        if (prevHasChange) {
          const end = Math.min(lines.length, contextLines)
          for (let j = 0; j < end; j++) {
            if (lineCount >= maxLines) {
              console.log(color.gray('... diff truncated'))
              return
            }
            console.log(color.gray(`  ${lines[j]}`))
            lineCount++
          }
          if (end < lines.length && !nextHasChange) {
            console.log(color.gray('  ...'))
          }
        }
      }
    }
  }
}
