// Package config manages the environment variable configurations.
package config

import (
	"crypto/sha512"
	"errors"
	"fmt"
	"net/url"
	"os"
	"reflect"
	"slices"
	"sort"
	"strconv"
	"strings"
	"text/tabwriter"

	"github.com/Defacto2/helper"
)

const (
	ConfigDir    = "defacto2-app" // ConfigDir is the subdirectory for the home user ".config".
	HTTPPort     = 1323           // HTTPPort is the default port number for the unencrypted HTTP server.
	SessionHours = 3              // SessionHours is the default number of hours for the session cookie to remain active.
	MinimumFiles = 40000          // MinimumFiles is the minimum number of unique filenames expected in an asset subdirectory.
	Down         = "AbsDownload"  // AbsDownload means the absolute download asset directory.
	Logger       = "AbsLog"       // AbsLog means the absolute log directory.
	Prev         = "AbsPreview"   // AbsPreview means the absolute preview assets directory.
	Thumb        = "AbsThumbnail" // AbsThumbnail means the absolute thumbnanil assets directory.
)

const (
	minwidth = 2
	tabwidth = 4
	padding  = 2
	padchar  = ' '
	flags    = 0
	hide     = "XXXXXXXX"
	h1       = "Configuration"
	h2       = "Value"
	h3       = "Environment variable"
	line     = "â”€"
)

var (
	ErrNoPort  = errors.New("the server cannot start without a http or a tls port")
	ErrPointer = errors.New("pointer is nil")
	ErrVer     = errors.New("postgresql version request failed")
)

// Configuration is a struct that holds the configuration options.
type Configuration struct {
	Title       string // Title is the name of the configuration option.
	Variable    string // Variable is the environment variable name.
	Value       string // Value of the configuration option that is safe to print.
	Description string // Description is the help text for the configuration option.
}

// Config options for the Defacto2 server using the [caarlos0/env] package.
//
// [caarlos0/env]:https://github.com/caarlos0/env
type Config struct {
	AbsLog         string `env:"D2_DIR_LOG" help:"The absolute directory path will store all logs generated by this application"`
	AbsDownload    string `env:"D2_DIR_DOWNLOAD" help:"The directory path that holds the UUID named files that are served as artifact downloads"`
	AbsPreview     string `env:"D2_DIR_PREVIEW" help:"The directory path that holds the UUID named image files that are served as previews of the artifact"`
	AbsThumbnail   string `env:"D2_DIR_THUMBNAIL" help:"The directory path that holds the UUID named squared image files that are served as artifact thumbnails"`
	AbsExtra       string `env:"D2_DIR_EXTRA" help:"The directory path that holds extra assets of the UUID named files that are generated by the application"`
	AbsOrphaned    string `env:"D2_DIR_ORPHANED" help:"The directory path that holds the UUID named files that are not linked to any database records"`
	DatabaseURL    string `env:"D2_DATABASE_URL" help:"Provide the URL of the database to which to connect"`
	SessionKey     string `env:"D2_SESSION_KEY,unset" help:"Use a fixed session key for the cookie store, which can be left blank to generate a random key"`
	GoogleClientID string `env:"D2_GOOGLE_CLIENT_ID,unset" help:"The Google OAuth2 client ID"`
	GoogleIDs      string `env:"D2_GOOGLE_IDS,unset" help:"Create a comma-separated list of Google account IDs to permit access to the editor mode"`
	MatchHost      string `env:"D2_MATCH_HOST" help:"Limits connections to the specific host or domain name; leave blank to permit connections from anywhere"`
	TLSCert        string `env:"D2_TLS_CERT" help:"An absolute file path to the TLS certificate, or leave blank to use a self-signed, localhost certificate"`
	TLSKey         string `env:"D2_TLS_KEY" help:"An absolute file path to the TLS key, or leave blank to use a self-signed, localhost key"`
	// GoogleAccounts is a slice of Google OAuth2 accounts that are allowed to login.
	// Each account is a 48 byte slice of bytes that represents the SHA-384 hash of the unique Google ID.
	GoogleAccounts [][48]byte
	HTTPPort       uint `env:"D2_HTTP_PORT" help:"The port number to be used by the unencrypted HTTP web server"`
	MaxProcs       uint `env:"D2_MAX_PROCS" help:"Limit the number of operating system threads the program can use"`
	SessionMaxAge  int  `env:"D2_SESSION_MAX_AGE" help:"List the maximum number of hours for the session cookie to remain active before expiring and requiring a new login"`
	TLSPort        uint `env:"D2_TLS_PORT" help:"The port number to be used by the encrypted, HTTPS web server"`
	Quiet          bool `env:"D2_QUIET" help:"Suppress most startup output to the terminal, intended for use with systemd or other process managers"`
	Compression    bool `env:"D2_COMPRESSION" help:"Enable gzip compression of the HTTP/HTTPS responses; you may turn this off when using a reverse proxy"`
	ProdMode       bool `env:"D2_PROD_MODE" help:"Use the production mode to log errors to files and recover from panics"`
	ReadOnly       bool `env:"D2_READ_ONLY" help:"Use the read-only mode to turn off all POST, PUT, and DELETE requests and any related user interface"`
	NoCrawl        bool `env:"D2_NO_CRAWL" help:"Tell search engines to not crawl any of website pages or assets"`
	LogAll         bool `env:"D2_LOG_ALL" help:"Log all HTTP and HTTPS client requests including those with 200 OK responses"`
}

