import * as core from '@actions/core'
import * as exec from '@actions/exec'
import * as fs from 'fs'
import * as fshelper from './fs-helper'
import * as io from '@actions/io'
import * as path from 'path'
import * as refHelper from './ref-helper'
import * as regexpHelper from './regexp-helper'
import * as retryHelper from './retry-helper'
import {GitVersion} from './git-version'

// Auth header not supported before 2.9
// Wire protocol v2 not supported before 2.18
// sparse-checkout not [well-]supported before 2.28 (see https://github.com/actions/checkout/issues/1386)
export const MinimumGitVersion = new GitVersion('2.18')
export const MinimumGitSparseCheckoutVersion = new GitVersion('2.28')

export interface IGitCommandManager {
  branchDelete(remote: boolean, branch: string): Promise<void>
  branchExists(remote: boolean, pattern: string): Promise<boolean>
  branchList(remote: boolean): Promise<string[]>
  disableSparseCheckout(): Promise<void>
  sparseCheckout(sparseCheckout: string[]): Promise<void>
  sparseCheckoutNonConeMode(sparseCheckout: string[]): Promise<void>
  checkout(ref: string, startPoint: string): Promise<void>
  checkoutDetach(): Promise<void>
  config(
    configKey: string,
    configValue: string,
    globalConfig?: boolean,
    add?: boolean,
    configFile?: string
  ): Promise<void>
  configExists(configKey: string, globalConfig?: boolean): Promise<boolean>
  fetch(
    refSpec: string[],
    options: {
      filter?: string
      fetchDepth?: number
      fetchTags?: boolean
      showProgress?: boolean
    }
  ): Promise<void>
  getDefaultBranch(repositoryUrl: string): Promise<string>
  getSubmoduleConfigPaths(recursive: boolean): Promise<string[]>
  getWorkingDirectory(): string
  init(): Promise<void>
  isDetached(): Promise<boolean>
  lfsFetch(ref: string): Promise<void>
  lfsInstall(): Promise<void>
  log1(format?: string): Promise<string>
  remoteAdd(remoteName: string, remoteUrl: string): Promise<void>
  removeEnvironmentVariable(name: string): void
  revParse(ref: string): Promise<string>
  setEnvironmentVariable(name: string, value: string): void
  shaExists(sha: string): Promise<boolean>
  submoduleForeach(command: string, recursive: boolean): Promise<string>
  submoduleSync(recursive: boolean): Promise<void>
  submoduleUpdate(fetchDepth: number, recursive: boolean): Promise<void>
  submoduleStatus(): Promise<boolean>
  tagExists(pattern: string): Promise<boolean>
  tryClean(): Promise<boolean>
  tryConfigUnset(configKey: string, globalConfig?: boolean): Promise<boolean>
  tryConfigUnsetValue(
    configKey: string,
    configValue: string,
    globalConfig?: boolean,
    configFile?: string
  ): Promise<boolean>
  tryDisableAutomaticGarbageCollection(): Promise<boolean>
  tryGetFetchUrl(): Promise<string>
  tryGetConfigValues(
    configKey: string,
    globalConfig?: boolean,
    configFile?: string
  ): Promise<string[]>
  tryGetConfigKeys(
    pattern: string,
    globalConfig?: boolean,
    configFile?: string
  ): Promise<string[]>
  tryReset(): Promise<boolean>
  version(): Promise<GitVersion>
}

export async function createCommandManager(
  workingDirectory: string,
  lfs: boolean,
  doSparseCheckout: boolean
): Promise<IGitCommandManager> {
  return await GitCommandManager.createCommandManager(
    workingDirectory,
    lfs,
    doSparseCheckout
  )
}

class GitCommandManager {
  private gitEnv = {
    GIT_TERMINAL_PROMPT: '0', // Disable git prompt
    GCM_INTERACTIVE: 'Never' // Disable prompting for git credential manager
  }
  private gitPath = ''
  private lfs = false
  private doSparseCheckout = false
  private workingDirectory = ''
  private gitVersion: GitVersion = new GitVersion()

  // Private constructor; use createCommandManager()
  private constructor() {}

  async branchDelete(remote: boolean, branch: string): Promise<void> {
    const args = ['branch', '--delete', '--force']
    if (remote) {
      args.push('--remote')
    }
    args.push(branch)

    await this.execGit(args)
  }

