import fs from 'node:fs'
import path from 'node:path'

const DIFF_COVERAGE_IGNORE_LINE_TOKEN = 'diff-coverage-ignore-line:'
const DEFAULT_BRANCH_REF_CANDIDATES = ['origin/main', 'main']

export function normalizeDiffRangeMode(mode) {
  return mode === 'exact' ? 'exact' : 'merge-base'
}

export function buildGitDiffRevisionArgs(base, head, mode = 'merge-base') {
  return mode === 'exact'
    ? [base, head]
    : [`${base}...${head}`]
}

export function resolveGitDiffContext({
  base,
  head,
  mode = 'merge-base',
  execGit,
}) {
  const requestedMode = normalizeDiffRangeMode(mode)
  const context = {
    base,
    head,
    mode: requestedMode,
    requestedMode,
    reason: null,
    useCombinedMergeDiff: false,
  }

  if (requestedMode !== 'exact' || !base || !head || !execGit)
    return context

  const baseCommit = resolveCommitSha(base, execGit) ?? base
  const headCommit = resolveCommitSha(head, execGit) ?? head
  const parents = getCommitParents(headCommit, execGit)
  if (parents.length < 2)
    return context

  const [firstParent, secondParent] = parents
  if (firstParent !== baseCommit)
    return context

  const defaultBranchRef = resolveDefaultBranchRef(execGit)
  if (!defaultBranchRef || !isAncestor(secondParent, defaultBranchRef, execGit))
    return context

  return {
    ...context,
    reason: `ignored merge from ${defaultBranchRef}`,
    useCombinedMergeDiff: true,
  }
}

export function parseChangedLineMap(diff, isTrackedComponentSourceFile) {
  const lineMap = new Map()
  let currentFile = null

  for (const line of diff.split('\n')) {
    if (line.startsWith('+++ b/')) {
      currentFile = line.slice(6).trim()
      continue
    }

    if (!currentFile || !isTrackedComponentSourceFile(currentFile))
      continue

    const match = line.match(/^@{2,}(?: -\d+(?:,\d+)?)+ \+(\d+)(?:,(\d+))? @{2,}/)
    if (!match)
      continue

    const start = Number(match[1])
    const count = match[2] ? Number(match[2]) : 1
    if (count === 0)
      continue

    const linesForFile = lineMap.get(currentFile) ?? new Set()
    for (let offset = 0; offset < count; offset += 1)
      linesForFile.add(start + offset)
    lineMap.set(currentFile, linesForFile)
  }

  return lineMap
}

export function normalizeToRepoRelative(filePath, {
  appComponentsCoveragePrefix,
  appComponentsPrefix,
  repoRoot,
  sharedTestPrefix,
  webRoot,
}) {
  if (!filePath)
    return ''

  if (filePath.startsWith(appComponentsPrefix) || filePath.startsWith(sharedTestPrefix))
    return filePath

  if (filePath.startsWith(appComponentsCoveragePrefix))
    return `web/${filePath}`

  const absolutePath = path.isAbsolute(filePath)
    ? filePath
    : path.resolve(webRoot, filePath)

  return path.relative(repoRoot, absolutePath).split(path.sep).join('/')
}

export function getLineHits(entry) {
  if (entry?.l && Object.keys(entry.l).length > 0)
    return entry.l

  const lineHits = {}
  for (const [statementId, statement] of Object.entries(entry?.statementMap ?? {})) {
    const line = statement?.start?.line
    if (!line)
      continue

    const hits = entry?.s?.[statementId] ?? 0
    const previous = lineHits[line]
    lineHits[line] = previous === undefined ? hits : Math.max(previous, hits)
  }

  return lineHits
}

export function getChangedStatementCoverage(entry, changedLines) {
  const normalizedChangedLines = [...(changedLines ?? [])].sort((a, b) => a - b)
  if (!entry) {
    return {
      covered: 0,
      total: normalizedChangedLines.length,
      uncoveredLines: normalizedChangedLines,
    }
  }

  const uncoveredLines = []
  let covered = 0
  let total = 0

  for (const [statementId, statement] of Object.entries(entry.statementMap ?? {})) {
    if (!rangeIntersectsChangedLines(statement, changedLines))
      continue

    total += 1
    const hits = entry.s?.[statementId] ?? 0
    if (hits > 0) {
      covered += 1
      continue
    }

    uncoveredLines.push(getFirstChangedLineInRange(statement, normalizedChangedLines))
  }

  return {
    covered,
    total,
    uncoveredLines: uncoveredLines.sort((a, b) => a - b),
  }
}

export function getChangedBranchCoverage(entry, changedLines) {
  const normalizedChangedLines = [...(changedLines ?? [])].sort((a, b) => a - b)
  if (!entry) {
    return {
      covered: 0,
      total: 0,
      uncoveredBranches: [],
    }
  }

  const uncoveredBranches = []
  let covered = 0
  let total = 0

  for (const [branchId, branch] of Object.entries(entry.branchMap ?? {})) {
    const hits = Array.isArray(entry.b?.[branchId]) ? entry.b[branchId] : []
    const locations = getBranchLocations(branch)
    const armCount = Math.max(locations.length, hits.length)
    const impactedArmIndexes = getImpactedBranchArmIndexes(branch, changedLines, armCount)

    if (impactedArmIndexes.length === 0)
      continue

    for (const armIndex of impactedArmIndexes) {
      total += 1
      if ((hits[armIndex] ?? 0) > 0) {
        covered += 1
        continue
      }

      const location = locations[armIndex] ?? branch.loc ?? branch
      uncoveredBranches.push({
        armIndex,
        line: getFirstChangedLineInRange(location, normalizedChangedLines, branch.line ?? 1),
      })
    }
  }

  uncoveredBranches.sort((a, b) => a.line - b.line || a.armIndex - b.armIndex)
  return {
    covered,
    total,
    uncoveredBranches,
  }
}

