import crypto from 'node:crypto'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { dynamicImport, dynamicRequire } from '../utils'
import { TemplateError } from '../errors'
import { BASE_STEP_NAME, FINALIZE_STEP_NAME } from './consts'
import type { Readable } from 'node:stream'
import type { Path } from 'glob'
import type { BuildOptions } from './types'

/**
 * Validate that a source path for copy operations is a relative path that stays
 * within the context directory. This prevents path traversal attacks and ensures
 * files are copied from within the expected directory.
 *
 * @param src The source path to validate
 * @param stackTrace Optional stack trace for error reporting
 * @throws TemplateError if the path is absolute or escapes the context directory
 *
 * Invalid paths:
 * - Absolute paths: /absolute/path, C:\Windows\path
 * - Parent directory escapes: ../foo, foo/../../bar, ./foo/../../../bar
 *
 * Valid paths:
 * - Simple relative: foo, foo/bar
 * - Current directory prefix: ./foo, ./foo/bar
 * - Internal parent refs that don't escape: foo/../bar (stays within context)
 */
export function validateRelativePath(
  src: string,
  stackTrace: string | undefined
): void {
  // Check for absolute paths using Node's cross-platform implementation
  if (path.isAbsolute(src)) {
    const error = new TemplateError(
      `Invalid source path "${src}": absolute paths are not allowed. Use a relative path within the context directory.`,
      stackTrace
    )
    throw error
  }

  // Normalize the path and check if it escapes the context directory
  const normalized = path.normalize(src)

  // After normalization, a path that escapes would be '..' or start with '../'
  // We check for '..' followed by path separator to avoid false positives on filenames like '..myconfig'
  // Examples:
  // - '../foo' -> '../foo' (escapes)
  // - 'foo/../../bar' -> '../bar' (escapes)
  // - './foo/../../../bar' -> '../../bar' (escapes)
  // - 'foo/../bar' -> 'bar' (doesn't escape)
  // - './foo/bar' -> 'foo/bar' (doesn't escape)
  // - '..myconfig' -> '..myconfig' (valid filename, doesn't escape)
  const escapes = normalized === '..' || normalized.startsWith('..' + path.sep)

  if (escapes) {
    const error = new TemplateError(
      `Invalid source path "${src}": path escapes the context directory. The path must stay within the context directory.`,
      stackTrace
    )
    throw error
  }
}

/**
 * Normalize build arguments from different overload signatures.
 * Handles string name or legacy options object with alias.
 *
 * @param nameOrOptions Name or legacy options with alias
 * @param options Optional build options (when first arg is name)
 * @returns Object with normalized name, tags, and build options
 * @throws TemplateError if no template name is provided
 */
export function normalizeBuildArguments(
  nameOrOptions: string | BuildOptions,
  options?: Omit<BuildOptions, 'alias'>
): {
  name: string
  buildOptions: Omit<BuildOptions, 'alias'>
} {
  let name: string
  let buildOptions: Omit<BuildOptions, 'alias'>

  if (typeof nameOrOptions === 'string') {
    name = nameOrOptions
    buildOptions = options ?? {}
  } else {
    // Legacy: options object with alias
    const { alias, ...restOpts } = nameOrOptions
    name = alias
    buildOptions = restOpts
  }

  if (!name || name.length === 0) {
    throw new TemplateError('Name must be provided')
  }

  return { name, buildOptions }
}

/**
 * Read and parse a .dockerignore file.
 *
 * @param contextPath Directory path containing the .dockerignore file
 * @returns Array of ignore patterns (empty lines and comments are filtered out)
 */
export function readDockerignore(contextPath: string): string[] {
  const dockerignorePath = path.join(contextPath, '.dockerignore')
  if (!fs.existsSync(dockerignorePath)) {
    return []
  }

  const content = fs.readFileSync(dockerignorePath, 'utf-8')
  return content
    .split('\n')
    .map((line) => line.trim())
    .filter((line) => line && !line.startsWith('#'))
}

/**
 * Normalize path separators to forward slashes for glob patterns (glob expects / even on Windows)
 * @param path - The path to normalize
 * @returns The normalized path
 */
function normalizePath(path: string): string {
  return path.replace(/\\/g, '/')
}

/**
 * Get all files for a given path and ignore patterns.
 *
 * @param src Path to the source directory
 * @param contextPath Base directory for resolving relative paths
 * @param ignorePatterns Ignore patterns
 * @returns Array of files
 */