  async branchExists(remote: boolean, pattern: string): Promise<boolean> {
    const args = ['branch', '--list']
    if (remote) {
      args.push('--remote')
    }
    args.push(pattern)

    const output = await this.execGit(args)
    return !!output.stdout.trim()
  }

  async branchList(remote: boolean): Promise<string[]> {
    const result: string[] = []

    // Note, this implementation uses "rev-parse --symbolic-full-name" because the output from
    // "branch --list" is more difficult when in a detached HEAD state.

    // TODO(https://github.com/actions/checkout/issues/786): this implementation uses
    // "rev-parse --symbolic-full-name" because there is a bug
    // in Git 2.18 that causes "rev-parse --symbolic" to output symbolic full names. When
    // 2.18 is no longer supported, we can switch back to --symbolic.

    const args = ['rev-parse', '--symbolic-full-name']
    if (remote) {
      args.push('--remotes=origin')
    } else {
      args.push('--branches')
    }

    const stderr: string[] = []
    const errline: string[] = []
    const stdout: string[] = []
    const stdline: string[] = []

    const listeners = {
      stderr: (data: Buffer) => {
        stderr.push(data.toString())
      },
      errline: (data: Buffer) => {
        errline.push(data.toString())
      },
      stdout: (data: Buffer) => {
        stdout.push(data.toString())
      },
      stdline: (data: Buffer) => {
        stdline.push(data.toString())
      }
    }

    // Suppress the output in order to avoid flooding annotations with innocuous errors.
    await this.execGit(args, false, true, listeners)

    core.debug(`stderr callback is: ${stderr}`)
    core.debug(`errline callback is: ${errline}`)
    core.debug(`stdout callback is: ${stdout}`)
    core.debug(`stdline callback is: ${stdline}`)

    for (let branch of stdline) {
      branch = branch.trim()
      if (!branch) {
        continue
      }

      if (branch.startsWith('refs/heads/')) {
        branch = branch.substring('refs/heads/'.length)
      } else if (branch.startsWith('refs/remotes/')) {
        branch = branch.substring('refs/remotes/'.length)
      }

      result.push(branch)
    }

    return result
  }

  async disableSparseCheckout(): Promise<void> {
    await this.execGit(['sparse-checkout', 'disable'])
    // Disabling 'sparse-checkout` leaves behind an undesirable side-effect in config (even in a pristine environment).
    await this.tryConfigUnset('extensions.worktreeConfig', false)
  }

  async sparseCheckout(sparseCheckout: string[]): Promise<void> {
    await this.execGit(['sparse-checkout', 'set', ...sparseCheckout])
  }

  async sparseCheckoutNonConeMode(sparseCheckout: string[]): Promise<void> {
    await this.execGit(['config', 'core.sparseCheckout', 'true'])
    const output = await this.execGit([
      'rev-parse',
      '--git-path',
      'info/sparse-checkout'
    ])
    const sparseCheckoutPath = path.join(
      this.workingDirectory,
      output.stdout.trimRight()
    )
    await fs.promises.appendFile(
      sparseCheckoutPath,
      `\n${sparseCheckout.join('\n')}\n`
    )
  }

  async checkout(ref: string, startPoint: string): Promise<void> {
    const args = ['checkout', '--progress', '--force']
    if (startPoint) {
      args.push('-B', ref, startPoint)
    } else {
      args.push(ref)
    }

    await this.execGit(args)
  }

  async checkoutDetach(): Promise<void> {
    const args = ['checkout', '--detach']
    await this.execGit(args)
  }

  async config(
    configKey: string,
    configValue: string,
    globalConfig?: boolean,
    add?: boolean,
    configFile?: string
  ): Promise<void> {
    const args: string[] = ['config']
    if (configFile) {
      args.push('--file', configFile)
    } else {
      args.push(globalConfig ? '--global' : '--local')
    }
    if (add) {
      args.push('--add')
    }
    args.push(...[configKey, configValue])
    await this.execGit(args)
  }

  async configExists(
    configKey: string,
    globalConfig?: boolean
  ): Promise<boolean> {
    const pattern = regexpHelper.escape(configKey)
    const output = await this.execGit(
      [
        'config',
        globalConfig ? '--global' : '--local',
        '--name-only',
        '--get-regexp',
        pattern
      ],
      true
    )
    return output.exitCode === 0
  }