// List returns a list of the configuration options.
func (c Config) List() []Configuration {
	skip := []string{"GoogleAccounts"}
	t := reflect.TypeOf(c)
	configs := make([]Configuration, t.NumField())
	for i := range t.NumField() {
		name := t.Field(i).Name
		switch {
		case slices.Contains(skip, name):
			continue
		case name == "TLSCert", name == "TLSKey":
			configs[i].Value = valueCert(reflect.ValueOf(c).Field(i), c.TLSPort)
		case name == "GoogleIDs":
			configs[i].Value = valueGoogles(c.GoogleAccounts)
		default:
			configs[i].Value = reflectValue(t.Field(i), reflect.ValueOf(c).Field(i))
		}
		configs[i].Title = Format(name)
		configs[i].Variable = t.Field(i).Tag.Get("env")
		configs[i].Description = t.Field(i).Tag.Get("help")
		if configs[i].Value == hide {
			configs[i].Value = "In use, but hidden for security"
		}
	}
	return configs
}

func reflectValue(f reflect.StructField, v reflect.Value) string {
	// special values that require formatting or hiding
	switch f.Name {
	case "GoogleClientID":
		return valueGoogleIDs(v)
	case "MatchHost":
		return valueMatchHost(v)
	case "SessionKey":
		return valueSessionKey(v)
	case "SessionMaxAge":
		return valueHours(v)
	case "DatabaseURL":
		return valueDatabase(v.String())
	case "HTTPPort":
		return valueHTTP(v)
	case "MaxProcs":
		return valueProcs(v)
	case "TLSPort":
		return valueTLS(v)
	}
	// empty cases
	if v.String() == "" {
		if s := emptyCases(f.Name); s != "" {
			return s
		}
	}
	// generic values
	switch f.Type.Kind() {
	case reflect.Bool:
		return valueBool(v)
	case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
		return strconv.FormatUint(v.Uint(), 10)
	case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
		return strconv.FormatInt(v.Int(), 10)
	case reflect.String:
		return v.String()
	default:
		return fmt.Sprintf("%v", v.Interface())
	}
}

func emptyCases(name string) string {
	switch name {
	case Down:
		return "Empty, no downloads will be served"
	case Prev:
		return "Empty, no preview images will be shown"
	case Thumb:
		return "Empty, no thumbnails will be shown"
	case Logger:
		return "Empty, logs print to the terminal (stdout)"
	default:
		return ""
	}
}

// Envs returns a list of the environment variable names in the Config struct.
func (c Config) Envs() []string {
	t := reflect.TypeOf(c)
	envNames := make([]string, t.NumField())
	for i := range t.NumField() {
		envNames[i] = t.Field(i).Tag.Get("env")
	}
	return envNames
}

// Helps returns a list of the help text in the Config struct.
func (c Config) Helps() []string {
	t := reflect.TypeOf(c)
	helpVals := make([]string, t.NumField())
	for i := range t.NumField() {
		helpVals[i] = t.Field(i).Tag.Get("help")
	}
	return helpVals
}

// Names returns a list of the field names in the Config struct.
func (c Config) Names() []string {
	t := reflect.TypeOf(c)
	fieldNames := make([]string, t.NumField())
	for i := range t.NumField() {
		fieldNames[i] = t.Field(i).Name
	}
	return fieldNames
}

