import {
  type ReposConfigurationWithDefaultsApplied,
  type GitConfig,
} from './configTypes.ts'
import util from 'util'
import childProcess from 'child_process'
import {minimatch} from 'minimatch'

import { type Repository, type FileInfo } from './dataTypes.ts'
import cloneUrl from './vcses/git/helpers.ts'
import { addBranchToCommitsMap } from './vcses/git/operations.ts'
import { getLocation, readChunkedCommandOutput } from './helpers.ts'
import { getFileList, getFileLastTouchInfo } from './vcses/git/operations.ts'

const exec = util.promisify(childProcess.exec)

const branchesForReposMap: Map<string, Array<{name: string, description?: string, compareTo?: string}>> = new Map()
const tagsForReposMap: Map<string, Array<{name: string}>> = new Map()

const getBranchesAndTags = async (
  reposConfig: ReposConfigurationWithDefaultsApplied,
  repoName: string,
  outputDir: string,
  slugify: Function,
): Promise<{
  branches: Array<{name: string, description?: string, compareTo?: string}>,
  tags: Array<{name: string}>,
}> => {
  const repoConfig = reposConfig.repos[repoName]
  const cachedBranchNames = branchesForReposMap.get(repoName)
  const cachedTagNames = tagsForReposMap.get(repoName)
  if (cachedBranchNames !== undefined) {
    return {branches: cachedBranchNames, tags: cachedTagNames}
  }

  // Get all branches and tags available in the repository
  // Get it from the local, cloned location, instead of the remote, since `git -C` commands
  // don't work on remote locations.
  const clonedLocation = getLocation(reposConfig, outputDir, repoName, slugify) 
  const allBranches = (await exec(`git -C ${clonedLocation} branch --format="%(refname:short)"`)).stdout.split("\n").filter(branch => branch !== '')
  const allTags = (await exec(`git -C ${clonedLocation} tag`)).stdout.split("\n").filter(tag => tag !== '')

  // Sort the list of branch descriptions from `branches` by the length
  // of their patterns.
  // Then, for each branch from the repository, see if it matches a pattern.
  // If that pattern has rules associated with it (like a description or a max),
  // apply those.
  type RulesObject = {max?: number, description?: string, compareTo?: string}
  let branchRules: Array<{pattern: string, matches: Array<string>, rules: RulesObject}> = repoConfig.branches.map((branchDescription) => {
    const rules: RulesObject = {}

    if (typeof branchDescription !== 'string') {
      if (branchDescription.max) { rules.max = branchDescription.max }
      if (branchDescription.description) { rules.description = branchDescription.description }
      if (branchDescription.compareTo) { rules.compareTo = branchDescription.compareTo }
    }

    return {
      pattern: (typeof branchDescription === 'string' ? branchDescription : branchDescription.pattern),
      matches: [],
      rules,
    }
  })

  let tagRules: Array<{pattern: string, matches: Array<string>, rules: RulesObject}> = repoConfig.tags.map((tagDescription) => {
    const rules: RulesObject = {}

    if (typeof tagDescription !== 'string') {
      if (tagDescription.max) { rules.max = tagDescription.max }
    }

    return {
      pattern: (typeof tagDescription === 'string' ? tagDescription : tagDescription.pattern),
      matches: [],
      rules,
    }
  })

  branchRules.sort((a, b) => b.pattern.length - a.pattern.length)
  tagRules.sort((a, b) => b.pattern.length - a.pattern.length)

  allBranches.forEach((branchName) => {
    const matchingPatternIndex = branchRules.findIndex((branchRule) => {
      return minimatch(branchName, branchRule.pattern)
    })

    if (matchingPatternIndex === -1) { return }

    const matchedRule = branchRules[matchingPatternIndex]
    matchedRule.matches.push(branchName)
    if (
      matchedRule.rules?.max
      && matchedRule.rules?.max < matchedRule.matches.length
    ) {
      matchedRule.matches.pop()
    }
  })

  allTags.forEach((tagName) => {
    const matchingPatternIndex = tagRules.findIndex((tagRule) => {
      return minimatch(tagName, tagRule.pattern)
    })

    if (matchingPatternIndex === -1) { return }

    const matchedRule = tagRules[matchingPatternIndex]
    matchedRule.matches.push(tagName)
    if (
      matchedRule.rules?.max
      && matchedRule.rules?.max < matchedRule.matches.length
    ) {
      matchedRule.matches.pop()
    }
  })

  const branches: Array<{
    name: string,
    description?: string,
    compareTo?: string,
  }> = branchRules.map((branchRule) => {
    return branchRule.matches.map((match) => {
      const result = {name: match}
      if (branchRule.rules.description) { result['description'] = branchRule.rules.description }
      if (branchRule.rules.compareTo) { result['compareTo'] = branchRule.rules.compareTo }
      return result
    }).flat()
  }).flat()

  const tags: Array<{ name: string }> = tagRules.map((tagRule) => {
    return tagRule.matches.map((match) => {
      const result = {name: match}
      return result
    }).flat()
  }).flat()

  if (branchesForReposMap.get(repoName) === undefined) {
    branchesForReposMap.set(repoName, branches)
  }
  if (tagsForReposMap.get(repoName) === undefined) {
    tagsForReposMap.set(repoName, tags)
  }
  return { branches, tags }
}