  async fetch(
    refSpec: string[],
    options: {
      filter?: string
      fetchDepth?: number
      fetchTags?: boolean
      showProgress?: boolean
    }
  ): Promise<void> {
    const args = ['-c', 'protocol.version=2', 'fetch']
    if (!refSpec.some(x => x === refHelper.tagsRefSpec) && !options.fetchTags) {
      args.push('--no-tags')
    }

    args.push('--prune', '--no-recurse-submodules')
    if (options.showProgress) {
      args.push('--progress')
    }

    if (options.filter) {
      args.push(`--filter=${options.filter}`)
    }

    if (options.fetchDepth && options.fetchDepth > 0) {
      args.push(`--depth=${options.fetchDepth}`)
    } else if (
      fshelper.fileExistsSync(
        path.join(this.workingDirectory, '.git', 'shallow')
      )
    ) {
      args.push('--unshallow')
    }

    args.push('origin')
    for (const arg of refSpec) {
      args.push(arg)
    }

    const that = this
    await retryHelper.execute(async () => {
      await that.execGit(args)
    })
  }

  async getDefaultBranch(repositoryUrl: string): Promise<string> {
    let output: GitOutput | undefined
    await retryHelper.execute(async () => {
      output = await this.execGit([
        'ls-remote',
        '--quiet',
        '--exit-code',
        '--symref',
        repositoryUrl,
        'HEAD'
      ])
    })

    if (output) {
      // Satisfy compiler, will always be set
      for (let line of output.stdout.trim().split('\n')) {
        line = line.trim()
        if (line.startsWith('ref:') || line.endsWith('HEAD')) {
          return line
            .substr('ref:'.length, line.length - 'ref:'.length - 'HEAD'.length)
            .trim()
        }
      }
    }

    throw new Error('Unexpected output when retrieving default branch')
  }

  async getSubmoduleConfigPaths(recursive: boolean): Promise<string[]> {
    // Get submodule config file paths.
    // Use `--show-origin` to get the config file path for each submodule.
    const output = await this.submoduleForeach(
      `git config --local --show-origin --name-only --get-regexp remote.origin.url`,
      recursive
    )

    // Extract config file paths from the output (lines starting with "file:").
    const configPaths =
      output.match(/(?<=(^|\n)file:)[^\t]+(?=\tremote\.origin\.url)/g) || []

    return configPaths
  }

  getWorkingDirectory(): string {
    return this.workingDirectory
  }

  async init(): Promise<void> {
    await this.execGit(['init', this.workingDirectory])
  }

  async isDetached(): Promise<boolean> {
    // Note, "branch --show-current" would be simpler but isn't available until Git 2.22
    const output = await this.execGit(
      ['rev-parse', '--symbolic-full-name', '--verify', '--quiet', 'HEAD'],
      true
    )
    return !output.stdout.trim().startsWith('refs/heads/')
  }

  async lfsFetch(ref: string): Promise<void> {
    const args = ['lfs', 'fetch', 'origin', ref]

    const that = this
    await retryHelper.execute(async () => {
      await that.execGit(args)
    })
  }

  async lfsInstall(): Promise<void> {
    await this.execGit(['lfs', 'install', '--local'])
  }

  async log1(format?: string): Promise<string> {
    const args = format ? ['log', '-1', format] : ['log', '-1']
    const silent = format ? false : true
    const output = await this.execGit(args, false, silent)
    return output.stdout
  }

  async remoteAdd(remoteName: string, remoteUrl: string): Promise<void> {
    await this.execGit(['remote', 'add', remoteName, remoteUrl])
  }

  removeEnvironmentVariable(name: string): void {
    delete this.gitEnv[name]
  }

  /**
   * Resolves a ref to a SHA. For a branch or lightweight tag, the commit SHA is returned.
   * For an annotated tag, the tag SHA is returned.
   * @param {string} ref  For example: 'refs/heads/main' or '/refs/tags/v1'
   * @returns {Promise<string>}
   */
  async revParse(ref: string): Promise<string> {
    const output = await this.execGit(['rev-parse', ref])
    return output.stdout.trim()
  }

  setEnvironmentVariable(name: string, value: string): void {
    this.gitEnv[name] = value
  }