// Values returns a list of the values in the Config struct.
// These are not safe to print meaning that they may contain sensitive information.
func (c Config) Values() []string {
	fields := reflect.VisibleFields(reflect.TypeOf(c))
	sort.Slice(fields, func(i, j int) bool {
		return fields[i].Name < fields[j].Name
	})
	values := make([]string, 0, len(fields))
	for _, field := range fields {
		if !field.IsExported() {
			continue
		}
		val := reflect.ValueOf(c).FieldByName(field.Name)
		values = append(values, val.String())
	}
	return values
}

// String returns a string representation of the Config struct.
// The output is formatted as a table with the following columns:
// Environment variable and Value.
func (c Config) String() string {
	b := new(strings.Builder)
	c.fprint(b)
	return b.String()
}

// fprint prints a list of active configurations options.
func (c Config) fprint(b *strings.Builder) *strings.Builder {
	fields := reflect.VisibleFields(reflect.TypeOf(c))
	sort.Slice(fields, func(i, j int) bool {
		return fields[i].Name < fields[j].Name
	})
	values := reflect.ValueOf(c)

	w := tabwriter.NewWriter(b, minwidth, tabwidth, padding, padchar, flags)
	fmt.Fprint(b, "The Defacto2 server configuration:\n\n")
	fmt.Fprintf(w, "\t%s\t%s\t%s\n",
		h1, h3, h2)
	fmt.Fprintf(w, "\t%s\t%s\t%s\n",
		strings.Repeat(line, len(h1)),
		strings.Repeat(line, len(h3)),
		strings.Repeat(line, len(h2)))

	for _, field := range fields {
		if !field.IsExported() {
			continue
		}
		switch field.Name {
		case "GoogleAccounts":
			continue
		default:
		}
		val := values.FieldByName(field.Name)
		id := field.Name
		name := field.Tag.Get("env")
		if before, found := strings.CutSuffix(name, ",unset"); found {
			name = before
		}
		c.fprintField(w, id, name, val)
	}
	w.Flush()
	return b
}

// fprintField prints the id, name, value and help text to the tabwriter.
func (c Config) fprintField(w *tabwriter.Writer,
	id, name string,
	val reflect.Value,
) {
	fmt.Fprintf(w, "\t\t\t\t\n")
	switch id {
	case "HTTPPort":
		fmt.Fprintf(w, "\t%s\t%s\t%s\n", Format(id), name, valueHTTP(val))
	case "TLSPort":
		fmt.Fprintf(w, "\t%s\t%s\t%s\n", Format(id), name, valueTLS(val))
	case "TLSCert", "TLSKey":
		fmt.Fprintf(w, "\t%s\t%s\t%s\n", Format(id), name, valueCert(val, c.TLSPort))
	case Down, Prev, Thumb, Logger:
		fprintDirs(w, id, name, val.String())
	case "MaxProcs":
		fmt.Fprintf(w, "\t%s\t%s\t%s\n", Format(id), name, valueProcs(val))
	case "GoogleIDs":
		fmt.Fprintf(w, "\t%s\t%s\t%s\n", Format(id), name, valueGoogles(c.GoogleAccounts))
	default:
		fprintField2(w, id, name, val)
	}
}

// fprintDirs prints the directory path to the tabwriter or a warning if the path is empty.
func fprintDirs(w *tabwriter.Writer, id, name, val string) {
	fmt.Fprintf(w, "\t%s\t%s", Format(id), name)
	if val != "" {
		if st, err := os.Stat(val); err != nil && os.IsNotExist(err) {
			fmt.Fprintf(w, "\t%s\n\t\t\tProblem: DIRECTORY DOES NOT EXIST\n", val)
			return
		} else if err != nil {
			fmt.Fprintf(w, "\t%s\n\t\t\tError: %s\n", val, err)
			return
		} else if !st.IsDir() {
			fmt.Fprintf(w, "\t%s\n\t\t\tProblem: PATH POINTS TO A FILE\n", val)
			return
		}
		fmt.Fprintf(w, "\t%s\n", val)
		return
	}
	switch id {
	case Down:
		fmt.Fprintf(w, "\tEmpty, no downloads will be served\n")
	case Prev:
		fmt.Fprintf(w, "\tEmpty, no preview images will be shown\n")
	case Thumb:
		fmt.Fprintf(w, "\tEmpty, no thumbnails will be shown\n")
	case Logger:
		fmt.Fprintf(w, "\tEmpty, logs print to the terminal (stdout)\n")
	default:
		fmt.Fprintln(w)
	}
}