let cachedRepos: Array<Repository> | null = null
// A cached list of files that we have already found. Save things here
// when we read them from calling `git show`.
// The key for this map is a string like "filename-sha".
const fileMap: Map<string, FileInfo> = new Map()

const repos: (
  reposConfig: ReposConfigurationWithDefaultsApplied,
  outputDir: string,
  slugify: Function
) => Promise<Array<Repository>> = async (reposConfig, outputDir, slugify) => {
  if (cachedRepos !== null) { return cachedRepos }

  const repoNames = Object.keys(reposConfig.repos)
  cachedRepos = []

  const getRefShaAndFileList = async (ref, refType: 'branch' | 'tag', repoName: string, repoLocation: string) => {
    const refHeadRes = await exec(`git -C ${repoLocation} show-ref refs/${refType === 'branch' ? 'heads' : 'tags'}/${ref.name}`)
    const refHead = refHeadRes.stdout.split(" ")[0]
    const result = {
      name: ref.name,
      sha: refHead,
      fileList: await getFileList(ref.name, repoLocation)
    }
    if (ref.description) { result['description'] = ref.description }
    if (ref.compareTo) { result['compareTo'] = ref.compareTo }

    return result
  }

  for (const repoName of repoNames) {
    const repoLocation = getLocation(reposConfig, outputDir, repoName, slugify)
    const commits: Repository['commits'] = new Map()
    const branchesAndTags = await getBranchesAndTags(reposConfig, repoName, outputDir, slugify)
    const branchNames = branchesAndTags.branches.map(branch => branch.name)
    for (const branchName of branchNames) {
      await addBranchToCommitsMap(branchName, repoLocation, commits)
    }

    const branches = await Promise.all(branchesAndTags.branches.map(async (branch) => {
      return getRefShaAndFileList(branch, 'branch', repoName, repoLocation)
    }))

    const tags = await Promise.all(branchesAndTags.tags.map(async (tag) => {
      return getRefShaAndFileList(tag, 'tag', repoName, repoLocation)
    }))

    const branchesWithCompareToInfo: Repository['branches'] = branches.map((branch) => {
      const branchDescription = branchesAndTags.branches.find(branchToAdd => branchToAdd.name === branch.name)
      const compareTo = branchDescription.compareTo || reposConfig.repos[repoName].defaultBranch
      const compareToBranch = branches.find((test) => test.name === compareTo)

      const compareToBranchCommits = new Set<string>()
      let currentCommit = commits.get(compareToBranch.sha)
      while (currentCommit !== undefined) {
        compareToBranchCommits.add(currentCommit.hash)
        currentCommit = commits.get(currentCommit.parent)
      }

      const thisBranchCommits = new Set<string>()
      currentCommit = commits.get(branch.sha)
      while (currentCommit !== undefined) {
        thisBranchCommits.add(currentCommit.hash)
        currentCommit = commits.get(currentCommit.parent)
      }

      // At this point, we have all commits in the compareTo branch in one set, and
      // all commits from this branch in another set.
      const onlyInThisBranch = Array.from(thisBranchCommits).filter((thisBranchCommit) => {
        return !commits.get(thisBranchCommit).isMerge && !compareToBranchCommits.has(thisBranchCommit)
      }).length
      const onlyInCompareToBranch = Array.from(compareToBranchCommits).filter((compareToBranchCommit) => {
        return !commits.get(compareToBranchCommit).isMerge && !thisBranchCommits.has(compareToBranchCommit)
      }).length

      const compareToInfo = {
        ahead: onlyInThisBranch,
        behind: onlyInCompareToBranch,
        compareTo: compareTo,
      }

      return {...branch, ...compareToInfo}
    })

    cachedRepos.push({
      name: repoName,
      description: reposConfig.repos[repoName].description,
      branches: branchesWithCompareToInfo,
      cloneUrl: cloneUrl.cloneUrl(reposConfig.baseUrl, repoName),
      defaultBranch: reposConfig.repos[repoName].defaultBranch,
      tags: tags,
      files: async (filename: string, sha: string) => {
        let key = `${filename}-${sha}`
        // First, see if fileMap already contains this file/sha combination.
        if (fileMap.has(key)) {
          return fileMap.get(key)
        }
        else {
          // If it didn't, look up the chain of commits that may have the same file contents.
          let currentCommit = commits.get(commits.get(sha).parent)
          while (currentCommit !== undefined) {
            if (currentCommit.diffs.map(d => d.fileName).includes(filename)) {
              break
            }
            if (fileMap.has(`${filename}-${currentCommit.hash}`)) {
              return fileMap.get(`${filename}-${currentCommit.hash}`)
            }

            currentCommit = commits.get(currentCommit.parent)
          }
        }
        const fileContents = await readChunkedCommandOutput('git', ['-C',  repoLocation, 'show', `${sha}:${filename}`])
        const blameLines = await getFileLastTouchInfo(filename, sha, repoLocation)
        const commit = {
          contents: fileContents,
          lastModified: new Date(), //TODO: make lastModified check the actual modification date
          blameLines,
        }

        fileMap.set(key, commit)
        return commit
      },
      commits,
    })
  }

  return cachedRepos
}

export {repos, getBranchesAndTags}
