package chromium

import (
	"bytes"
	"encoding/json"
	"errors"
	"fmt"
	"html/template"
	"net/http"
	"net/url"
	"os"
	"path/filepath"
	"strconv"
	"strings"
	"time"

	"github.com/dlclark/regexp2"
	"github.com/gomarkdown/markdown"
	"github.com/labstack/echo/v4"
	"github.com/microcosm-cc/bluemonday"

	"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
	"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
	"github.com/gotenberg/gotenberg/v8/pkg/modules/pdfengines"
)

var sameSiteRegexp = regexp2.MustCompile(
	`("sameSite"\s*:\s*")(?i:(lax|strict|none))(")`,
	regexp2.None,
)

// FormDataChromiumOptions creates [Options] from the form data.
//
// It falls back to the default value if the considered key is not present.
//
// JSON-encoded fields:
//   - failOnHttpStatusCodes: []int
//   - failOnResourceHttpStatusCodes: []int
//   - ignoreResourceHttpStatusDomains: []string
//   - cookies: []Cookie
//   - extraHttpHeaders: map[string]string
//   - emulatedMediaFeatures: map[string]string
//
// Domain filtering only applies to resource checks triggered by
// "failOnResourceHttpStatusCodes".
func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
	defaultOptions := DefaultOptions()

	var (
		skipNetworkIdleEvent            bool
		skipNetworkAlmostIdleEvent      bool
		failOnHttpStatusCodes           []int64
		failOnResourceHttpStatusCodes   []int64
		ignoreResourceHttpStatusDomains []string
		failOnResourceLoadingFailed     bool
		failOnConsoleExceptions         bool
		waitDelay                       time.Duration
		waitWindowStatus                string
		waitForExpression               string
		waitForSelector                 string
		cookies                         []Cookie
		userAgent                       string
		extraHttpHeaders                []ExtraHttpHeader
		emulatedMediaType               string
		emulatedMediaFeatures           []EmulatedMediaFeature
		omitBackground                  bool
	)

	form := ctx.FormData().
		Bool("skipNetworkIdleEvent", &skipNetworkIdleEvent, defaultOptions.SkipNetworkIdleEvent).
		Bool("skipNetworkAlmostIdleEvent", &skipNetworkAlmostIdleEvent, defaultOptions.SkipNetworkAlmostIdleEvent).
		Custom("failOnHttpStatusCodes", func(value string) error {
			if value == "" {
				failOnHttpStatusCodes = defaultOptions.FailOnHttpStatusCodes
				return nil
			}

			err := json.Unmarshal([]byte(value), &failOnHttpStatusCodes)
			if err != nil {
				return fmt.Errorf("unmarshal failOnHttpStatusCodes: %w", err)
			}

			return nil
		}).
		Custom("failOnResourceHttpStatusCodes", func(value string) error {
			if value == "" {
				failOnResourceHttpStatusCodes = defaultOptions.FailOnResourceHttpStatusCodes
				return nil
			}

			err := json.Unmarshal([]byte(value), &failOnResourceHttpStatusCodes)
			if err != nil {
				return fmt.Errorf("unmarshal failOnResourceHttpStatusCodes: %w", err)
			}

			return nil
		}).
		Custom("ignoreResourceHttpStatusDomains", func(value string) error {
			if value == "" {
				ignoreResourceHttpStatusDomains = defaultOptions.IgnoreResourceHttpStatusDomains
				return nil
			}

			err := json.Unmarshal([]byte(value), &ignoreResourceHttpStatusDomains)
			if err != nil {
				return fmt.Errorf("unmarshal ignoreResourceHttpStatusDomains: %w", err)
			}

			return nil
		}).
		Bool("failOnResourceLoadingFailed", &failOnResourceLoadingFailed, defaultOptions.FailOnResourceLoadingFailed).
		Bool("failOnConsoleExceptions", &failOnConsoleExceptions, defaultOptions.FailOnConsoleExceptions).
		Duration("waitDelay", &waitDelay, defaultOptions.WaitDelay).
		String("waitWindowStatus", &waitWindowStatus, defaultOptions.WaitWindowStatus).
		String("waitForExpression", &waitForExpression, defaultOptions.WaitForExpression).
		String("waitForSelector", &waitForSelector, defaultOptions.WaitForSelector).
		Custom("cookies", func(value string) error {
			if value == "" {
				cookies = defaultOptions.Cookies
				return nil
			}

			// sameSite attribute from cookies must accept case-insensitive
			// values.
			// See https://github.com/gotenberg/gotenberg/issues/1331.
			normalized, err := sameSiteRegexp.ReplaceFunc(value, func(m regexp2.Match) string {
				groups := m.Groups()
				provided := groups[2].String()
				var canon string
				switch strings.ToLower(provided) {
				case "lax":
					canon = "Lax"
				case "strict":
					canon = "Strict"
				case "none":
					canon = "None"
				default:
					canon = provided
				}
				return groups[1].String() + canon + groups[3].String()
			}, -1, -1)
			if err != nil {
				return fmt.Errorf("normalize sameSite from cookies: %w", err)
			}

			err = json.Unmarshal([]byte(normalized), &cookies)
			if err != nil {
				return fmt.Errorf("unmarshal cookies: %w", err)
			}

			for i, cookie := range cookies {
				if strings.TrimSpace(cookie.Name) == "" || strings.TrimSpace(cookie.Value) == "" || strings.TrimSpace(cookie.Domain) == "" {
					err = errors.Join(err, fmt.Errorf("cookie %d must have its name, value and domain set", i))
				}
			}

			return err
		}).
		String("userAgent", &userAgent, defaultOptions.UserAgent).
		Custom("extraHttpHeaders", func(value string) error {
			if value == "" {
				extraHttpHeaders = defaultOptions.ExtraHttpHeaders
				return nil
			}

			var headers map[string]string
			err := json.Unmarshal([]byte(value), &headers)
			if err != nil {
				return fmt.Errorf("unmarshal extraHttpHeaders: %w", err)
			}

			for k, v := range headers {
				var scope string
				var valueTokens []string
				var invalidScopeToken bool

				tokens := strings.SplitSeq(v, ";")
				for token := range tokens {
					if strings.HasPrefix(strings.ToLower(strings.TrimSpace(token)), "scope") {
						tokenNoSpaces := strings.Join(strings.Fields(token), "")
						parts := strings.SplitN(tokenNoSpaces, "=", 2)

						if len(parts) == 2 && strings.ToLower(parts[0]) == "scope" && parts[1] != "" {
							scope = parts[1]
						} else {
							err = errors.Join(err, fmt.Errorf("invalid scope '%s' for header '%s'", scope, k))
							invalidScopeToken = true
							break
						}
					} else if token != "" {
						valueTokens = append(valueTokens, token)
					}
				}

				if invalidScopeToken {
					continue
				}

				var scopeRegexp *regexp2.Regexp
				if len(scope) > 0 {
					p, errCompile := regexp2.Compile(scope, regexp2.None)
					if errCompile != nil {
						err = errors.Join(err, fmt.Errorf("invalid scope regex pattern for header '%s': %w", k, errCompile))
						continue
					}
					p.MatchTimeout = 5 * time.Second
					scopeRegexp = p
				}

				extraHttpHeaders = append(extraHttpHeaders, ExtraHttpHeader{
					Name:  k,
					Value: strings.Join(valueTokens, "; "),
					Scope: scopeRegexp,
				})
			}

			return err
		}).
		Custom("emulatedMediaType", func(value string) error {
			if value == "" {
				emulatedMediaType = defaultOptions.EmulatedMediaType
				return nil
			}

			if value != "screen" && value != "print" {
				return errors.New("wrong value, expected either 'screen', 'print' or empty")
			}

			emulatedMediaType = value

			return nil
		}).
		Custom("emulatedMediaFeatures", func(value string) error {
			if value == "" {
				emulatedMediaFeatures = defaultOptions.EmulatedMediaFeatures
				return nil
			}

			var features map[string]string
			err := json.Unmarshal([]byte(value), &features)
			if err != nil {
				return fmt.Errorf("unmarshal emulatedMediaFeatures: %w", err)
			}

			for k, v := range features {
				emulatedMediaFeatures = append(emulatedMediaFeatures, EmulatedMediaFeature{
					Name:  k,
					Value: v,
				})
			}

			return err
		}).
		Bool("omitBackground", &omitBackground, defaultOptions.OmitBackground)

	options := Options{
		SkipNetworkIdleEvent:            skipNetworkIdleEvent,
		SkipNetworkAlmostIdleEvent:      skipNetworkAlmostIdleEvent,
		FailOnHttpStatusCodes:           failOnHttpStatusCodes,
		FailOnResourceHttpStatusCodes:   failOnResourceHttpStatusCodes,
		IgnoreResourceHttpStatusDomains: ignoreResourceHttpStatusDomains,
		FailOnResourceLoadingFailed:     failOnResourceLoadingFailed,
		FailOnConsoleExceptions:         failOnConsoleExceptions,
		WaitDelay:                       waitDelay,
		WaitWindowStatus:                waitWindowStatus,
		WaitForExpression:               waitForExpression,
		WaitForSelector:                 waitForSelector,
		Cookies:                         cookies,
		UserAgent:                       userAgent,
		ExtraHttpHeaders:                extraHttpHeaders,
		EmulatedMediaType:               emulatedMediaType,
		EmulatedMediaFeatures:           emulatedMediaFeatures,
		OmitBackground:                  omitBackground,
	}

	return form, options
}