  async shaExists(sha: string): Promise<boolean> {
    const args = ['rev-parse', '--verify', '--quiet', `${sha}^{object}`]
    const output = await this.execGit(args, true)
    return output.exitCode === 0
  }

  async submoduleForeach(command: string, recursive: boolean): Promise<string> {
    const args = ['submodule', 'foreach']
    if (recursive) {
      args.push('--recursive')
    }
    args.push(command)

    const output = await this.execGit(args)
    return output.stdout
  }

  async submoduleSync(recursive: boolean): Promise<void> {
    const args = ['submodule', 'sync']
    if (recursive) {
      args.push('--recursive')
    }

    await this.execGit(args)
  }

  async submoduleUpdate(fetchDepth: number, recursive: boolean): Promise<void> {
    const args = ['-c', 'protocol.version=2']
    args.push('submodule', 'update', '--init', '--force')
    if (fetchDepth > 0) {
      args.push(`--depth=${fetchDepth}`)
    }

    if (recursive) {
      args.push('--recursive')
    }

    await this.execGit(args)
  }

  async submoduleStatus(): Promise<boolean> {
    const output = await this.execGit(['submodule', 'status'], true)
    core.debug(output.stdout)
    return output.exitCode === 0
  }

  async tagExists(pattern: string): Promise<boolean> {
    const output = await this.execGit(['tag', '--list', pattern])
    return !!output.stdout.trim()
  }

  async tryClean(): Promise<boolean> {
    const output = await this.execGit(['clean', '-ffdx'], true)
    return output.exitCode === 0
  }

  async tryConfigUnset(
    configKey: string,
    globalConfig?: boolean
  ): Promise<boolean> {
    const output = await this.execGit(
      [
        'config',
        globalConfig ? '--global' : '--local',
        '--unset-all',
        configKey
      ],
      true
    )
    return output.exitCode === 0
  }

  async tryConfigUnsetValue(
    configKey: string,
    configValue: string,
    globalConfig?: boolean,
    configFile?: string
  ): Promise<boolean> {
    const args = ['config']
    if (configFile) {
      args.push('--file', configFile)
    } else {
      args.push(globalConfig ? '--global' : '--local')
    }
    args.push('--unset', configKey, configValue)

    const output = await this.execGit(args, true)
    return output.exitCode === 0
  }

  async tryDisableAutomaticGarbageCollection(): Promise<boolean> {
    const output = await this.execGit(
      ['config', '--local', 'gc.auto', '0'],
      true
    )
    return output.exitCode === 0
  }

  async tryGetFetchUrl(): Promise<string> {
    const output = await this.execGit(
      ['config', '--local', '--get', 'remote.origin.url'],
      true
    )

    if (output.exitCode !== 0) {
      return ''
    }

    const stdout = output.stdout.trim()
    if (stdout.includes('\n')) {
      return ''
    }

    return stdout
  }

  async tryGetConfigValues(
    configKey: string,
    globalConfig?: boolean,
    configFile?: string
  ): Promise<string[]> {
    const args = ['config']
    if (configFile) {
      args.push('--file', configFile)
    } else {
      args.push(globalConfig ? '--global' : '--local')
    }
    args.push('--get-all', configKey)

    const output = await this.execGit(args, true)

    if (output.exitCode !== 0) {
      return []
    }

    return output.stdout
      .trim()
      .split('\n')
      .filter(value => value.trim())
  }

  async tryGetConfigKeys(
    pattern: string,
    globalConfig?: boolean,
    configFile?: string
  ): Promise<string[]> {
    const args = ['config']
    if (configFile) {
      args.push('--file', configFile)
    } else {
      args.push(globalConfig ? '--global' : '--local')
    }
    args.push('--name-only', '--get-regexp', pattern)

    const output = await this.execGit(args, true)

    if (output.exitCode !== 0) {
      return []
    }

    return output.stdout
      .trim()
      .split('\n')
      .filter(key => key.trim())
  }

  async tryReset(): Promise<boolean> {
    const output = await this.execGit(['reset', '--hard', 'HEAD'], true)
    return output.exitCode === 0
  }

  async version(): Promise<GitVersion> {
    return this.gitVersion
  }

  static async createCommandManager(
    workingDirectory: string,
    lfs: boolean,
    doSparseCheckout: boolean
  ): Promise<GitCommandManager> {
    const result = new GitCommandManager()
    await result.initializeCommandManager(
      workingDirectory,
      lfs,
      doSparseCheckout
    )
    return result
  }