export function getIgnoredChangedLinesFromFile(filePath, changedLines) {
  if (!fs.existsSync(filePath))
    return emptyIgnoreResult(changedLines)

  const sourceCode = fs.readFileSync(filePath, 'utf8')
  return getIgnoredChangedLinesFromSource(sourceCode, changedLines)
}

export function getIgnoredChangedLinesFromSource(sourceCode, changedLines) {
  const ignoredLines = new Map()
  const invalidPragmas = []
  const changedLineSet = new Set(changedLines ?? [])

  const sourceLines = sourceCode.split('\n')
  sourceLines.forEach((lineText, index) => {
    const lineNumber = index + 1
    const commentIndex = lineText.indexOf('//')
    if (commentIndex < 0)
      return

    const tokenIndex = lineText.indexOf(DIFF_COVERAGE_IGNORE_LINE_TOKEN, commentIndex + 2)
    if (tokenIndex < 0)
      return

    const reason = lineText.slice(tokenIndex + DIFF_COVERAGE_IGNORE_LINE_TOKEN.length).trim()
    if (!changedLineSet.has(lineNumber))
      return

    if (!reason) {
      invalidPragmas.push({
        line: lineNumber,
        reason: 'missing ignore reason',
      })
      return
    }

    ignoredLines.set(lineNumber, reason)
  })

  const effectiveChangedLines = new Set(
    [...changedLineSet].filter(lineNumber => !ignoredLines.has(lineNumber)),
  )

  return {
    effectiveChangedLines,
    ignoredLines,
    invalidPragmas,
  }
}

function emptyIgnoreResult(changedLines = []) {
  return {
    effectiveChangedLines: new Set(changedLines),
    ignoredLines: new Map(),
    invalidPragmas: [],
  }
}

function getCommitParents(ref, execGit) {
  const output = tryExecGit(execGit, ['rev-list', '--parents', '-n', '1', ref])
  if (!output)
    return []

  return output
    .trim()
    .split(/\s+/)
    .slice(1)
}

function resolveCommitSha(ref, execGit) {
  return tryExecGit(execGit, ['rev-parse', '--verify', ref])?.trim() ?? null
}

function resolveDefaultBranchRef(execGit) {
  const originHeadRef = tryExecGit(execGit, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'])?.trim()
  if (originHeadRef)
    return originHeadRef

  for (const ref of DEFAULT_BRANCH_REF_CANDIDATES) {
    if (tryExecGit(execGit, ['rev-parse', '--verify', '-q', ref]))
      return ref
  }

  return null
}

function isAncestor(ancestorRef, descendantRef, execGit) {
  try {
    execGit(['merge-base', '--is-ancestor', ancestorRef, descendantRef])
    return true
  }
  catch {
    return false
  }
}

function tryExecGit(execGit, args) {
  try {
    return execGit(args)
  }
  catch {
    return null
  }
}

function getBranchLocations(branch) {
  return Array.isArray(branch?.locations) ? branch.locations.filter(Boolean) : []
}

function getImpactedBranchArmIndexes(branch, changedLines, armCount) {
  if (!changedLines || changedLines.size === 0 || armCount === 0)
    return []

  const locations = getBranchLocations(branch)
  if (isWholeBranchTouched(branch, changedLines, locations, armCount))
    return Array.from({ length: armCount }, (_, armIndex) => armIndex)

  const impactedArmIndexes = []
  for (let armIndex = 0; armIndex < armCount; armIndex += 1) {
    const location = locations[armIndex]
    if (rangeIntersectsChangedLines(location, changedLines))
      impactedArmIndexes.push(armIndex)
  }

  return impactedArmIndexes
}

function isWholeBranchTouched(branch, changedLines, locations, armCount) {
  if (!changedLines || changedLines.size === 0)
    return false

  if (branch.line && changedLines.has(branch.line))
    return true

  const branchRange = branch.loc ?? branch
  if (!rangeIntersectsChangedLines(branchRange, changedLines))
    return false

  if (locations.length === 0 || locations.length < armCount)
    return true

  for (const lineNumber of changedLines) {
    if (!lineTouchesLocation(lineNumber, branchRange))
      continue
    if (!locations.some(location => lineTouchesLocation(lineNumber, location)))
      return true
  }

  return false
}

function rangeIntersectsChangedLines(location, changedLines) {
  if (!location || !changedLines || changedLines.size === 0)
    return false

  const startLine = getLocationStartLine(location)
  const endLine = getLocationEndLine(location) ?? startLine
  if (!startLine || !endLine)
    return false

  for (const lineNumber of changedLines) {
    if (lineNumber >= startLine && lineNumber <= endLine)
      return true
  }

  return false
}

function getFirstChangedLineInRange(location, changedLines, fallbackLine = 1) {
  const startLine = getLocationStartLine(location)
  const endLine = getLocationEndLine(location) ?? startLine
  if (!startLine || !endLine)
    return startLine ?? fallbackLine

  for (const lineNumber of changedLines) {
    if (lineNumber >= startLine && lineNumber <= endLine)
      return lineNumber
  }

  return startLine ?? fallbackLine
}

function lineTouchesLocation(lineNumber, location) {
  const startLine = getLocationStartLine(location)
  const endLine = getLocationEndLine(location) ?? startLine
  if (!startLine || !endLine)
    return false

  return lineNumber >= startLine && lineNumber <= endLine
}

function getLocationStartLine(location) {
  return location?.start?.line ?? location?.line ?? null
}

function getLocationEndLine(location) {
  return location?.end?.line ?? location?.line ?? null
}