// fprintField2 prints the id, name, value and help text to the tabwriter.
func fprintField2(w *tabwriter.Writer, id, name string, val reflect.Value) {
	if val.Kind() == reflect.Bool {
		fmt.Fprintf(w, "\t%s\t%s\t%v\n", Format(id), name, valueBool(val))
		return
	}
	fmt.Fprintf(w, "\t%s\t%s\t", Format(id), name)
	switch id {
	case "GoogleClientID":
		fmt.Fprintln(w, valueGoogleIDs(val))
	case "MatchHost":
		fmt.Fprintln(w, valueMatchHost(val))
	case "SessionKey":
		fmt.Fprintln(w, valueSessionKey(val))
	case "SessionMaxAge":
		fmt.Fprintln(w, valueHours(val))
	case "DatabaseURL":
		fmt.Fprintln(w, valueDatabase(val.String()))
	default:
		if val.String() == "" {
			fmt.Fprintln(w, "Empty")
			return
		}
		if val.IsValid() {
			fmt.Fprintf(w, "%v\n", val)
		}
	}
}

// Addresses returns a list of urls that the server is accessible from.
func (c Config) Addresses() (string, error) {
	b := new(strings.Builder)
	if err := c.addresses(b, true); err != nil {
		return "", fmt.Errorf("list of server addresses: %w", err)
	}
	return b.String(), nil
}

func addressesHelper(b *strings.Builder) {
	fmt.Fprintf(b, "%s\n",
		"Depending on your firewall, network and certificate setup,")
	fmt.Fprintf(b, "%s\n",
		"this web server could be accessible from the following addresses:")
	fmt.Fprintf(b, "\n")
}

// addresses prints a list of urls that the server is accessible from.
func (c Config) addresses(b *strings.Builder, help bool) error {
	pad := strings.Repeat(string(padchar), padding)
	values := reflect.ValueOf(c)
	if help {
		addressesHelper(b)
	}
	hosts, err := helper.LocalHosts()
	if err != nil {
		return fmt.Errorf("the server cannot get the local host names: %w", err)
	}
	port := values.FieldByName("HTTPPort").Uint()
	tls := values.FieldByName("TLSPort").Uint()
	if port == 0 && tls == 0 {
		return ErrNoPort
	}
	const disable, text, secure = 0, 80, 443
	for _, host := range hosts {
		if c.MatchHost != "" && host != c.MatchHost {
			continue
		}
		switch port {
		case text:
			fmt.Fprintf(b, "%shttp://%s\n", pad, host)
		case disable:
			continue
		default:
			fmt.Fprintf(b, "%shttp://%s:%d\n", pad, host, port)
		}
		switch tls {
		case secure:
			fmt.Fprintf(b, "%shttps://%s\n", pad, host)
		case disable:
			continue
		default:
			fmt.Fprintf(b, "%shttps://%s:%d\n", pad, host, tls)
		}
	}
	if c.MatchHost == "" {
		return localIPs(b, port, pad)
	}
	return nil
}

func localIPs(b *strings.Builder, port uint64, pad string) error {
	ips, err := helper.LocalIPs()
	if err != nil {
		return fmt.Errorf("the server cannot get the local IP addresses: %w", err)
	}
	for _, ip := range ips {
		if port == 0 {
			break
		}
		fmt.Fprintf(b, "%shttp://%s:%d\n", pad, ip, port)
	}
	return nil
}

// Format returns a human readable description of the named configuration identifier.
func Format(name string) string {
	m := map[string]string{
		Down:             "Downloads, directory path",
		Prev:             "Previews, directory path",
		Thumb:            "Thumbnails, directory path",
		Logger:           "Logs, directory path",
		"Compression":    "Gzip compression",
		"DatabaseURL":    "Database connection, URL",
		"GoogleClientID": "Google OAuth2 client ID",
		"GoogleIDs":      "Google IDs for sign-in",
		"LogAll":         "Log all HTTP requests",
		"MaxProcs":       "Maximum CPU processes",
		"MatchHost":      "Match hostname, domain or IP address",
		"NoCrawl":        "Disallow search engine crawling",
		"ProdMode":       "Production mode",
		"Quiet":          "Quiet mode",
		"ReadOnly":       "Read-only mode",
		"SessionKey":     "Session encryption key",
		"SessionMaxAge":  "Session, maximum age",
		"TLSCert":        "TLS certificate, file path",
		"TLSHost":        "TLS hostname",
		"TLSKey":         "TLS key, file path",
	}
	if desc, found := m[name]; found {
		return desc
	}
	return helper.SplitAsSpaces(name)
}