// FormDataChromiumPdfOptions creates [PdfOptions] from the form data. Fallback to
// the default value if the considered key is not present.
func FormDataChromiumPdfOptions(ctx *api.Context) (*api.FormData, PdfOptions) {
	form, options := FormDataChromiumOptions(ctx)
	defaultPdfOptions := DefaultPdfOptions()

	var (
		landscape, printBackground, singlePage           bool
		scale, paperWidth, paperHeight                   float64
		marginTop, marginBottom, marginLeft, marginRight float64
		pageRanges                                       string
		headerTemplate, footerTemplate                   string
		preferCssPageSize                                bool
		generateDocumentOutline                          bool
		generateTaggedPdf                                bool
	)

	form.
		Bool("landscape", &landscape, defaultPdfOptions.Landscape).
		Bool("printBackground", &printBackground, defaultPdfOptions.PrintBackground).
		Float64("scale", &scale, defaultPdfOptions.Scale).
		Bool("singlePage", &singlePage, defaultPdfOptions.SinglePage).
		Inches("paperWidth", &paperWidth, defaultPdfOptions.PaperWidth).
		Inches("paperHeight", &paperHeight, defaultPdfOptions.PaperHeight).
		Inches("marginTop", &marginTop, defaultPdfOptions.MarginTop).
		Inches("marginBottom", &marginBottom, defaultPdfOptions.MarginBottom).
		Inches("marginLeft", &marginLeft, defaultPdfOptions.MarginLeft).
		Inches("marginRight", &marginRight, defaultPdfOptions.MarginRight).
		String("nativePageRanges", &pageRanges, defaultPdfOptions.PageRanges).
		Content("header.html", &headerTemplate, defaultPdfOptions.HeaderTemplate).
		Content("footer.html", &footerTemplate, defaultPdfOptions.FooterTemplate).
		Bool("preferCssPageSize", &preferCssPageSize, defaultPdfOptions.PreferCssPageSize).
		Bool("generateDocumentOutline", &generateDocumentOutline, defaultPdfOptions.GenerateDocumentOutline).
		Bool("generateTaggedPdf", &generateTaggedPdf, defaultPdfOptions.GenerateTaggedPdf)

	pdfOptions := PdfOptions{
		Options:                 options,
		Landscape:               landscape,
		PrintBackground:         printBackground,
		Scale:                   scale,
		SinglePage:              singlePage,
		PaperWidth:              paperWidth,
		PaperHeight:             paperHeight,
		MarginTop:               marginTop,
		MarginBottom:            marginBottom,
		MarginLeft:              marginLeft,
		MarginRight:             marginRight,
		PageRanges:              pageRanges,
		HeaderTemplate:          headerTemplate,
		FooterTemplate:          footerTemplate,
		PreferCssPageSize:       preferCssPageSize,
		GenerateDocumentOutline: generateDocumentOutline,
		GenerateTaggedPdf:       generateTaggedPdf,
	}

	return form, pdfOptions
}