export async function getAllFilesInPath(
  src: string,
  contextPath: string,
  ignorePatterns: string[],
  includeDirectories: boolean = true
) {
  const { glob } = await dynamicImport<typeof import('glob')>('glob')
  const files = new Map<string, Path>()

  const globFiles = await glob(src, {
    ignore: ignorePatterns,
    withFileTypes: true,
    dot: true,
    // this is required so that the ignore pattern is relative to the file path
    cwd: contextPath,
  })

  for (const file of globFiles) {
    if (file.isDirectory()) {
      // For directories, add the directory itself and all files inside it
      if (includeDirectories) {
        files.set(file.fullpath(), file)
      }
      const dirPattern = normalizePath(
        // When the matched directory is '.', `file.relative()` can be an empty string.
        // In that case, we want to match all files under the current directory instead of
        // creating an absolute glob like '/**/*' which would traverse the entire filesystem.
        path.join(file.relative() || '.', '**/*')
      )
      const dirFiles = await glob(dirPattern, {
        ignore: ignorePatterns,
        withFileTypes: true,
        dot: true,
        cwd: contextPath,
      })
      dirFiles.forEach((f) => files.set(f.fullpath(), f))
    } else {
      // For files, just add the file
      files.set(file.fullpath(), file)
    }
  }

  // Sort by full path for a deterministic order — the default sort() would
  // stringify the Path objects to '[object Object]' and keep glob order,
  // making the files hash dependent on filesystem traversal order.
  return Array.from(files.values()).sort((a, b) =>
    a.fullpath() < b.fullpath() ? -1 : a.fullpath() > b.fullpath() ? 1 : 0
  )
}

/**
 * Calculate a hash of files being copied to detect changes for cache invalidation.
 * The hash includes file content, metadata (mode, size), and relative paths.
 * Note: uid, gid, and mtime are excluded to ensure stable hashes across environments.
 *
 * @param src Source path pattern for files to copy
 * @param dest Destination path where files will be copied
 * @param contextPath Base directory for resolving relative paths
 * @param ignorePatterns Glob patterns to ignore
 * @param resolveSymlinks Whether to resolve symbolic links when hashing
 * @param stackTrace Optional stack trace for error reporting
 * @returns Hex string hash of all files
 * @throws Error if no files match the source pattern
 */
export async function calculateFilesHash(
  src: string,
  dest: string,
  contextPath: string,
  ignorePatterns: string[],
  resolveSymlinks: boolean,
  stackTrace: string | undefined
): Promise<string> {
  const srcPath = path.join(contextPath, src)
  const hash = crypto.createHash('sha256')
  const content = `COPY ${src} ${dest}`

  hash.update(content)

  const files = await getAllFilesInPath(src, contextPath, ignorePatterns, true)

  if (files.length === 0) {
    const error = new Error(`No files found in ${srcPath}`)
    if (stackTrace) {
      error.stack = stackTrace
    }
    throw error
  }

  // Hash stats - only include stable metadata (mode, size)
  // Exclude uid, gid, and mtime to ensure consistent hashes across environments
  const hashStats = (stats: fs.Stats) => {
    hash.update(stats.mode.toString())
    hash.update(stats.size.toString())
  }

  // Process files recursively
  for (const file of files) {
    // Add a relative path to hash calculation
    const relativePath = file.relativePosix()
    hash.update(relativePath)

    // Add stat information to hash calculation
    if (file.isSymbolicLink()) {
      // If the symlink is broken, it will return undefined, otherwise it will return a stats object of the target
      const stats = fs.statSync(file.fullpath(), { throwIfNoEntry: false })
      const shouldFollow =
        resolveSymlinks && (stats?.isFile() || stats?.isDirectory())

      if (!shouldFollow) {
        const stats = fs.lstatSync(file.fullpath())

        hashStats(stats)

        const content = fs.readlinkSync(file.fullpath())
        hash.update(content)

        continue
      }
    }

    const stats = fs.statSync(file.fullpath())
    hashStats(stats)

    // Add file content to hash calculation
    if (stats.isFile()) {
      const content = fs.readFileSync(file.fullpath())
      hash.update(new Uint8Array(content))
    }
  }

  return hash.digest('hex')
}

/**
 * Get the caller's stack trace frame at a specific depth.
 *
 * @param depth The depth of the stack trace to retrieve
 *   - Levels: caller (e.g., TemplateBase.fromImage) > original caller (e.g., user's template file)
 * @returns The caller frame as a string, or undefined if not available
 */
export function getCallerFrame(depth: number): string | undefined {
  const stackTrace = new Error().stack
  if (!stackTrace) {
    return
  }

  const lines = stackTrace.split('\n').slice(1) // Skip the this function (getCallerFrame)
  if (lines.length < depth + 1) {
    return
  }

  return lines.slice(depth).join('\n')
}

// adopted from https://github.com/sindresorhus/callsites
export function callsites(depth: number): NodeJS.CallSite[] {
  const _originalPrepareStackTrace = Error.prepareStackTrace
  try {
    let result: NodeJS.CallSite[] = []
    Error.prepareStackTrace = (_, callSites) => {
      const callSitesWithoutCurrent = callSites.slice(depth)
      result = callSitesWithoutCurrent
      return callSitesWithoutCurrent
    }

    new Error().stack
    return result
  } finally {
    Error.prepareStackTrace = _originalPrepareStackTrace
  }
}

