package main

import (
	"bytes"
	"embed"
	"fmt"
	"html/template"
	"io/fs"
	"net/url"
	"os"
	"os/signal"
	"path"
	"path/filepath"
	"strconv"
	"strings"
	"syscall"
	"time"

	"github.com/KorAP/Koral-Mapper/config"
	"github.com/KorAP/Koral-Mapper/mapper"
	"github.com/alecthomas/kong"
	"github.com/gofiber/fiber/v2"
	"github.com/gofiber/fiber/v2/middleware/cors"
	"github.com/gofiber/fiber/v2/middleware/limiter"
	"github.com/rs/zerolog"
	"github.com/rs/zerolog/log"
)

//go:embed static/*
var staticFS embed.FS

const (
	maxInputLength = 1024 * 1024 // 1MB
	maxParamLength = 1024        // 1KB
)

type appConfig struct {
	Port     *int     `kong:"short='p',help='Port to listen on'"`
	Config   string   `kong:"short='c',help='YAML configuration file containing mapping directives and global settings'"`
	Mappings []string `kong:"short='m',help='Individual YAML mapping files to load (supports glob patterns like dir/*.yaml)'"`
	LogLevel *string  `kong:"short='l',help='Log level (debug, info, warn, error)'"`
}

type BasePageData struct {
	Title       string
	Version     string
	Hash        string
	Date        string
	Description string
	Server      string
	SDK         string
	Stylesheet  string
	ServiceURL  string
	CookieName  string
}

type SingleMappingPageData struct {
	BasePageData
	MapID       string
	Mappings    []config.MappingList
	QueryURL    string
	ResponseURL string
}

type QueryParams struct {
	Dir      string
	FoundryA string
	FoundryB string
	LayerA   string
	LayerB   string
}

// requestParams holds common request parameters
type requestParams struct {
	MapID    string
	Dir      string
	FoundryA string
	FoundryB string
	LayerA   string
	LayerB   string
	Rewrites *bool // nil = use mapping list default; non-nil = override
}

// MappingSectionData contains per-section UI metadata so request and response
// rows can be rendered from one shared template block.
type MappingSectionData struct {
	Title           string
	Mode            string
	CheckboxClass   string
	CheckboxName    string
	FieldsClass     string
	ArrowClass      string
	ArrowDirection  string
	ArrowLabel      string
	AnnotationLabel string
}

// ConfigPageData holds all data passed to the configuration page template.
type ConfigPageData struct {
	BasePageData
	AnnotationMappings []config.MappingList
	CorpusMappings     []config.MappingList
	MappingSections    []MappingSectionData
}

func parseConfig() *appConfig {
	cfg := &appConfig{}

	desc := config.Description
	desc += " [" + config.Version + "]"

	ctx := kong.Parse(cfg,
		kong.Description(desc),
		kong.UsageOnError(),
	)
	if ctx.Error != nil {
		fmt.Fprintln(os.Stderr, ctx.Error)
		os.Exit(1)
	}
	return cfg
}

func setupLogger(level string) {
	// Parse log level
	lvl, err := zerolog.ParseLevel(strings.ToLower(level))
	if err != nil {
		log.Error().Err(err).Str("level", level).Msg("Invalid log level, defaulting to info")
		lvl = zerolog.InfoLevel
	}

	// Configure zerolog
	zerolog.SetGlobalLevel(lvl)
	log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
}

// setupFiberLogger configures fiber's logger middleware to integrate with zerolog
func setupFiberLogger() fiber.Handler {
	// Check if HTTP request logging should be enabled based on current log level
	currentLevel := zerolog.GlobalLevel()

	// Only enable HTTP request logging if log level is debug or info
	if currentLevel > zerolog.InfoLevel {
		return func(c *fiber.Ctx) error {
			return c.Next()
		}
	}

	return func(c *fiber.Ctx) error {
		// Record start time
		start := time.Now()

		// Process request
		err := c.Next()

		// Calculate latency
		latency := time.Since(start)
		status := c.Response().StatusCode()

		// Determine log level based on status code
		logEvent := log.Info()
		if status >= 400 && status < 500 {
			logEvent = log.Warn()
		} else if status >= 500 {
			logEvent = log.Error()
		}

		// Log the request
		logEvent.
			Int("status", status).
			Dur("latency", latency).
			Str("method", c.Method()).
			Str("path", c.Path()).
			Str("ip", c.IP()).
			Str("user_agent", c.Get("User-Agent")).
			Msg("HTTP request")

		return err
	}
}