// FormDataChromiumScreenshotOptions creates [ScreenshotOptions] from the form
// data. Fallback to the default value if the considered key is not present.
func FormDataChromiumScreenshotOptions(ctx *api.Context) (*api.FormData, ScreenshotOptions) {
	form, options := FormDataChromiumOptions(ctx)
	defaultScreenshotOptions := DefaultScreenshotOptions()

	var (
		width, height     int
		clip              bool
		format            string
		quality           int
		optimizeForSpeed  bool
		deviceScaleFactor float64
	)

	form.
		Int("width", &width, defaultScreenshotOptions.Width).
		Int("height", &height, defaultScreenshotOptions.Height).
		Bool("clip", &clip, defaultScreenshotOptions.Clip).
		Custom("format", func(value string) error {
			if value == "" {
				format = defaultScreenshotOptions.Format
				return nil
			}

			if value != "png" && value != "jpeg" && value != "webp" {
				return fmt.Errorf("wrong value, expected either 'png', 'jpeg' or 'webp'")
			}

			format = value

			return nil
		}).
		Custom("quality", func(value string) error {
			if value == "" {
				quality = defaultScreenshotOptions.Quality
				return nil
			}

			intValue, err := strconv.Atoi(value)
			if err != nil {
				return err
			}

			if intValue < 0 {
				return errors.New("value is negative")
			}

			if intValue > 100 {
				return errors.New("value is superior to 100")
			}

			quality = intValue
			return nil
		}).
		Bool("optimizeForSpeed", &optimizeForSpeed, defaultScreenshotOptions.OptimizeForSpeed).
		Float64("deviceScaleFactor", &deviceScaleFactor, defaultScreenshotOptions.DeviceScaleFactor)

	screenshotOptions := ScreenshotOptions{
		Options:           options,
		Width:             width,
		Height:            height,
		Clip:              clip,
		Format:            format,
		Quality:           quality,
		OptimizeForSpeed:  optimizeForSpeed,
		DeviceScaleFactor: deviceScaleFactor,
	}

	return form, screenshotOptions
}