/**
 * Get the directory of the caller at a specific stack depth.
 *
 * @param depth The depth of the stack trace
 * @returns The caller's directory path, or undefined if not available
 */
export function getCallerDirectory(depth: number): string | undefined {
  // +1 depth to skip this function (getCallerDirectory)
  const callSites = callsites(depth + 1)
  if (callSites.length === 0) {
    return undefined
  }

  let fileName = callSites[0].getFileName()
  if (!fileName) {
    return undefined
  }

  // Handle file:// URLs returned by getFileName() in ESM modules
  if (fileName.startsWith('file:')) {
    // we use the dynamic import to avoid bundling node:url for browser compatibility
    // getCallerDirectory method is not called in the browser
    const { fileURLToPath } =
      dynamicRequire<typeof import('node:url')>('node:url')
    fileName = fileURLToPath(fileName)
  }

  return path.dirname(fileName)
}

/**
 * Convert a numeric file mode to a zero-padded octal string.
 *
 * @param mode File mode as a number (e.g., 493 for 0o755)
 * @returns Zero-padded 4-digit octal string (e.g., "0755")
 *
 * @example
 * ```ts
 * padOctal(0o755) // Returns "0755"
 * padOctal(0o644) // Returns "0644"
 * ```
 */
export function padOctal(mode: number): string {
  return mode.toString(8).padStart(4, '0')
}

/**
 * Create a gzipped tar archive of files matching a pattern and return a
 * readable stream over it.
 *
 * The archive is spooled to a temporary file on disk so it can be uploaded as
 * a stream with a known `Content-Length` instead of being buffered in memory.
 * The temporary file is removed automatically once the returned stream is
 * closed — whether it was fully read, errored, or destroyed. This mirrors the
 * Python SDK's `tar_file_stream`, where closing the temp-file handle deletes
 * it (Node has no portable delete-on-close, so cleanup is tied to the stream's
 * `close` event instead).
 *
 * @param fileName Glob pattern for files to include
 * @param fileContextPath Base directory for resolving file paths
 * @param ignorePatterns Ignore patterns to exclude from the archive
 * @param resolveSymlinks Whether to follow symbolic links
 * @returns The readable stream and the archive size in bytes
 */
export async function tarFileStream(
  fileName: string,
  fileContextPath: string,
  ignorePatterns: string[],
  resolveSymlinks: boolean
): Promise<{ stream: Readable; size: number }> {
  const { create } = await dynamicImport<typeof import('tar')>('tar')
  // Dynamically import so the browser bundle doesn't pull in node:fs.
  const { createReadStream } =
    await dynamicImport<typeof import('node:fs')>('node:fs')

  const allFiles = await getAllFilesInPath(
    fileName,
    fileContextPath,
    ignorePatterns,
    true
  )

  const filePaths = allFiles.map((file) => file.relativePosix())

  const tmpDir = await fs.promises.mkdtemp(
    path.join(os.tmpdir(), 'e2b-template-')
  )
  const tarPath = path.join(tmpDir, 'context.tar.gz')
  // Best-effort removal so it can never mask the archive-creation error or the
  // upload result. A leaked temp dir is non-fatal — the OS reclaims it.
  const removeTmpDir = () =>
    fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {})

  try {
    await create(
      {
        gzip: true,
        cwd: fileContextPath,
        follow: resolveSymlinks,
        noDirRecurse: true,
        file: tarPath,
      },
      filePaths
    )

    const { size } = await fs.promises.stat(tarPath)

    const stream = createReadStream(tarPath)
    // Remove the spooled archive once the stream is done — `close` fires after
    // the fd is released on every path (end, error, or destroy).
    stream.once('close', removeTmpDir)
    return { stream, size }
  } catch (err) {
    await removeTmpDir()
    throw err
  }
}

/**
 * Get the array index for a build step based on its name.
 *
 * Special steps:
 * - BASE_STEP_NAME: Returns 0 (first step)
 * - FINALIZE_STEP_NAME: Returns the last index
 * - Numeric strings: Converted to number
 *
 * @param step Build step name or number as string
 * @param stackTracesLength Total number of stack traces (used for FINALIZE_STEP_NAME)
 * @returns Index for the build step
 */
export function getBuildStepIndex(
  step: string,
  stackTracesLength: number
): number {
  if (step === BASE_STEP_NAME) {
    return 0
  }

  if (step === FINALIZE_STEP_NAME) {
    return stackTracesLength - 1
  }

  return Number(step)
}

/**
 * Read GCP service account JSON from a file or object.
 *
 * @param contextPath Base directory for resolving relative file paths
 * @param pathOrContent Either a path to a JSON file or a service account object
 * @returns Service account JSON as a string
 */
export function readGCPServiceAccountJSON(
  contextPath: string,
  pathOrContent: string | object
): string {
  if (typeof pathOrContent === 'string') {
    return fs.readFileSync(path.join(contextPath, pathOrContent), 'utf-8')
  }
  return JSON.stringify(pathOrContent)
}