// extractRequestParams extracts and validates common request parameters
func extractRequestParams(c *fiber.Ctx) (*requestParams, error) {
	params := &requestParams{
		MapID:    c.Params("map"),
		Dir:      c.Query("dir", "atob"),
		FoundryA: c.Query("foundryA", ""),
		FoundryB: c.Query("foundryB", ""),
		LayerA:   c.Query("layerA", ""),
		LayerB:   c.Query("layerB", ""),
	}

	if rewrites := c.Query("rewrites", ""); rewrites != "" {
		v := rewrites == "true"
		params.Rewrites = &v
	}

	// Validate input parameters
	if err := validateInput(params.MapID, params.Dir, params.FoundryA, params.FoundryB, params.LayerA, params.LayerB, c.Body()); err != nil {
		return nil, err
	}

	// Validate direction
	if params.Dir != "atob" && params.Dir != "btoa" {
		return nil, fmt.Errorf("invalid direction, must be 'atob' or 'btoa'")
	}

	return params, nil
}

// parseRequestBody parses JSON request body and direction
func parseRequestBody(c *fiber.Ctx, dir string) (any, mapper.Direction, error) {
	var jsonData any
	if err := c.BodyParser(&jsonData); err != nil {
		return nil, mapper.BtoA, fmt.Errorf("invalid JSON in request body")
	}

	direction, err := mapper.ParseDirection(dir)
	if err != nil {
		return nil, mapper.BtoA, err
	}

	return jsonData, direction, nil
}

func main() {
	// Confine config file loading to the current working directory tree
	// (path traversal prevention). Can be overridden via the "basePath"
	// YAML field or the KORAL_MAPPER_BASE_PATH environment variable.
	// In Docker (WORKDIR /), the default "/" naturally allows all paths.
	cwd, err := os.Getwd()
	if err != nil {
		log.Fatal().Err(err).Msg("Failed to determine working directory")
	}
	config.AllowedBasePath = cwd

	// Parse command line flags
	cfg := parseConfig()

	// Validate command line arguments
	if cfg.Config == "" && len(cfg.Mappings) == 0 {
		log.Fatal().Msg("At least one configuration source must be provided: use -c for main config file or -m for mapping files")
	}

	// Expand glob patterns in mapping files
	expandedMappings, err := expandGlobs(cfg.Mappings)
	if err != nil {
		log.Fatal().Err(err).Msg("Failed to expand glob patterns in mapping files")
	}

	// Load configuration from multiple sources
	yamlConfig, err := config.LoadFromSources(cfg.Config, expandedMappings)
	if err != nil {
		log.Fatal().Err(err).Msg("Failed to load configuration")
	}

	// Apply basePath from config/env if specified (overrides CWD default)
	if yamlConfig.BasePath != "" {
		config.AllowedBasePath = yamlConfig.BasePath
	}

	finalPort := yamlConfig.Port
	finalLogLevel := yamlConfig.LogLevel

	// Use command line values if provided (they override config file)
	if cfg.Port != nil {
		finalPort = *cfg.Port
	}
	if cfg.LogLevel != nil {
		finalLogLevel = *cfg.LogLevel
	}

	// Set up logging with the final log level
	setupLogger(finalLogLevel)

	// Create a new mapper instance
	m, err := mapper.NewMapper(yamlConfig.Lists)
	if err != nil {
		log.Fatal().Err(err).Msg("Failed to create mapper")
	}

	// Create fiber app
	app := fiber.New(fiber.Config{
		DisableStartupMessage: true,
		BodyLimit:             maxInputLength,
		ReadBufferSize:        64 * 1024, // 64KB - increase header size limit
		WriteBufferSize:       64 * 1024, // 64KB - increase response buffer size
	})

	// Add zerolog-integrated logger middleware
	app.Use(setupFiberLogger())

	// Set up routes
	setupRoutes(app, m, yamlConfig)

	// Start server
	go func() {
		log.Info().Int("port", finalPort).Msg("Starting server")
		fmt.Printf("Starting server port=%d\n", finalPort)

		for _, list := range yamlConfig.Lists {
			log.Info().Str("id", list.ID).Str("desc", list.Description).Msg("Loaded mapping")
			fmt.Printf("Loaded mapping desc=%s id=%s\n",
				formatConsoleField(list.Description),
				list.ID,
			)
		}

		if err := app.Listen(fmt.Sprintf(":%d", finalPort)); err != nil {
			log.Fatal().Err(err).Msg("Server error")
		}
	}()

	// Wait for interrupt signal
	sigChan := make(chan os.Signal, 1)
	signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
	<-sigChan

	// Graceful shutdown
	log.Info().Msg("Shutting down server")
	if err := app.Shutdown(); err != nil {
		log.Error().Err(err).Msg("Error during shutdown")
	}
}