// rejectFileScheme returns an HTTP 400 [api] error when rawURL uses the
// file:// scheme. /forms/chromium/convert/url and
// /forms/chromium/screenshot/url accept user-supplied URLs and are
// intended for navigating to remote HTTP(S) resources; allowing file://
// lets a caller reach Chromium's working directory through the default
// deny-list's /tmp/ allowance, which exists only to serve main-page
// HTML/Markdown that the other routes generate. Filter the scheme at the
// route layer where no request-scoped allowedFilePrefixes exists.
func rejectFileScheme(rawURL string) error {
	parsed, err := url.Parse(rawURL)
	if err != nil {
		return api.WrapError(
			fmt.Errorf("parse URL: %w", err),
			api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Invalid URL: %s", err)),
		)
	}
	if strings.EqualFold(parsed.Scheme, "file") {
		return api.WrapError(
			fmt.Errorf("file:// scheme not allowed on URL route"),
			api.NewSentinelHttpError(
				http.StatusBadRequest,
				"file:// URLs are not accepted on this route. Use the /convert/html or /convert/markdown routes to render local HTML",
			),
		)
	}
	return nil
}

// convertUrlRoute returns an [api.Route] which can convert a URL to PDF.
func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
	return api.Route{
		Method:      http.MethodPost,
		Path:        "/forms/chromium/convert/url",
		IsMultipart: true,
		Handler: func(c echo.Context) error {
			ctx := c.Get("context").(*api.Context)
			form, options := FormDataChromiumPdfOptions(ctx)
			mode := pdfengines.FormDataPdfSplitMode(form, false)
			pdfFormats := pdfengines.FormDataPdfFormats(form)
			metadata := pdfengines.FormDataPdfMetadata(form, false)
			userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
			embedPaths := pdfengines.FormDataPdfEmbeds(form)
			watermark := pdfengines.FormDataPdfWatermark(form, false)
			watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
			stamp := pdfengines.FormDataPdfStamp(form, false)
			stampFile := pdfengines.FormDataPdfStampFile(form)
			rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
			embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)

			var url string
			err := form.
				MandatoryString("url", &url).
				Validate()
			if err != nil {
				return fmt.Errorf("validate form data: %w", err)
			}

			err = rejectFileScheme(url)
			if err != nil {
				return fmt.Errorf("reject URL scheme: %w", err)
			}

			err = pdfengines.EnsureWatermarkFile(&watermark, watermarkFile)
			if err != nil {
				return fmt.Errorf("validate watermark: %w", err)
			}
			err = pdfengines.EnsureStampFile(&stamp, stampFile)
			if err != nil {
				return fmt.Errorf("validate stamp: %w", err)
			}

			err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, embedsMetadata, watermark, stamp, rotateAngle, rotatePages)
			if err != nil {
				return fmt.Errorf("convert URL to PDF: %w", err)
			}

			return nil
		},
	}
}

