import { statSync } from 'node:fs'
import { posix, relative, sep } from 'node:path'
import { fileURLToPath } from 'node:url'

import type { StarlightUserConfig as StarlightUserConfigWithPlugins } from '@astrojs/starlight/types'
import type { AstroConfig } from 'astro'
import picomatch from 'picomatch'

import type { StarlightLinksValidatorOptions } from '..'
import type { ValidationReport, ValidationReportIssue } from '../reporters'

import { getFallbackHeadings, getLocaleConfig, isInconsistentLocaleLink, type LocaleConfig } from './i18n'
import type { Link } from './link'
import { ensureTrailingSlash, normalizePathname, stripLeadingSlash, stripTrailingSlash } from './path'
import { getErrorPosition, isSameLineSourcePosition, type Reference } from './position'
import { getValidationData, type ValidationData } from './store'

const documentationUrl = 'https://starlight-links-validator.vercel.app/'

const validationErrorDefinitions = {
  InconsistentLocale: {
    message: 'inconsistent locale',
    slug: 'inconsistent-locale',
  },
  InvalidHash: {
    message: 'invalid hash',
    slug: 'invalid-hash',
  },
  InvalidLink: {
    message: 'invalid link',
    slug: 'invalid-link',
  },
  InvalidLinkToCustomPage: {
    message: 'invalid link to custom page',
    slug: 'invalid-link-to-custom-page',
  },
  LocalLink: {
    message: 'local link',
    slug: 'local-link',
  },
  RelativeLink: {
    message: 'relative link',
    slug: 'relative-link',
  },
  SameSite: {
    message: ({ site }) => `${site} can be omitted`,
    slug: 'same-site',
  },
  TrailingSlashMissing: {
    message: 'missing trailing slash',
    slug: 'missing-trailing-slash',
  },
  TrailingSlashForbidden: {
    message: 'forbidden trailing slash',
    slug: 'forbidden-trailing-slash',
  },
} as const satisfies Record<
  string,
  { slug: string; message: string | ((context: ValidationErrorMessageContext) => string) }
>

export const ValidationErrorType = Object.freeze(
  Object.fromEntries(Object.keys(validationErrorDefinitions).map((type) => [type, type])) as {
    [Key in ValidationErrorType]: Key
  },
)

export async function validateLinks(
  pages: PageData[],
  projectRoutes: ProjectRoutes,
  outputDir: URL,
  astroConfig: AstroConfig,
  starlightConfig: StarlightUserConfig,
  options: StarlightLinksValidatorOptions,
): Promise<ValidationReport> {
  const localeConfig = getLocaleConfig(starlightConfig)
  const validationData = getValidationData()
  const allPages: Pages = new Set(pages.map((page) => normalizePathname(page.pathname, astroConfig.base)))

  const issues: ValidationContext['issues'] = new Map()

  for (const [id, { links: fileLinks, file }] of validationData) {
    for (const link of fileLinks) {
      const validationContext: ValidationContext = {
        astroConfig,
        file,
        id,
        issues,
        link,
        localeConfig,
        options,
        outputDir,
        pages: allPages,
        projectRoutes,
        validationData,
      }

      if (link.raw.startsWith('#') || link.raw.startsWith('?')) {
        if (options.errorOnInvalidHashes) {
          validateSelfHash(validationContext)
        }
      } else {
        validateLink(validationContext)
      }
    }
  }

  const validationReportFiles = await Promise.all(
    [...issues.values()].map((file) => buildValidationReportFile(file, astroConfig)),
  )

  const files: ValidationReport['files'] = []
  let errorCount = 0
  let hasInvalidLinkToCustomPage = false

  for (const validationReportFile of validationReportFiles) {
    files.push(validationReportFile.file)
    errorCount += validationReportFile.errorCount
    hasInvalidLinkToCustomPage ||= validationReportFile.hasInvalidLinkToCustomPage
  }

  return {
    errorCount,
    files,
    hasErrors: files.length > 0,
    hasInvalidLinkToCustomPage,
  }
}

export function getValidationErrorMessage(type: ValidationErrorType, context: ValidationErrorMessageContext) {
  const { message } = validationErrorDefinitions[type]
  return typeof message === 'function' ? message(context) : message
}