func setupRoutes(app *fiber.App, m *mapper.Mapper, yamlConfig *config.MappingConfig) {
	configTmpl := template.Must(template.ParseFS(staticFS, "static/config.html"))
	pluginTmpl := template.Must(template.ParseFS(staticFS, "static/plugin.html"))

	// Security headers middleware to mitigate MIME-sniffing and referrer
	// information leaks (OWASP Secure Headers). X-Frame-Options is
	// intentionally omitted because the service is designed to be embedded
	// in cross-origin iframes (Kalamar plugin).
	app.Use(func(c *fiber.Ctx) error {
		c.Set("X-Content-Type-Options", "nosniff")
		c.Set("Referrer-Policy", "strict-origin-when-cross-origin")
		return c.Next()
	})

	// CORS middleware to allow cross-origin requests from trusted
	// origins. Required because the service is designed to be
	// called as a KorAP/Kalamar plugin from cross-origin iframes.
	// Configurable via the "allowOrigins" YAML key or the
	// KORAL_MAPPER_ALLOW_ORIGINS environment variable
	// (default: "https://korap.ids-mannheim.de").
	app.Use(cors.New(cors.Config{
		AllowOrigins: yamlConfig.AllowOrigins,
		AllowMethods: "GET,POST",
		AllowHeaders: "Content-Type",
	}))

	// Rate limiting middleware to prevent resource exhaustion from
	// request floods. The maximum number of requests per minute
	// per IP is configurable via the "rateLimit" YAML key or the
	// KORAL_MAPPER_RATE_LIMIT environment variable (default: 100).
	rateLimit := yamlConfig.RateLimit
	if rateLimit <= 0 {
		rateLimit = 100
	}
	app.Use(limiter.New(limiter.Config{
		Max:               rateLimit,
		Expiration:        1 * time.Minute,
		LimiterMiddleware: limiter.SlidingWindow{},
	}))

	// Health check endpoint
	app.Get("/health", func(c *fiber.Ctx) error {
		return c.SendString("OK")
	})

	// Static file serving from embedded FS
	app.Get("/static/*", handleStaticFile())

	// Composite cascade transformation endpoints (cfg in path)
	app.Post("/query/:cfg", handleCompositeQueryTransform(m, yamlConfig))
	app.Post("/response/:cfg", handleCompositeResponseTransform(m, yamlConfig))

	// Transformation endpoint
	app.Post("/:map/query", handleTransform(m, yamlConfig))

	// Response transformation endpoint
	app.Post("/:map/response", handleResponseTransform(m, yamlConfig))

	// Kalamar plugin endpoint
	app.Get("/", handleKalamarPlugin(yamlConfig, configTmpl, pluginTmpl))
	app.Get("/:map", handleKalamarPlugin(yamlConfig, configTmpl, pluginTmpl))
}

