package serve

import (
	"context"
	"errors"
	"fmt"
	"net/http"
	"net/url"
	"strings"
	"time"

	"github.com/urfave/cli/v3"

	"gh.tarampamp.am/error-pages/internal/cli/shared"
	"gh.tarampamp.am/error-pages/internal/config"
	appHttp "gh.tarampamp.am/error-pages/internal/http"
	"gh.tarampamp.am/error-pages/internal/logger"
)

type command struct {
	c *cli.Command

	opt struct {
		http struct { // our HTTP server
			addr           string
			port           uint16
			readBufferSize uint
		}
	}
}

// NewCommand creates `serve` command.
func NewCommand(log *logger.Logger) *cli.Command { //nolint:funlen,gocognit,gocyclo
	var (
		cmd       command
		cfg       = config.New()
		env, trim = cli.EnvVars, cli.StringConfig{TrimSpace: true}
	)

	var (
		addrFlag                = shared.ListenAddrFlag
		portFlag                = shared.ListenPortFlag
		addTplFlag              = shared.AddTemplatesFlag
		disableTplFlag          = shared.DisableTemplateNamesFlag
		addCodeFlag             = shared.AddHTTPCodesFlag
		disableL10nFlag         = shared.DisableL10nFlag
		disableMinificationFlag = shared.DisableMinificationFlag
		jsonFormatFlag          = cli.StringFlag{
			Name: "json-format",
			Usage: "Override the default error page response in JSON format (Go templates are supported; the error " +
				"page will use this template if the client requests JSON content type)",
			Sources:  env("RESPONSE_JSON_FORMAT"),
			Category: shared.CategoryFormats,
			OnlyOnce: true,
			Config:   trim,
		}
		xmlFormatFlag = cli.StringFlag{
			Name: "xml-format",
			Usage: "Override the default error page response in XML format (Go templates are supported; the error " +
				"page will use this template if the client requests XML content type)",
			Sources:  env("RESPONSE_XML_FORMAT"),
			Category: shared.CategoryFormats,
			OnlyOnce: true,
			Config:   trim,
		}
		plainTextFormatFlag = cli.StringFlag{
			Name: "plaintext-format",
			Usage: "Override the default error page response in plain text format (Go templates are supported; the " +
				"error page will use this template if the client requests plain text content type or does not specify any)",
			Sources:  env("RESPONSE_PLAINTEXT_FORMAT"),
			Category: shared.CategoryFormats,
			OnlyOnce: true,
			Config:   trim,
		}
		templateNameFlag = cli.StringFlag{
			Name:    "template-name",
			Aliases: []string{"t"},
			Value:   cfg.TemplateName,
			Usage: "Name of the template to use for rendering error pages (built-in templates: " +
				strings.Join(cfg.Templates.Names(), ", ") + ")",
			Sources:  env("TEMPLATE_NAME"),
			Category: shared.CategoryTemplates,
			OnlyOnce: true,
			Config:   trim,
		}
		defaultCodeToRenderFlag = cli.UintFlag{
			Name:     "default-error-page",
			Usage:    "The code of the default (index page, when a code is not specified) error page to render",
			Value:    uint64(cfg.DefaultCodeToRender),
			Sources:  env("DEFAULT_ERROR_PAGE"),
			Category: shared.CategoryCodes,
			Validator: func(code uint64) error {
				if code > 999 { //nolint:mnd
					return fmt.Errorf("wrong HTTP code [%d] for the default error page", code)
				}

				return nil
			},
			OnlyOnce: true,
		}
		sendSameHTTPCodeFlag = cli.BoolFlag{
			Name: "send-same-http-code",
			Usage: "The HTTP response should have the same status code as the requested error page (by default, " +
				"every response with an error page will have a status code of 200)",
			Value:    cfg.RespondWithSameHTTPCode,
			Sources:  env("SEND_SAME_HTTP_CODE"),
			Category: shared.CategoryOther,
			OnlyOnce: true,
		}
		showDetailsFlag = cli.BoolFlag{
			Name:     "show-details",
			Usage:    "Show request details in the error page response (if supported by the template)",
			Value:    cfg.ShowDetails,
			Sources:  env("SHOW_DETAILS"),
			Category: shared.CategoryOther,
			OnlyOnce: true,
		}
		proxyHeadersListFlag = cli.StringFlag{
			Name: "proxy-headers",
			Usage: "HTTP headers listed here will be proxied from the original request to the error page response " +
				"(comma-separated list)",
			Value:   strings.Join(cfg.ProxyHeaders, ","),
			Sources: env("PROXY_HTTP_HEADERS"),
			Validator: func(s string) error {
				for _, raw := range strings.Split(s, ",") {
					if clean := strings.TrimSpace(raw); strings.ContainsRune(clean, ' ') {
						return fmt.Errorf("whitespaces in the HTTP headers are not allowed: %s", clean)
					}
				}

				return nil
			},
			Category: shared.CategoryOther,
			OnlyOnce: true,
			Config:   trim,
		}
		rotationModeFlag = cli.StringFlag{
			Name:     "rotation-mode",
			Value:    config.RotationModeDisabled.String(),
			Usage:    "Templates automatic rotation mode (" + strings.Join(config.RotationModeStrings(), "/") + ")",
			Sources:  env("TEMPLATES_ROTATION_MODE"),
			Category: shared.CategoryTemplates,
			OnlyOnce: true,
			Config:   trim,
			Validator: func(s string) error {
				if _, err := config.ParseRotationMode(s); err != nil {
					return err
				}

				return nil
			},
		}
		readBufferSizeFlag = cli.UintFlag{
			Name: "read-buffer-size",
			Usage: "Per-connection buffer size in bytes for reading requests, this also limits the maximum header size " +
				"(increase this buffer if your clients send multi-KB Request URIs and/or multi-KB headers (e.g., " +
				"large cookies), note that increasing this value will increase memory consumption)",
			Value:    1024 * 5, //nolint:mnd // 5 KB
			Sources:  env("READ_BUFFER_SIZE"),
			Category: shared.CategoryOther,
			OnlyOnce: true,
		}
	)

	// override some flag usage messages
	addrFlag.Usage = "The HTTP server will listen on this IP (v4 or v6) address (set 127.0.0.1/::1 for localhost, " +
		"0.0.0.0 to listen on all interfaces, or specify a custom IP)"
	portFlag.Usage = "The TCP port number for the HTTP server to listen on (0-65535)"

	disableL10nFlag.Value = cfg.L10n.Disable // set the default value depending on the configuration

	cmd.c = &cli.Command{
		Name:    "serve",
		Aliases: []string{"s", "server", "http"},
		Usage:   "Please start the HTTP server to serve the error pages. You can configure various options - please RTFM :D",
		Suggest: true,
		Action: func(ctx context.Context, c *cli.Command) error {
			cmd.opt.http.addr = c.String(addrFlag.Name)
			cmd.opt.http.port = uint16(c.Uint(portFlag.Name)) //nolint:gosec
			cmd.opt.http.readBufferSize = uint(c.Uint(readBufferSizeFlag.Name))
			cfg.L10n.Disable = c.Bool(disableL10nFlag.Name)
			cfg.DefaultCodeToRender = uint16(c.Uint(defaultCodeToRenderFlag.Name)) //nolint:gosec
			cfg.RespondWithSameHTTPCode = c.Bool(sendSameHTTPCodeFlag.Name)
			cfg.RotationMode, _ = config.ParseRotationMode(c.String(rotationModeFlag.Name))
			cfg.ShowDetails = c.Bool(showDetailsFlag.Name)
			cfg.DisableMinification = c.Bool(disableMinificationFlag.Name)

			{ // override default JSON, XML, and PlainText formats
				if c.IsSet(jsonFormatFlag.Name) {
					cfg.Formats.JSON = strings.TrimSpace(c.String(jsonFormatFlag.Name))
				}

				if c.IsSet(xmlFormatFlag.Name) {
					cfg.Formats.XML = strings.TrimSpace(c.String(xmlFormatFlag.Name))
				}

				if c.IsSet(plainTextFormatFlag.Name) {
					cfg.Formats.PlainText = strings.TrimSpace(c.String(plainTextFormatFlag.Name))
				}
			}

			// add templates from files to the configuration
			if add := c.StringSlice(addTplFlag.Name); len(add) > 0 {
				for _, templatePath := range add {
					if addedName, err := cfg.Templates.AddFromFile(templatePath); err != nil {
						return fmt.Errorf("cannot add template from file %s: %w", templatePath, err)
					} else {
						log.Info("Template added",
							logger.String("name", addedName),
							logger.String("path", templatePath),
						)
					}
				}
			}

			// set the list of HTTP headers we need to proxy from the incoming request to the error page response
			if c.IsSet(proxyHeadersListFlag.Name) {
				var m = make(map[string]struct{}) // map is used to avoid duplicates

				for _, header := range strings.Split(c.String(proxyHeadersListFlag.Name), ",") {
					m[http.CanonicalHeaderKey(strings.TrimSpace(header))] = struct{}{}
				}

				clear(cfg.ProxyHeaders) // clear the list before adding new headers

				for header := range m {
					cfg.ProxyHeaders = append(cfg.ProxyHeaders, header)
				}
			}

			// add custom HTTP codes to the configuration
			if add := c.StringMap(addCodeFlag.Name); len(add) > 0 {
				for code, desc := range shared.ParseHTTPCodes(add) {
					cfg.Codes[code] = desc

					log.Info("HTTP code added",
						logger.String("code", code),
						logger.String("message", desc.Message),
						logger.String("description", desc.Description),
					)
				}
			}

			// disable templates specified by the user
			if disable := c.StringSlice(disableTplFlag.Name); len(disable) > 0 {
				for _, templateName := range disable {
					if ok := cfg.Templates.Remove(templateName); ok {
						log.Info("Template disabled", logger.String("name", templateName))
					}
				}
			}

			// check if there are any templates available to render error pages
			if len(cfg.Templates.Names()) == 0 {
				return errors.New("no templates available to render error pages")
			}

			// if the rotation mode is set to random-on-startup, pick a random template (ignore the user-provided
			// template name)
			if cfg.RotationMode == config.RotationModeRandomOnStartup {
				cfg.TemplateName = cfg.Templates.RandomName()
			} else { // otherwise, use the user-provided template name
				cfg.TemplateName = c.String(templateNameFlag.Name)

				if !cfg.Templates.Has(cfg.TemplateName) {
					return fmt.Errorf(
						"template '%s' not found and cannot be used (available templates: %s)",
						cfg.TemplateName,
						cfg.Templates.Names(),
					)
				}
			}

			log.Debug("Configuration",
				logger.Strings("loaded templates", cfg.Templates.Names()...),
				logger.Strings("described HTTP codes", cfg.Codes.Codes()...),
				logger.String("JSON format", cfg.Formats.JSON),
				logger.String("XML format", cfg.Formats.XML),
				logger.String("plain text format", cfg.Formats.PlainText),
				logger.String("template name", cfg.TemplateName),
				logger.Bool("disable localization", cfg.L10n.Disable),
				logger.Uint16("default code to render", cfg.DefaultCodeToRender),
				logger.Bool("respond with the same HTTP code", cfg.RespondWithSameHTTPCode),
				logger.String("rotation mode", cfg.RotationMode.String()),
				logger.Bool("show details", cfg.ShowDetails),
				logger.Strings("proxy HTTP headers", cfg.ProxyHeaders...),
			)

			return cmd.Run(ctx, log, &cfg)
		},
		Flags: []cli.Flag{
			&addrFlag,
			&portFlag,
			&addTplFlag,
			&disableTplFlag,
			&addCodeFlag,
			&jsonFormatFlag,
			&xmlFormatFlag,
			&plainTextFormatFlag,
			&templateNameFlag,
			&disableL10nFlag,
			&defaultCodeToRenderFlag,
			&sendSameHTTPCodeFlag,
			&showDetailsFlag,
			&proxyHeadersListFlag,
			&rotationModeFlag,
			&readBufferSizeFlag,
			&disableMinificationFlag,
		},
	}

	return cmd.c
}