// screenshotUrlRoute returns an [api.Route] which can take a screenshot from a
// URL.
func screenshotUrlRoute(chromium Api) api.Route {
	return api.Route{
		Method:      http.MethodPost,
		Path:        "/forms/chromium/screenshot/url",
		IsMultipart: true,
		Handler: func(c echo.Context) error {
			ctx := c.Get("context").(*api.Context)
			form, options := FormDataChromiumScreenshotOptions(ctx)

			var url string
			err := form.
				MandatoryString("url", &url).
				Validate()
			if err != nil {
				return fmt.Errorf("validate form data: %w", err)
			}

			err = rejectFileScheme(url)
			if err != nil {
				return fmt.Errorf("reject URL scheme: %w", err)
			}

			err = screenshotUrl(ctx, chromium, url, options)
			if err != nil {
				return fmt.Errorf("URL screenshot: %w", err)
			}

			return nil
		},
	}
}

// convertHtmlRoute returns an [api.Route] which can convert an HTML file to
// PDF.
func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
	return api.Route{
		Method:      http.MethodPost,
		Path:        "/forms/chromium/convert/html",
		IsMultipart: true,
		Handler: func(c echo.Context) error {
			ctx := c.Get("context").(*api.Context)
			form, options := FormDataChromiumPdfOptions(ctx)
			mode := pdfengines.FormDataPdfSplitMode(form, false)
			pdfFormats := pdfengines.FormDataPdfFormats(form)
			metadata := pdfengines.FormDataPdfMetadata(form, false)
			userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
			embedPaths := pdfengines.FormDataPdfEmbeds(form)
			watermark := pdfengines.FormDataPdfWatermark(form, false)
			watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
			stamp := pdfengines.FormDataPdfStamp(form, false)
			stampFile := pdfengines.FormDataPdfStampFile(form)
			rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
			embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)

			var inputPath string
			err := form.
				MandatoryPath("index.html", &inputPath).
				Validate()
			if err != nil {
				return fmt.Errorf("validate form data: %w", err)
			}

			err = pdfengines.EnsureWatermarkFile(&watermark, watermarkFile)
			if err != nil {
				return fmt.Errorf("validate watermark: %w", err)
			}
			err = pdfengines.EnsureStampFile(&stamp, stampFile)
			if err != nil {
				return fmt.Errorf("validate stamp: %w", err)
			}

			url := fmt.Sprintf("file://%s", inputPath)
			options.AllowedFilePrefixes = []string{ctx.DirPath()}
			err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, embedsMetadata, watermark, stamp, rotateAngle, rotatePages)
			if err != nil {
				return fmt.Errorf("convert HTML to PDF: %w", err)
			}

			return nil
		},
	}
}

// screenshotHtmlRoute returns an [api.Route] which can take a screenshot from
// an HTML file.
func screenshotHtmlRoute(chromium Api) api.Route {
	return api.Route{
		Method:      http.MethodPost,
		Path:        "/forms/chromium/screenshot/html",
		IsMultipart: true,
		Handler: func(c echo.Context) error {
			ctx := c.Get("context").(*api.Context)
			form, options := FormDataChromiumScreenshotOptions(ctx)

			var inputPath string
			err := form.
				MandatoryPath("index.html", &inputPath).
				Validate()
			if err != nil {
				return fmt.Errorf("validate form data: %w", err)
			}

			url := fmt.Sprintf("file://%s", inputPath)
			options.AllowedFilePrefixes = []string{ctx.DirPath()}
			err = screenshotUrl(ctx, chromium, url, options)
			if err != nil {
				return fmt.Errorf("HTML screenshot: %w", err)
			}

			return nil
		},
	}
}