func handleStaticFile() fiber.Handler {
	return func(c *fiber.Ctx) error {
		name := c.Params("*")
		data, err := fs.ReadFile(staticFS, "static/"+name)
		if err != nil {
			return c.Status(fiber.StatusNotFound).SendString("not found")
		}
		switch {
		case strings.HasSuffix(name, ".js"):
			c.Set("Content-Type", "text/javascript; charset=utf-8")
		case strings.HasSuffix(name, ".css"):
			c.Set("Content-Type", "text/css; charset=utf-8")
		case strings.HasSuffix(name, ".html"):
			c.Set("Content-Type", "text/html; charset=utf-8")
		}
		return c.Send(data)
	}
}

func buildBasePageData(yamlConfig *config.MappingConfig) BasePageData {
	return BasePageData{
		Title:       config.Title,
		Version:     config.Version,
		Hash:        config.Buildhash,
		Date:        config.Buildtime,
		Description: config.Description,
		Server:      yamlConfig.Server,
		SDK:         yamlConfig.SDK,
		Stylesheet:  yamlConfig.Stylesheet,
		ServiceURL:  yamlConfig.ServiceURL,
		CookieName:  yamlConfig.CookieName,
	}
}

func buildConfigPageData(yamlConfig *config.MappingConfig) ConfigPageData {
	data := ConfigPageData{
		BasePageData: buildBasePageData(yamlConfig),
	}

	for _, list := range yamlConfig.Lists {
		normalized := list
		if normalized.Type == "" {
			normalized.Type = "annotation"
		}
		if list.IsCorpus() {
			data.CorpusMappings = append(data.CorpusMappings, normalized)
		} else {
			data.AnnotationMappings = append(data.AnnotationMappings, normalized)
		}
	}

	data.MappingSections = []MappingSectionData{
		{
			Title:          "Request",
			Mode:           "request",
			CheckboxClass:  "request-cb",
			CheckboxName:   "request",
			FieldsClass:    "request-fields",
			ArrowClass:     "request-dir-arrow",
			ArrowDirection: "atob",
			ArrowLabel:     "\u2192",
		},
		{
			Title:          "Response",
			Mode:           "response",
			CheckboxClass:  "response-cb",
			CheckboxName:   "response",
			FieldsClass:    "response-fields",
			ArrowClass:     "response-dir-arrow",
			ArrowDirection: "btoa",
			ArrowLabel:     "\u2190",
		},
	}

	return data
}

func handleCompositeQueryTransform(m *mapper.Mapper, yamlConfig *config.MappingConfig) fiber.Handler {
	listsByID := make(map[string]*config.MappingList, len(yamlConfig.Lists))
	for i := range yamlConfig.Lists {
		listsByID[yamlConfig.Lists[i].ID] = &yamlConfig.Lists[i]
	}

	return func(c *fiber.Ctx) error {
		cfgRaw := c.Params("cfg")
		if len(cfgRaw) > maxParamLength {
			return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
				"error": fmt.Sprintf("cfg too long (max %d bytes)", maxParamLength),
			})
		}

		var jsonData any
		if err := c.BodyParser(&jsonData); err != nil {
			return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
				"error": "invalid JSON in request body",
			})
		}

		entries, err := ParseCfgParam(cfgRaw, yamlConfig.Lists)
		if err != nil {
			return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
				"error": err.Error(),
			})
		}

		if len(entries) == 0 {
			return c.JSON(jsonData)
		}

		rewrites := c.Query("rewrites", "")
		var rewritesOverride *bool
		if rewrites != "" {
			v := rewrites == "true"
			rewritesOverride = &v
		}

		orderedIDs := make([]string, 0, len(entries))
		opts := make([]mapper.MappingOptions, 0, len(entries))
		for _, entry := range entries {
			dir := mapper.AtoB
			if entry.Direction == "btoa" {
				dir = mapper.BtoA
			}

			addRewrites := yamlConfig.Rewrites
			if list, ok := listsByID[entry.ID]; ok {
				addRewrites = list.EffectiveRewrites(yamlConfig.Rewrites)
			}
			if rewritesOverride != nil {
				addRewrites = *rewritesOverride
			}

			orderedIDs = append(orderedIDs, entry.ID)
			opts = append(opts, mapper.MappingOptions{
				Direction:   dir,
				FoundryA:    entry.FoundryA,
				LayerA:      entry.LayerA,
				FoundryB:    entry.FoundryB,
				LayerB:      entry.LayerB,
				FieldA:      entry.FieldA,
				FieldB:      entry.FieldB,
				AddRewrites: addRewrites,
			})
		}

		result, err := m.CascadeQueryMappings(orderedIDs, opts, jsonData)
		if err != nil {
			log.Error().Err(err).Str("cfg", cfgRaw).Msg("Failed to apply composite query mappings")
			return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
				"error": err.Error(),
			})
		}

		return c.JSON(result)
	}
}