  private async execGit(
    args: string[],
    allowAllExitCodes = false,
    silent = false,
    customListeners = {}
  ): Promise<GitOutput> {
    fshelper.directoryExistsSync(this.workingDirectory, true)

    const result = new GitOutput()

    const env = {}
    for (const key of Object.keys(process.env)) {
      env[key] = process.env[key]
    }
    for (const key of Object.keys(this.gitEnv)) {
      env[key] = this.gitEnv[key]
    }

    const defaultListener = {
      stdout: (data: Buffer) => {
        stdout.push(data.toString())
      }
    }

    const mergedListeners = {...defaultListener, ...customListeners}

    const stdout: string[] = []
    const options = {
      cwd: this.workingDirectory,
      env,
      silent,
      ignoreReturnCode: allowAllExitCodes,
      listeners: mergedListeners
    }

    result.exitCode = await exec.exec(`"${this.gitPath}"`, args, options)
    result.stdout = stdout.join('')

    core.debug(result.exitCode.toString())
    core.debug(result.stdout)

    return result
  }

  private async initializeCommandManager(
    workingDirectory: string,
    lfs: boolean,
    doSparseCheckout: boolean
  ): Promise<void> {
    this.workingDirectory = workingDirectory

    // Git-lfs will try to pull down assets if any of the local/user/system setting exist.
    // If the user didn't enable `LFS` in their pipeline definition, disable LFS fetch/checkout.
    this.lfs = lfs
    if (!this.lfs) {
      this.gitEnv['GIT_LFS_SKIP_SMUDGE'] = '1'
    }

    this.gitPath = await io.which('git', true)

    // Git version
    core.debug('Getting git version')
    this.gitVersion = new GitVersion()
    let gitOutput = await this.execGit(['version'])
    let stdout = gitOutput.stdout.trim()
    if (!stdout.includes('\n')) {
      const match = stdout.match(/\d+\.\d+(\.\d+)?/)
      if (match) {
        this.gitVersion = new GitVersion(match[0])
      }
    }
    if (!this.gitVersion.isValid()) {
      throw new Error('Unable to determine git version')
    }

    // Minimum git version
    if (!this.gitVersion.checkMinimum(MinimumGitVersion)) {
      throw new Error(
        `Minimum required git version is ${MinimumGitVersion}. Your git ('${this.gitPath}') is ${this.gitVersion}`
      )
    }

    if (this.lfs) {
      // Git-lfs version
      core.debug('Getting git-lfs version')
      let gitLfsVersion = new GitVersion()
      const gitLfsPath = await io.which('git-lfs', true)
      gitOutput = await this.execGit(['lfs', 'version'])
      stdout = gitOutput.stdout.trim()
      if (!stdout.includes('\n')) {
        const match = stdout.match(/\d+\.\d+(\.\d+)?/)
        if (match) {
          gitLfsVersion = new GitVersion(match[0])
        }
      }
      if (!gitLfsVersion.isValid()) {
        throw new Error('Unable to determine git-lfs version')
      }

      // Minimum git-lfs version
      // Note:
      // - Auth header not supported before 2.1
      const minimumGitLfsVersion = new GitVersion('2.1')
      if (!gitLfsVersion.checkMinimum(minimumGitLfsVersion)) {
        throw new Error(
          `Minimum required git-lfs version is ${minimumGitLfsVersion}. Your git-lfs ('${gitLfsPath}') is ${gitLfsVersion}`
        )
      }
    }

    this.doSparseCheckout = doSparseCheckout
    if (this.doSparseCheckout) {
      if (!this.gitVersion.checkMinimum(MinimumGitSparseCheckoutVersion)) {
        throw new Error(
          `Minimum Git version required for sparse checkout is ${MinimumGitSparseCheckoutVersion}. Your git ('${this.gitPath}') is ${this.gitVersion}`
        )
      }
    }
    // Set the user agent
    const gitHttpUserAgent = `git/${this.gitVersion} (github-actions-checkout)`
    core.debug(`Set git useragent to: ${gitHttpUserAgent}`)
    this.gitEnv['GIT_HTTP_USER_AGENT'] = gitHttpUserAgent
  }
}

class GitOutput {
  stdout = ''
  exitCode = 0
}