// convertMarkdownRoute returns an [api.Route] which can convert markdown files
// to PDF.
func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
	return api.Route{
		Method:      http.MethodPost,
		Path:        "/forms/chromium/convert/markdown",
		IsMultipart: true,
		Handler: func(c echo.Context) error {
			ctx := c.Get("context").(*api.Context)
			form, options := FormDataChromiumPdfOptions(ctx)
			mode := pdfengines.FormDataPdfSplitMode(form, false)
			pdfFormats := pdfengines.FormDataPdfFormats(form)
			metadata := pdfengines.FormDataPdfMetadata(form, false)
			userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
			embedPaths := pdfengines.FormDataPdfEmbeds(form)
			watermark := pdfengines.FormDataPdfWatermark(form, false)
			watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
			stamp := pdfengines.FormDataPdfStamp(form, false)
			stampFile := pdfengines.FormDataPdfStampFile(form)
			rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
			embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)

			var (
				inputPath     string
				markdownPaths []string
			)

			err := form.
				MandatoryPath("index.html", &inputPath).
				MandatoryPaths([]string{".md"}, &markdownPaths).
				Validate()
			if err != nil {
				return fmt.Errorf("validate form data: %w", err)
			}

			err = pdfengines.EnsureWatermarkFile(&watermark, watermarkFile)
			if err != nil {
				return fmt.Errorf("validate watermark: %w", err)
			}
			err = pdfengines.EnsureStampFile(&stamp, stampFile)
			if err != nil {
				return fmt.Errorf("validate stamp: %w", err)
			}

			url, err := markdownToHtml(ctx, inputPath, markdownPaths)
			if err != nil {
				return fmt.Errorf("transform markdown file(s) to HTML: %w", err)
			}

			options.AllowedFilePrefixes = []string{ctx.DirPath()}
			err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, embedsMetadata, watermark, stamp, rotateAngle, rotatePages)
			if err != nil {
				return fmt.Errorf("convert markdown to PDF: %w", err)
			}

			return nil
		},
	}
}

// screenshotMarkdownRoute returns an [api.Route] which can take a screenshot
// from Markdown files.
func screenshotMarkdownRoute(chromium Api) api.Route {
	return api.Route{
		Method:      http.MethodPost,
		Path:        "/forms/chromium/screenshot/markdown",
		IsMultipart: true,
		Handler: func(c echo.Context) error {
			ctx := c.Get("context").(*api.Context)
			form, options := FormDataChromiumScreenshotOptions(ctx)

			var (
				inputPath     string
				markdownPaths []string
			)

			err := form.
				MandatoryPath("index.html", &inputPath).
				MandatoryPaths([]string{".md"}, &markdownPaths).
				Validate()
			if err != nil {
				return fmt.Errorf("validate form data: %w", err)
			}

			url, err := markdownToHtml(ctx, inputPath, markdownPaths)
			if err != nil {
				return fmt.Errorf("transform markdown file(s) to HTML: %w", err)
			}

			options.AllowedFilePrefixes = []string{ctx.DirPath()}
			err = screenshotUrl(ctx, chromium, url, options)
			if err != nil {
				return fmt.Errorf("markdown screenshot: %w", err)
			}

			return nil
		},
	}
}

func markdownToHtml(ctx *api.Context, inputPath string, markdownPaths []string) (string, error) {
	// We have to convert each Markdown file referenced in the HTML
	// file to... HTML. Thanks to the "html/template" package, we are
	// able to provide the "toHTML" function which the user may call
	// directly inside the HTML file.

	var markdownFilesNotFoundErr error

	tmpl, err := template.
		New(filepath.Base(inputPath)).
		Funcs(template.FuncMap{
			"toHTML": func(filename string) (template.HTML, error) {
				var path string

				for _, markdownPath := range markdownPaths {
					markdownFilename := ctx.OriginalFilename(markdownPath)

					if filename == markdownFilename {
						path = markdownPath
						break
					}
				}

				if path == "" {
					markdownFilesNotFoundErr = errors.Join(
						markdownFilesNotFoundErr,
						fmt.Errorf("'%s'", filename),
					)

					return "", nil
				}

				b, err := os.ReadFile(path)
				if err != nil {
					return "", fmt.Errorf("read markdown file '%s': %w", filename, err)
				}

				unsafe := markdown.ToHTML(b, nil, nil)
				sanitized := bluemonday.UGCPolicy().SanitizeBytes(unsafe)

				// #nosec
				return template.HTML(sanitized), nil
			},
		}).ParseFiles(inputPath)
	if err != nil {
		return "", fmt.Errorf("parse template file: %w", err)
	}

	var buffer bytes.Buffer

	err = tmpl.Execute(&buffer, &struct{}{})
	if err != nil {
		return "", fmt.Errorf("execute template: %w", err)
	}

	if markdownFilesNotFoundErr != nil {
		return "", api.WrapError(
			fmt.Errorf("markdown files not found: %w", markdownFilesNotFoundErr),
			api.NewSentinelHttpError(
				http.StatusBadRequest,
				fmt.Sprintf("Markdown file(s) not found: %s", markdownFilesNotFoundErr),
			),
		)
	}

	inputPath = ctx.GeneratePath(".html")

	err = os.WriteFile(inputPath, buffer.Bytes(), 0o600)
	if err != nil {
		return "", fmt.Errorf("write template result: %w", err)
	}

	return fmt.Sprintf("file://%s", inputPath), nil
}