export function getValidationErrorDocumentationUrl(type: ValidationErrorType) {
  return new URL(`errors/${validationErrorDefinitions[type].slug}/`, documentationUrl).href
}

/**
 * Validate a link to another internal page that may or may not have a hash.
 */
function validateLink(context: ValidationContext) {
  const { astroConfig, id, link, localeConfig, options, pages, projectRoutes } = context

  if (isExcludedLink(link, context)) {
    return
  }

  if (link.error) {
    addIssue(context, link.error)
    return
  }

  const linkToValidate = link.transformed ?? link.raw
  const sanitizedLink = linkToValidate.replace(/^\//, '')
  const segments = sanitizedLink.split('#')

  let path = segments[0]
  const hash = segments[1]

  if (path === undefined) {
    throw new Error('Failed to validate a link with no path.')
  }

  path = stripQueryString(path)

  if (path.startsWith('.') || (!linkToValidate.startsWith('/') && !linkToValidate.startsWith('?'))) {
    if (options.errorOnRelativeLinks) {
      addIssue(context, ValidationErrorType.RelativeLink)
    }

    return
  }

  if (isValidAsset(path, context)) {
    return
  }

  const sanitizedPath = ensureTrailingSlash(stripQueryString(path))

  const isValidPage = pages.has(sanitizedPath)
  let fileHeadings = getFileHeadings(sanitizedPath, context)

  if (!isValidPage || !fileHeadings) {
    const projectRoute = projectRoutes.get(stripTrailingSlash(sanitizedPath))

    if (projectRoute?.type === 'redirect-external') {
      fileHeadings = undefined
    } else if (projectRoute?.type === 'redirect-internal') {
      fileHeadings = getFileHeadings(projectRoute.path, context)

      if (!fileHeadings) {
        const destination = projectRoutes.get(stripTrailingSlash(projectRoute.path))

        addIssue(
          context,
          destination?.type === 'custom-page'
            ? ValidationErrorType.InvalidLinkToCustomPage
            : ValidationErrorType.InvalidLink,
        )
        return
      }
    } else {
      addIssue(
        context,
        projectRoute?.type === 'custom-page'
          ? ValidationErrorType.InvalidLinkToCustomPage
          : ValidationErrorType.InvalidLink,
      )
      return
    }
  }

  if (options.errorOnInconsistentLocale && localeConfig && isInconsistentLocaleLink(id, link.raw, localeConfig)) {
    addIssue(context, ValidationErrorType.InconsistentLocale)
    return
  }

  if (hash && fileHeadings && !fileHeadings.includes(hash)) {
    if (options.errorOnInvalidHashes) {
      addIssue(context, ValidationErrorType.InvalidHash)
    }
    return
  }

  if (path.length > 0) {
    if (astroConfig.trailingSlash === 'always' && !path.endsWith('/')) {
      addIssue(context, ValidationErrorType.TrailingSlashMissing)
      return
    } else if (astroConfig.trailingSlash === 'never' && path.endsWith('/')) {
      addIssue(context, ValidationErrorType.TrailingSlashForbidden)
      return
    }
  }
}

function getFileHeadings(path: string, { astroConfig, localeConfig, options, validationData }: ValidationContext) {
  let headings = validationData.get(path === '' ? '/' : path)?.headings

  if (!options.errorOnFallbackPages && !headings && localeConfig) {
    headings = getFallbackHeadings(path, validationData, localeConfig, astroConfig.base)
  }

  return headings
}

/**
 * Validate a link to an hash in the same page.
 */
function validateSelfHash(context: ValidationContext) {
  const { link, id, validationData } = context

  if (isExcludedLink(link, context)) {
    return
  }

  const hash = link.raw.split('#')[1] ?? link.raw
  const sanitizedHash = hash.replace(/^#/, '')
  const fileHeadings = validationData.get(id)?.headings

  if (!fileHeadings) {
    throw new Error(`Failed to find headings for the file at '${id}'.`)
  }

  if (!fileHeadings.includes(sanitizedHash)) {
    addIssue(context, ValidationErrorType.InvalidHash)
  }
}

/**
 * Check if a link is a valid asset in the build output directory.
 */
function isValidAsset(path: string, context: ValidationContext) {
  if (context.astroConfig.base !== '/') {
    const base = stripLeadingSlash(context.astroConfig.base)

    if (path.startsWith(base)) {
      path = path.replace(new RegExp(`^${stripLeadingSlash(base)}/?`), '')
    } else {
      return false
    }
  }

  try {
    const filePath = fileURLToPath(new URL(path, context.outputDir))
    const stats = statSync(filePath)

    return stats.isFile()
  } catch {
    return false
  }
}

/**
 * Check if a link is excluded from validation by the user.
 */
function isExcludedLink(link: Link, { id, options, validationData }: ValidationContext) {
  if (Array.isArray(options.exclude)) return picomatch(options.exclude)(stripQueryString(link.raw))

  const file = validationData.get(id)?.file
  if (!file) throw new Error('Missing file path to check exclusion.')

  return options.exclude({
    file,
    link: link.raw,
    slug: stripTrailingSlash(id),
  })
}

function stripQueryString(path: string): string {
  return path.split('?')[0] ?? path
}

function getDocsPath(filePath: string, srcDir: AstroConfig['srcDir']) {
  return relative(fileURLToPath(srcDir), filePath).split(sep).join(posix.sep).replace('content/docs/', '')
}

async function buildValidationReportFile(
  fileValidationIssues: ValidationFileIssues,
  astroConfig: AstroConfig,
): Promise<{ errorCount: number; file: ValidationReport['files'][number]; hasInvalidLinkToCustomPage: boolean }> {
  const issuesWithPositions = await Promise.all(
    fileValidationIssues.issues.map(async (issue) => ({
      issue,
      position: await getErrorPosition(issue.reference, fileValidationIssues.filePath),
    })),
  )

  const groupedIssues: ValidationReportIssue[] = []
  let errorCount = 0
  let hasInvalidLinkToCustomPage = false

  for (const { issue, position } of issuesWithPositions) {
    errorCount += 1
    hasInvalidLinkToCustomPage ||= issue.type === ValidationErrorType.InvalidLinkToCustomPage

    const previousIssue = groupedIssues.at(-1)

    if (
      previousIssue &&
      previousIssue.link === issue.link &&
      previousIssue.type === issue.type &&
      isSameLineSourcePosition(previousIssue.positions[0], position)
    ) {
      previousIssue.positions.push(position)
    } else {
      groupedIssues.push({
        documentationUrl: getValidationErrorDocumentationUrl(issue.type),
        link: issue.link,
        message: getValidationErrorMessage(issue.type, { site: astroConfig.site }),
        positions: [position],
        type: issue.type,
      })
    }
  }

  return {
    errorCount,
    file: {
      docsPath: getDocsPath(fileValidationIssues.filePath, astroConfig.srcDir),
      filePath: fileValidationIssues.filePath,
      issues: groupedIssues,
    },
    hasInvalidLinkToCustomPage,
  }
}

function addIssue({ file, id, issues, link }: ValidationContext, type: ValidationErrorType) {
  const reportFile: ValidationFileIssues = issues.get(id) ?? { filePath: file, issues: [] }
  reportFile.issues.push({ link: link.raw, reference: link.reference, type })

  issues.set(id, reportFile)
}

export type ValidationErrorType = keyof typeof validationErrorDefinitions

interface ValidationIssue {
  link: string
  reference: Reference
  type: ValidationErrorType
}

interface ValidationFileIssues {
  filePath: string
  issues: ValidationIssue[]
}

interface PageData {
  pathname: string
}

type Pages = Set<PageData['pathname']>

export type ProjectRoutes = Map<
  string,
  | {
      type: 'custom-page'
    }
  | {
      type: 'redirect-external'
    }
  | {
      type: 'redirect-internal'
      path: string
    }
>

interface ValidationContext {
  astroConfig: AstroConfig
  id: string
  file: string
  issues: Map<string, ValidationFileIssues>
  link: Link
  localeConfig: LocaleConfig | undefined
  options: StarlightLinksValidatorOptions
  outputDir: URL
  pages: Pages
  projectRoutes: ProjectRoutes
  validationData: ValidationData
}

export type StarlightUserConfig = Omit<StarlightUserConfigWithPlugins, 'plugins'>

interface ValidationErrorMessageContext {
  site: AstroConfig['site']
}