func handleCompositeResponseTransform(m *mapper.Mapper, yamlConfig *config.MappingConfig) fiber.Handler {
	listsByID := make(map[string]*config.MappingList, len(yamlConfig.Lists))
	for i := range yamlConfig.Lists {
		listsByID[yamlConfig.Lists[i].ID] = &yamlConfig.Lists[i]
	}

	return func(c *fiber.Ctx) error {
		cfgRaw := c.Params("cfg")
		if len(cfgRaw) > maxParamLength {
			return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
				"error": fmt.Sprintf("cfg too long (max %d bytes)", maxParamLength),
			})
		}

		var jsonData any
		if err := c.BodyParser(&jsonData); err != nil {
			return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
				"error": "invalid JSON in request body",
			})
		}

		entries, err := ParseCfgParam(cfgRaw, yamlConfig.Lists)
		if err != nil {
			return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
				"error": err.Error(),
			})
		}

		if len(entries) == 0 {
			return c.JSON(jsonData)
		}

		rewrites := c.Query("rewrites", "")
		var rewritesOverride *bool
		if rewrites != "" {
			v := rewrites == "true"
			rewritesOverride = &v
		}

		orderedIDs := make([]string, 0, len(entries))
		opts := make([]mapper.MappingOptions, 0, len(entries))
		for _, entry := range entries {
			dir := mapper.AtoB
			if entry.Direction == "btoa" {
				dir = mapper.BtoA
			}

			addRewrites := yamlConfig.Rewrites
			if list, ok := listsByID[entry.ID]; ok {
				addRewrites = list.EffectiveRewrites(yamlConfig.Rewrites)
			}
			if rewritesOverride != nil {
				addRewrites = *rewritesOverride
			}

			orderedIDs = append(orderedIDs, entry.ID)
			opts = append(opts, mapper.MappingOptions{
				Direction:   dir,
				FoundryA:    entry.FoundryA,
				LayerA:      entry.LayerA,
				FoundryB:    entry.FoundryB,
				LayerB:      entry.LayerB,
				FieldA:      entry.FieldA,
				FieldB:      entry.FieldB,
				AddRewrites: addRewrites,
			})
		}

		result, err := m.CascadeResponseMappings(orderedIDs, opts, jsonData)
		if err != nil {
			log.Error().Err(err).Str("cfg", cfgRaw).Msg("Failed to apply composite response mappings")
			return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
				"error": err.Error(),
			})
		}

		return c.JSON(result)
	}
}

func handleTransform(m *mapper.Mapper, yamlConfig *config.MappingConfig) fiber.Handler {
	listsByID := make(map[string]*config.MappingList, len(yamlConfig.Lists))
	for i := range yamlConfig.Lists {
		listsByID[yamlConfig.Lists[i].ID] = &yamlConfig.Lists[i]
	}

	return func(c *fiber.Ctx) error {
		// Extract and validate parameters
		params, err := extractRequestParams(c)
		if err != nil {
			return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
				"error": err.Error(),
			})
		}

		// Parse request body
		jsonData, direction, err := parseRequestBody(c, params.Dir)
		if err != nil {
			return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
				"error": err.Error(),
			})
		}

		// Resolve rewrites: global default -> per-list -> query param
		addRewrites := yamlConfig.Rewrites
		if list, ok := listsByID[params.MapID]; ok {
			addRewrites = list.EffectiveRewrites(yamlConfig.Rewrites)
		}
		if params.Rewrites != nil {
			addRewrites = *params.Rewrites
		}

		// Apply mappings
		result, err := m.ApplyQueryMappings(params.MapID, mapper.MappingOptions{
			Direction:   direction,
			FoundryA:    params.FoundryA,
			FoundryB:    params.FoundryB,
			LayerA:      params.LayerA,
			LayerB:      params.LayerB,
			AddRewrites: addRewrites,
		}, jsonData)

		if err != nil {
			log.Error().Err(err).
				Str("mapID", params.MapID).
				Str("direction", params.Dir).
				Msg("Failed to apply mappings")

			return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
				"error": err.Error(),
			})
		}

		return c.JSON(result)
	}
}