func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, options PdfOptions, mode gotenberg.SplitMode, pdfFormats gotenberg.PdfFormats, metadata map[string]any, userPassword, ownerPassword string, embedPaths []string, embedsMetadata map[string]map[string]string, watermark, stamp gotenberg.Stamp, rotateAngle int, rotatePages string) error {
	outputPath := ctx.GeneratePath(".pdf")
	// See https://github.com/gotenberg/gotenberg/issues/1130.
	filename := ctx.OutputFilename(outputPath)
	outputPath = ctx.GeneratePathFromFilename(filename)

	err := chromium.Pdf(ctx, ctx.Log(), url, outputPath, options)
	err = handleChromiumError(err, options.Options)
	if err != nil {
		if errors.Is(err, ErrOmitBackgroundWithoutPrintBackground) {
			return api.WrapError(
				fmt.Errorf("convert to PDF: %w", err),
				api.NewSentinelHttpError(
					http.StatusBadRequest,
					"omitBackground requires printBackground set to true",
				),
			)
		}

		if errors.Is(err, ErrPrintingFailed) {
			return api.WrapError(
				fmt.Errorf("convert to PDF: %w", err),
				api.NewSentinelHttpError(
					http.StatusBadRequest,
					"Chromium failed to print the PDF; this usually happens when the page is too large",
				),
			)
		}

		if errors.Is(err, ErrInvalidPrinterSettings) {
			return api.WrapError(
				fmt.Errorf("convert to PDF: %w", err),
				api.NewSentinelHttpError(
					http.StatusBadRequest,
					"Chromium does not handle the provided settings; please check for aberrant form values",
				),
			)
		}

		if errors.Is(err, ErrPageRangesExceedsPageCount) {
			return api.WrapError(
				fmt.Errorf("convert to PDF: %w", err),
				api.NewSentinelHttpError(
					http.StatusBadRequest,
					fmt.Sprintf("The page ranges '%s' (nativePageRanges) exceeds the page count", options.PageRanges),
				),
			)
		}

		if errors.Is(err, ErrPageRangesSyntaxError) {
			return api.WrapError(
				fmt.Errorf("convert to PDF: %w", err),
				api.NewSentinelHttpError(
					http.StatusBadRequest,
					fmt.Sprintf("Chromium does not handle the page ranges '%s' (nativePageRanges) syntax", options.PageRanges),
				),
			)
		}

		return fmt.Errorf("convert to PDF: %w", err)
	}

	err = pdfengines.ValidatePdfFormatsCompat(pdfFormats, userPassword, embedPaths)
	if err != nil {
		return err
	}

	outputPaths, err := pdfengines.SplitPdfStub(ctx, engine, mode, []string{outputPath})
	if err != nil {
		return fmt.Errorf("split PDF: %w", err)
	}

	err = pdfengines.WatermarkStub(ctx, engine, watermark, outputPaths)
	if err != nil {
		return fmt.Errorf("watermark PDFs: %w", err)
	}

	err = pdfengines.StampStub(ctx, engine, stamp, outputPaths)
	if err != nil {
		return fmt.Errorf("stamp PDFs: %w", err)
	}

	err = pdfengines.RotateStub(ctx, engine, rotateAngle, rotatePages, outputPaths)
	if err != nil {
		return fmt.Errorf("rotate PDFs: %w", err)
	}

	convertOutputPaths, err := pdfengines.ConvertStub(ctx, engine, pdfFormats, outputPaths)
	if err != nil {
		return fmt.Errorf("convert PDF(s): %w", err)
	}

	// Metadata, embeds are written after Convert, as LibreOffice
	// strips them during PDF/A conversion.
	err = pdfengines.WriteMetadataStub(ctx, engine, metadata, convertOutputPaths)
	if err != nil {
		return fmt.Errorf("write metadata: %w", err)
	}

	err = pdfengines.EmbedFilesStub(ctx, engine, embedPaths, convertOutputPaths)
	if err != nil {
		return fmt.Errorf("embed files into PDFs: %w", err)
	}

	err = pdfengines.EmbedFilesMetadataStub(ctx, engine, embedsMetadata, convertOutputPaths)
	if err != nil {
		return fmt.Errorf("set embeds metadata: %w", err)
	}

	err = pdfengines.EncryptPdfStub(ctx, engine, userPassword, ownerPassword, convertOutputPaths)
	if err != nil {
		return fmt.Errorf("encrypt PDFs: %w", err)
	}

	zeroValuedSplitMode := gotenberg.SplitMode{}
	zeroValuedPdfFormats := gotenberg.PdfFormats{}
	if mode != zeroValuedSplitMode && pdfFormats != zeroValuedPdfFormats {
		// The PDF has been split and split parts have been converted to a
		// specific format. We want to keep the split naming.
		for i, convertOutputPath := range convertOutputPaths {
			err = ctx.Rename(convertOutputPath, outputPaths[i])
			if err != nil {
				return fmt.Errorf("rename output path: %w", err)
			}
		}
	} else {
		outputPaths = convertOutputPaths
	}

	err = ctx.AddOutputPaths(outputPaths...)
	if err != nil {
		return fmt.Errorf("add output paths: %w", err)
	}

	return nil
}