// Run current command.
func (cmd *command) Run(ctx context.Context, log *logger.Logger, cfg *config.Config) error {
	var srv = appHttp.NewServer(log, cmd.opt.http.readBufferSize)

	if err := srv.Register(cfg); err != nil {
		return err
	}

	var startingErrCh = make(chan error, 1) // channel for server starting error
	defer close(startingErrCh)

	// to track the frequency of each template's use, we send a simple GET request to the GoatCounter API
	// (https://goatcounter.com, https://github.com/arp242/goatcounter) to increment the counter. this service is
	// free and does not require an API key. no private data is sent, as shown in the URL below. this is necessary
	// to render a badge displaying the number of template usages on the error-pages repository README file :D
	//
	// badge code example:
	//	![Used times](https://img.shields.io/badge/dynamic/json?
	//		url=https%3A%2F%2Ferror-pages.goatcounter.com%2Fcounter%2F%2Fuse-template%2Flost-in-space.json
	//		&query=%24.count&label=Used%20times)
	//
	// if you wish, you may view the collected statistics at any time here - https://error-pages.goatcounter.com/
	go func() {
		var tpl = url.QueryEscape(cfg.TemplateName)

		req, reqErr := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf(
			// https://www.goatcounter.com/help/pixel
			"https://error-pages.goatcounter.com/count?p=/use-template/%s&t=%s", tpl, tpl,
		), http.NoBody)
		if reqErr != nil {
			return
		}

		req.Header.Set("User-Agent", fmt.Sprintf("Mozilla/5.0 (error-pages, rnd:%d)", time.Now().UnixNano()))

		resp, respErr := (&http.Client{Timeout: 10 * time.Second}).Do(req) //nolint:mnd // don't care about the response
		if respErr != nil {
			log.Debug("Cannot send a request to increment the template usage counter", logger.Error(respErr))

			return
		} else if resp != nil {
			_ = resp.Body.Close()
		}
	}()

	// start HTTP server in separate goroutine
	go func(errCh chan<- error) {
		var now = time.Now()

		defer func() {
			log.Info("HTTP server stopped", logger.Duration("uptime", time.Since(now).Round(time.Millisecond)))
		}()

		log.Info("HTTP server starting",
			logger.String("addr", cmd.opt.http.addr),
			logger.Uint16("port", cmd.opt.http.port),
		)

		if err := srv.Start(cmd.opt.http.addr, cmd.opt.http.port); err != nil && !errors.Is(err, http.ErrServerClosed) {
			errCh <- err
		}
	}(startingErrCh)

	// and wait for...
	select {
	case err := <-startingErrCh: // ..server starting error
		return err

	case <-ctx.Done(): // ..or context cancellation
		const shutdownTimeout = 5 * time.Second

		log.Info("HTTP server stopping", logger.Duration("with timeout", shutdownTimeout))

		if err := srv.Stop(shutdownTimeout); err != nil { //nolint:contextcheck
			return err
		}
	}

	return nil
}