func handleResponseTransform(m *mapper.Mapper, yamlConfig *config.MappingConfig) fiber.Handler {
	listsByID := make(map[string]*config.MappingList, len(yamlConfig.Lists))
	for i := range yamlConfig.Lists {
		listsByID[yamlConfig.Lists[i].ID] = &yamlConfig.Lists[i]
	}

	return func(c *fiber.Ctx) error {
		// Extract and validate parameters
		params, err := extractRequestParams(c)
		if err != nil {
			return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
				"error": err.Error(),
			})
		}

		// Parse request body
		jsonData, direction, err := parseRequestBody(c, params.Dir)
		if err != nil {
			return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
				"error": err.Error(),
			})
		}

		// Resolve rewrites: global default -> per-list -> query param
		addRewrites := yamlConfig.Rewrites
		if list, ok := listsByID[params.MapID]; ok {
			addRewrites = list.EffectiveRewrites(yamlConfig.Rewrites)
		}
		if params.Rewrites != nil {
			addRewrites = *params.Rewrites
		}

		// Apply response mappings
		result, err := m.ApplyResponseMappings(params.MapID, mapper.MappingOptions{
			Direction:   direction,
			FoundryA:    params.FoundryA,
			FoundryB:    params.FoundryB,
			LayerA:      params.LayerA,
			LayerB:      params.LayerB,
			AddRewrites: addRewrites,
		}, jsonData)

		if err != nil {
			log.Error().Err(err).
				Str("mapID", params.MapID).
				Str("direction", params.Dir).
				Msg("Failed to apply response mappings")

			return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
				"error": err.Error(),
			})
		}

		return c.JSON(result)
	}
}

// validateInput checks if the input parameters are valid
func validateInput(mapID, dir, foundryA, foundryB, layerA, layerB string, body []byte) error {
	// Define parameter checks
	params := []struct {
		name  string
		value string
	}{
		{"mapID", mapID},
		{"dir", dir},
		{"foundryA", foundryA},
		{"foundryB", foundryB},
		{"layerA", layerA},
		{"layerB", layerB},
	}

	for _, param := range params {
		// Check input lengths and invalid characters in one combined condition
		if len(param.value) > maxParamLength {
			return fmt.Errorf("%s too long (max %d bytes)", param.name, maxParamLength)
		}
		if strings.ContainsAny(param.value, "<>{}[]\\") {
			return fmt.Errorf("%s contains invalid characters", param.name)
		}
	}

	if len(body) > maxInputLength {
		return fmt.Errorf("request body too large (max %d bytes)", maxInputLength)
	}

	return nil
}