func screenshotUrl(ctx *api.Context, chromium Api, url string, options ScreenshotOptions) error {
	ext := fmt.Sprintf(".%s", options.Format)
	outputPath := ctx.GeneratePath(ext)

	err := chromium.Screenshot(ctx, ctx.Log(), url, outputPath, options)
	err = handleChromiumError(err, options.Options)
	if err != nil {
		return fmt.Errorf("screenshot: %w", err)
	}

	err = ctx.AddOutputPaths(outputPath)
	if err != nil {
		return fmt.Errorf("add output path: %w", err)
	}

	return nil
}

func handleChromiumError(err error, options Options) error {
	if err == nil {
		return nil
	}

	if errors.Is(err, ErrInvalidEvaluationExpression) {
		if options.WaitForExpression == "" {
			// We do not expect the 'waitWindowStatus' form field to return
			// an ErrInvalidEvaluationExpression error. In such a scenario,
			// we return a 500.
			return err
		}

		return api.WrapError(
			err,
			api.NewSentinelHttpError(
				http.StatusBadRequest,
				fmt.Sprintf("The expression '%s' (waitForExpression) returned an exception or undefined", options.WaitForExpression),
			),
		)
	}

	if errors.Is(err, ErrInvalidSelectorQuery) {
		if options.WaitForSelector == "" {
			// We only expect to see this error if the user specified a selector.
			// If they didn't and we still generated the error, return a 500.
			return err
		}

		return api.WrapError(
			err,
			api.NewSentinelHttpError(
				http.StatusBadRequest,
				fmt.Sprintf("The selector '%s' (waitForSelector) returned an exception or undefined", options.WaitForSelector),
			),
		)
	}

	if errors.Is(err, ErrInvalidHttpStatusCode) {
		return api.WrapError(
			err,
			api.NewSentinelHttpError(
				http.StatusConflict,
				fmt.Sprintf("Invalid HTTP status code from the main page: %s", strings.ReplaceAll(err.Error(), fmt.Sprintf(": %s", ErrInvalidHttpStatusCode.Error()), "")),
			),
		)
	}

	if errors.Is(err, ErrInvalidResourceHttpStatusCode) {
		return api.WrapError(
			err,
			api.NewSentinelHttpError(
				http.StatusConflict,
				fmt.Sprintf("Invalid HTTP status code from resources:\n%s", strings.ReplaceAll(err.Error(), fmt.Sprintf(": %s", ErrInvalidResourceHttpStatusCode.Error()), "")),
			),
		)
	}

	if errors.Is(err, ErrConsoleExceptions) {
		return api.WrapError(
			err,
			api.NewSentinelHttpError(
				http.StatusConflict,
				fmt.Sprintf("Chromium console exceptions:\n%s", strings.ReplaceAll(err.Error(), ErrConsoleExceptions.Error(), "")),
			),
		)
	}

	if errors.Is(err, ErrLoadingFailed) {
		return api.WrapError(
			err,
			api.NewSentinelHttpError(
				http.StatusBadRequest,
				fmt.Sprintf("Chromium returned %v", err),
			),
		)
	}

	if errors.Is(err, ErrResourceLoadingFailed) {
		return api.WrapError(
			err,
			api.NewSentinelHttpError(
				http.StatusConflict,
				fmt.Sprintf("Chromium failed to load resources: %v", strings.ReplaceAll(err.Error(), fmt.Sprintf(": %s", ErrResourceLoadingFailed.Error()), "")),
			),
		)
	}

	return err
}