func valueBool(val reflect.Value) string {
	if val.Bool() {
		return "On"
	}
	return "Off"
}

func valueGoogleIDs(val reflect.Value) string {
	if val.String() == "" {
		return "Empty, no accounts for web administration"
	}
	return hide
}

func valueMatchHost(val reflect.Value) string {
	if val.String() == "" {
		return "Empty, no address restrictions"
	}
	return val.String()
}

func valueSessionKey(val reflect.Value) string {
	if val.String() == "" {
		return "Empty, a random key will be generated during the server start"
	}
	return hide
}

func valueHours(val reflect.Value) string {
	i := val.Int()
	if i == 1 {
		return "1 hour"
	}
	return fmt.Sprintf("%v hours", i)
}

// valueHTTP prints the HTTP port number to the tabwriter.
func valueHTTP(val reflect.Value) string {
	if val.Kind() == reflect.Uint && val.Uint() == 0 {
		return "Unused, the web server will not use HTTP"
	}
	port := val.Uint()
	const common = 80
	if port == common {
		return fmt.Sprintf("%d, the web server will use HTTP, example: http://localhost", port)
	}
	return fmt.Sprintf("%d, the web server will use HTTP, example: http://localhost:%d", port, port)
}

// valueTLS prints the HTTPS port number to the tabwriter.
func valueTLS(val reflect.Value) string {
	if val.Kind() == reflect.Uint && val.Uint() == 0 {
		return "Unused, the web server will not use HTTPS"
	}
	port := val.Uint()
	const common = 443
	if port == common {
		return fmt.Sprintf("%d, the web server will use HTTPS, example: https://localhost", port)
	}
	return fmt.Sprintf("%d, the web server will use HTTPS, example: https://localhost:%d", port, port)
}

// valueCert prints the TLS certificate and key locations to the tabwriter.
func valueCert(val reflect.Value, tlsport uint) string {
	if tlsport == 0 {
		return "Not in use"
	}
	if val.String() == "" {
		return "Empty, will use a placeholder configuration"
	}
	return val.String()
}

// valueProcs prints the number of CPU cores to the tabwriter.
func valueProcs(val reflect.Value) string {
	if val.Kind() == reflect.Uint && val.Uint() == 0 {
		return "Unused, the application will use all available CPU threads"
	}
	return fmt.Sprintf("%d, the application will limit access to CPU threads\n", val.Uint())
}

// valueDatabase replaces the password in the database connection URL with XXXXXs.
func valueDatabase(rawURL string) string {
	u, err := url.Parse(rawURL)
	if err != nil {
		return rawURL
	}
	_, exists := u.User.Password()
	if !exists {
		return rawURL
	}
	u.User = url.UserPassword(u.User.Username(), hide)
	return u.String()
}

func valueGoogles(ids [][48]byte) string {
	l := len(ids)
	switch l {
	case 0:
		return "Empty, no accounts for web administration"
	case 1:
		return "1 Google account allowed to sign-in"
	default:
		return fmt.Sprintf("%d Google accounts allowed to sign-in", l)
	}
}

// StaticThumb returns the path to the thumbnail directory.
func StaticThumb() string {
	return "/public/image/thumb"
}

// StaticOriginal returns the path to the image directory.
func StaticOriginal() string {
	return "/public/image/original"
}

// UseTLS returns true if the server is configured to use TLS.
func (c Config) UseTLS() bool {
	return c.TLSPort > 0 && c.TLSCert != "" || c.TLSKey != ""
}

// UseHTTP returns true if the server is configured to use HTTP.
func (c Config) UseHTTP() bool {
	return c.HTTPPort > 0
}

// UseTLSLocal returns true if the server is configured to use the local-mode.
func (c Config) UseTLSLocal() bool {
	return c.TLSPort > 0 && c.TLSCert == "" && c.TLSKey == ""
}

// Override the configuration settings fetched from the environment.
func (c *Config) Override() {
	// hash and delete any supplied google ids
	ids := strings.Split(c.GoogleIDs, ",")
	for _, id := range ids {
		sum := sha512.Sum384([]byte(id))
		c.GoogleAccounts = append(c.GoogleAccounts, sum)
	}
	c.GoogleIDs = "overwrite placeholder"
	c.GoogleIDs = "" // empty the string

	// set the default HTTP port if both ports are configured to zero
	if c.HTTPPort == 0 && c.TLSPort == 0 {
		c.HTTPPort = HTTPPort
	}
}