func handleKalamarPlugin(yamlConfig *config.MappingConfig, configTmpl *template.Template, pluginTmpl *template.Template) fiber.Handler {
	return func(c *fiber.Ctx) error {
		mapID := c.Params("map")

		// Config page (GET /)
		if mapID == "" {
			data := buildConfigPageData(yamlConfig)
			var buf bytes.Buffer
			if err := configTmpl.Execute(&buf, data); err != nil {
				log.Error().Err(err).Msg("Failed to execute config template")
				return c.Status(fiber.StatusInternalServerError).SendString("internal error")
			}
			c.Set("Content-Type", "text/html")
			return c.Send(buf.Bytes())
		}

		// Single-mapping page (GET /:map) — existing behavior
		// Get query parameters
		dir := c.Query("dir", "atob")
		foundryA := c.Query("foundryA", "")
		foundryB := c.Query("foundryB", "")
		layerA := c.Query("layerA", "")
		layerB := c.Query("layerB", "")

		// Validate input parameters and direction in one step
		if err := validateInput(mapID, dir, foundryA, foundryB, layerA, layerB, []byte{}); err != nil {
			return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
				"error": err.Error(),
			})
		}

		if dir != "atob" && dir != "btoa" {
			return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
				"error": "invalid direction, must be 'atob' or 'btoa'",
			})
		}

		queryParams := QueryParams{
			Dir:      dir,
			FoundryA: foundryA,
			FoundryB: foundryB,
			LayerA:   layerA,
			LayerB:   layerB,
		}

		queryURL, err := buildMapServiceURL(yamlConfig.ServiceURL, mapID, "query", queryParams)
		if err != nil {
			log.Warn().Err(err).Msg("Failed to build query service URL")
			return c.Status(fiber.StatusInternalServerError).SendString("internal error")
		}
		reversed := queryParams
		if queryParams.Dir == "btoa" {
			reversed.Dir = "atob"
		} else {
			reversed.Dir = "btoa"
		}
		responseURL, err := buildMapServiceURL(yamlConfig.ServiceURL, mapID, "response", reversed)
		if err != nil {
			log.Warn().Err(err).Msg("Failed to build response service URL")
			return c.Status(fiber.StatusInternalServerError).SendString("internal error")
		}

		data := SingleMappingPageData{
			BasePageData: buildBasePageData(yamlConfig),
			MapID:        mapID,
			Mappings:     yamlConfig.Lists,
			QueryURL:     queryURL,
			ResponseURL:  responseURL,
		}

		var buf bytes.Buffer
		if err := pluginTmpl.Execute(&buf, data); err != nil {
			log.Error().Err(err).Msg("Failed to execute plugin template")
			return c.Status(fiber.StatusInternalServerError).SendString("internal error")
		}
		c.Set("Content-Type", "text/html")
		return c.Send(buf.Bytes())
	}
}

func buildMapServiceURL(serviceURL, mapID, endpoint string, params QueryParams) (string, error) {
	service, err := url.Parse(serviceURL)
	if err != nil {
		return "", err
	}
	service.Path = path.Join(service.Path, mapID, endpoint)
	service.RawQuery = buildQueryParams(params.Dir, params.FoundryA, params.FoundryB, params.LayerA, params.LayerB)
	return service.String(), nil
}

func formatConsoleField(value string) string {
	if strings.ContainsAny(value, " \t") {
		return strconv.Quote(value)
	}
	return value
}

// buildQueryParams builds a query string from the provided parameters
func buildQueryParams(dir, foundryA, foundryB, layerA, layerB string) string {
	params := url.Values{}
	if dir != "" {
		params.Add("dir", dir)
	}
	if foundryA != "" {
		params.Add("foundryA", foundryA)
	}
	if foundryB != "" {
		params.Add("foundryB", foundryB)
	}
	if layerA != "" {
		params.Add("layerA", layerA)
	}
	if layerB != "" {
		params.Add("layerB", layerB)
	}
	return params.Encode()
}

// expandGlobs expands glob patterns in the slice of file paths
// Returns the expanded list of files or an error if glob expansion fails
func expandGlobs(patterns []string) ([]string, error) {
	var expanded []string

	for _, pattern := range patterns {
		// Use filepath.Glob which works cross-platform
		matches, err := filepath.Glob(pattern)
		if err != nil {
			return nil, fmt.Errorf("failed to expand glob pattern '%s': %w", pattern, err)
		}

		// If no matches found, treat as literal filename (consistent with shell behavior)
		if len(matches) == 0 {
			log.Warn().Str("pattern", pattern).Msg("Glob pattern matched no files, treating as literal filename")
			expanded = append(expanded, pattern)
		} else {
			expanded = append(expanded, matches...)
		}
	}

	return expanded, nil
}
