package pq

import (
	"context"
	"database/sql/driver"
	"fmt"
	"math/rand"
	"net"
	"net/netip"
	neturl "net/url"
	"os"
	"path/filepath"
	"reflect"
	"slices"
	"sort"
	"strconv"
	"strings"
	"time"
	"unicode"

	"github.com/lib/pq/internal/pqutil"
)

type (
	// SSLMode is a sslmode setting.
	SSLMode string

	// SSLNegotiation is a sslnegotiation setting.
	SSLNegotiation string

	// TargetSessionAttrs is a target_session_attrs setting.
	TargetSessionAttrs string

	// LoadBalanceHosts is a load_balance_hosts setting.
	LoadBalanceHosts string
)

// Values for [SSLMode] that pq supports.
const (
	// disable: No SSL
	SSLModeDisable = SSLMode("disable")

	// require: require SSL, but skip verification.
	SSLModeRequire = SSLMode("require")

	// verify-ca: require SSL and verify that the certificate was signed by a
	// trusted CA.
	SSLModeVerifyCA = SSLMode("verify-ca")

	// verify-full: require SSK and verify that the certificate was signed by a
	// trusted CA and the server host name matches the one in the certificate.
	SSLModeVerifyFull = SSLMode("verify-full")
)

var sslModes = []SSLMode{SSLModeDisable, SSLModeRequire, SSLModeVerifyFull, SSLModeVerifyCA}

// Values for [SSLNegotiation] that pq supports.
const (
	// Negotiate whether SSL should be used. This is the default.
	SSLNegotiationPostgres = SSLNegotiation("postgres")

	// Always use SSL, don't try to negotiate.
	SSLNegotiationDirect = SSLNegotiation("direct")
)

var sslNegotiations = []SSLNegotiation{SSLNegotiationPostgres, SSLNegotiationDirect}

// Values for [TargetSessionAttrs] that pq supports.
const (
	// Any successful connection is acceptable. This is the default.
	TargetSessionAttrsAny = TargetSessionAttrs("any")

	// Session must accept read-write transactions by default: the server must
	// not be in hot standby mode and default_transaction_read_only must be
	// off.
	TargetSessionAttrsReadWrite = TargetSessionAttrs("read-write")

	// Session must not accept read-write transactions by default.
	TargetSessionAttrsReadOnly = TargetSessionAttrs("read-only")

	// Server must not be in hot standby mode.
	TargetSessionAttrsPrimary = TargetSessionAttrs("primary")

	// Server must be in hot standby mode.
	TargetSessionAttrsStandby = TargetSessionAttrs("standby")

	// First try to find a standby server, but if none of the listed hosts is a
	// standby server, try again in any mode.
	TargetSessionAttrsPreferStandby = TargetSessionAttrs("prefer-standby")
)

var targetSessionAttrs = []TargetSessionAttrs{TargetSessionAttrsAny,
	TargetSessionAttrsReadWrite, TargetSessionAttrsReadOnly, TargetSessionAttrsPrimary,
	TargetSessionAttrsStandby, TargetSessionAttrsPreferStandby}

// Values for [LoadBalanceHosts] that pq supports.
const (
	// Don't load balance; try hosts in the order in which they're provided.
	// This is the default.
	LoadBalanceHostsDisable = LoadBalanceHosts("disable")

	// Hosts are tried in random order to balance connections across multiple
	// PostgreSQL servers.
	//
	// When using this value it's recommended to also configure a reasonable
	// value for connect_timeout. Because then, if one of the nodes that are
	// used for load balancing is not responding, a new node will be tried.
	LoadBalanceHostsRandom = LoadBalanceHosts("random")
)

var loadBalanceHosts = []LoadBalanceHosts{LoadBalanceHostsDisable, LoadBalanceHostsRandom}

// Connector represents a fixed configuration for the pq driver with a given
// dsn. Connector satisfies the [database/sql/driver.Connector] interface and
// can be used to create any number of DB Conn's via [sql.OpenDB].
type Connector struct {
	cfg    Config
	dialer Dialer
}

// NewConnector returns a connector for the pq driver in a fixed configuration
// with the given dsn. The returned connector can be used to create any number
// of equivalent Conn's. The returned connector is intended to be used with
// [sql.OpenDB].
func NewConnector(dsn string) (*Connector, error) {
	cfg, err := NewConfig(dsn)
	if err != nil {
		return nil, err
	}
	return NewConnectorConfig(cfg)
}

// NewConnectorConfig returns a connector for the pq driver in a fixed
// configuration with the given [Config]. The returned connector can be used to
// create any number of equivalent Conn's. The returned connector is intended to
// be used with [sql.OpenDB].
func NewConnectorConfig(cfg Config) (*Connector, error) {
	return &Connector{cfg: cfg, dialer: defaultDialer{}}, nil
}

// Connect returns a connection to the database using the fixed configuration of
// this Connector. Context is not used.
func (c *Connector) Connect(ctx context.Context) (driver.Conn, error) { return c.open(ctx) }

// Dialer allows change the dialer used to open connections.
func (c *Connector) Dialer(dialer Dialer) { c.dialer = dialer }

// Driver returns the underlying driver of this Connector.
func (c *Connector) Driver() driver.Driver { return &Driver{} }

// Config holds options pq supports when connecting to PostgreSQL.
//
// The postgres struct tag is used for the value from the DSN (e.g.
// "dbname=abc"), and the env struct tag is used for the environment variable
// (e.g. "PGDATABASE=abc")
type Config struct {
	// The host to connect to. Absolute paths and values that start with @ are
	// for unix domain sockets. Defaults to localhost.
	//
	// A comma-separated list of host names is also accepted, in which case each
	// host name in the list is tried in order or randomly if load_balance_hosts
	// is set. An empty item selects the default of localhost. The
	// target_session_attrs option controls properties the host must have to be
	// considered acceptable.
	Host string `postgres:"host" env:"PGHOST"`

	// IPv4 or IPv6 address to connect to. Using hostaddr allows the application
	// to avoid a host name lookup, which might be important in applications
	// with time constraints. A hostname is required for sslmode=verify-full and
	// the GSSAPI or SSPI authentication methods.
	//
	// The following rules are used:
	//
	// - If host is given without hostaddr, a host name lookup occurs.
	//
	// - If hostaddr is given without host, the value for hostaddr gives the
	//   server network address. The connection attempt will fail if the
	//   authentication method requires a host name.
	//
	// - If both host and hostaddr are given, the value for hostaddr gives the
	//   server network address. The value for host is ignored unless the
	//   authentication method requires it, in which case it will be used as the
	//   host name.
	//
	// A comma-separated list of hostaddr values is also accepted, in which case
	// each host in the list is tried in order or randonly if load_balance_hosts
	// is set. An empty item causes the corresponding host name to be used, or
	// the default host name if that is empty as well. The target_session_attrs
	// option controls properties the host must have to be considered
	// acceptable.
	Hostaddr netip.Addr `postgres:"hostaddr" env:"PGHOSTADDR"`

	// The port to connect to. Defaults to 5432.
	//
	// If multiple hosts were given in the host or hostaddr parameters, this
	// parameter may specify a comma-separated list of ports of the same length
	// as the host list, or it may specify a single port number to be used for
	// all hosts. An empty string, or an empty item in a comma-separated list,
	// specifies the default of 5432.
	Port uint16 `postgres:"port" env:"PGPORT"`

	// The name of the database to connect to.
	Database string `postgres:"dbname" env:"PGDATABASE"`

	// The user to sign in as. Defaults to the current user.
	User string `postgres:"user" env:"PGUSER"`

	// The user's password.
	Password string `postgres:"password" env:"PGPASSWORD"`

	// Path to [pgpass] file to store passwords; overrides Password.
	//
	// [pgpass]: http://www.postgresql.org/docs/current/static/libpq-pgpass.html
	Passfile string `postgres:"passfile" env:"PGPASSFILE"`

	// Commandline options to send to the server at connection start.
	Options string `postgres:"options" env:"PGOPTIONS"`

	// Application name, displayed in pg_stat_activity and log entries.
	ApplicationName string `postgres:"application_name" env:"PGAPPNAME"`

	// Used if application_name is not given. Specifying a fallback name is
	// useful in generic utility programs that wish to set a default application
	// name but allow it to be overridden by the user.
	FallbackApplicationName string `postgres:"fallback_application_name" env:"-"`

	// Whether to use SSL. Defaults to "require" (different from libpq's default
	// of "prefer").
	//
	// [RegisterTLSConfig] can be used to registers a custom [tls.Config], which
	// can be used by setting sslmode=pqgo-«key» in the connection string.
	SSLMode SSLMode `postgres:"sslmode" env:"PGSSLMODE"`

	// When set to "direct" it will use SSL without negotiation (PostgreSQL ≥17 only).
	SSLNegotiation SSLNegotiation `postgres:"sslnegotiation" env:"PGSSLNEGOTIATION"`

	// Cert file location. The file must contain PEM encoded data.
	SSLCert string `postgres:"sslcert" env:"PGSSLCERT"`

	// Key file location. The file must contain PEM encoded data.
	SSLKey string `postgres:"sslkey" env:"PGSSLKEY"`

	// The location of the root certificate file. The file must contain PEM encoded data.
	SSLRootCert string `postgres:"sslrootcert" env:"PGSSLROOTCERT"`

	// By default SNI is on, any value which is not starting with "1" disables
	// SNI.
	SSLSNI bool `postgres:"sslsni" env:"PGSSLSNI"`

	// Interpert sslcert and sslkey as PEM encoded data, rather than a path to a
	// PEM file. This is a pq extension, not supported in libpq.
	SSLInline bool `postgres:"sslinline" env:"-"`

	// GSS (Kerberos) service name when constructing the SPN (default is
	// postgres). This will be combined with the host to form the full SPN:
	// krbsrvname/host.
	KrbSrvname string `postgres:"krbsrvname" env:"PGKRBSRVNAME"`

	// GSS (Kerberos) SPN. This takes priority over krbsrvname if present. This
	// is a pq extension, not supported in libpq.
	KrbSpn string `postgres:"krbspn" env:"-"`

	// Maximum time to wait while connecting, in seconds. Zero, negative, or not
	// specified means wait indefinitely
	ConnectTimeout time.Duration `postgres:"connect_timeout" env:"PGCONNECT_TIMEOUT"`

	// Whether to always send []byte parameters over as binary. Enables single
	// round-trip mode for non-prepared Query calls. This is a pq extension, not
	// supported in libpq.
	BinaryParameters bool `postgres:"binary_parameters" env:"-"`

	// This connection should never use the binary format when receiving query
	// results from prepared statements. Only provided for debugging. This is a
	// pq extension, not supported in libpq.
	DisablePreparedBinaryResult bool `postgres:"disable_prepared_binary_result" env:"-"`

	// Client encoding; pq only supports UTF8 and this must be blank or "UTF8".
	ClientEncoding string `postgres:"client_encoding" env:"PGCLIENTENCODING"`

	// Date/time representation to use; pq only supports "ISO, MDY" and this
	// must be blank or "ISO, MDY".
	Datestyle string `postgres:"datestyle" env:"PGDATESTYLE"`

	// Default time zone.
	TZ string `postgres:"tz" env:"PGTZ"`

	// Default mode for the genetic query optimizer.
	Geqo string `postgres:"geqo" env:"PGGEQO"`

	// Determine whether the session must have certain properties to be
	// acceptable. It's typically used in combination with multiple host names
	// to select the first acceptable alternative among several hosts.
	TargetSessionAttrs TargetSessionAttrs `postgres:"target_session_attrs" env:"PGTARGETSESSIONATTRS"`

	// Controls the order in which the client tries to connect to the available
	// hosts. Once a connection attempt is successful no other hosts will be
	// tried. This parameter is typically used in combination with multiple host
	// names.
	//
	// This parameter can be used in combination with target_session_attrs to,
	// for example, load balance over standby servers only. Once successfully
	// connected, subsequent queries on the returned connection will all be sent
	// to the same server.
	LoadBalanceHosts LoadBalanceHosts `postgres:"load_balance_hosts" env:"PGLOADBALANCEHOSTS"`

	// Runtime parameters: any unrecognized parameter in the DSN will be added
	// to this and sent to PostgreSQL during startup.
	Runtime map[string]string `postgres:"-" env:"-"`

	// Multi contains additional connection details. The first value is
	// available in [Config.Host], [Config.Hostaddr], and [Config.Port], and
	// additional ones (if any) are available here.
	Multi []ConfigMultihost

	// Record which parameters were given, so we can distinguish between an
	// empty string "not given at all".
	//
	// The alternative is to use pointers or sql.Null[..], but that's more
	// awkward to use.
	set []string `env:"set"`

	multiHost     []string
	multiHostaddr []netip.Addr
	multiPort     []uint16
}

// ConfigMultihost specifies an additional server to try to connect to.
type ConfigMultihost struct {
	Host     string
	Hostaddr netip.Addr
	Port     uint16
}

// NewConfig creates a new [Config] from the current environment and given DSN.
//
// A subset of the connection parameters supported by PostgreSQL are supported
// by pq; see the [Config] struct fields for supported parameters. pq also lets
// you specify any [run-time parameter] (such as search_path or work_mem)
// directly in the connection string. This is different from libpq, which does
// not allow run-time parameters in the connection string, instead requiring you
// to supply them in the options parameter.
//
// # key=value connection strings
//
// For key=value strings, use single quotes for values that contain whitespace
// or empty values. A backslash will escape the next character:
//
//	"user=pqgo password='with spaces'"
//	"user=''"
//	"user=space\ man password='it\'s valid'"
//
// # URL connection strings
//
// pq supports URL-style postgres:// or postgresql:// connection strings in the
// form:
//
//	postgres[ql]://[user[:pwd]@][net-location][:port][/dbname][?param1=value1&...]
//
// Go's [net/url.Parse] is more strict than PostgreSQL's URL parser and will
// (correctly) reject %2F in the host part. This means that unix-socket URLs:
//
//	postgres://[user[:pwd]@][unix-socket][:port[/dbname]][?param1=value1&...]
//	postgres://%2Ftmp%2Fpostgres/db
//
// will not work. You will need to use "host=/tmp/postgres dbname=db".
//
// Similarly, multiple ports also won't work, but ?port= will:
//
//	postgres://host1,host2:5432,6543/dbname         Doesn't work
//	postgres://host1,host2/dbname?port=5432,6543    Works
//
// # Environment
//
// Most [PostgreSQL environment variables] are supported by pq. Environment
// variables have a lower precedence than explicitly provided connection
// parameters. pq will return an error if environment variables it does not
// support are set. Environment variables have a lower precedence than
// explicitly provided connection parameters.
//
// [run-time parameter]: http://www.postgresql.org/docs/current/static/runtime-config.html
// [PostgreSQL environment variables]: http://www.postgresql.org/docs/current/static/libpq-envars.html
func NewConfig(dsn string) (Config, error) {
	return newConfig(dsn, os.Environ())
}

// Clone returns a copy of the [Config].
func (cfg Config) Clone() Config {
	rt := make(map[string]string)
	for k, v := range cfg.Runtime {
		rt[k] = v
	}
	c := cfg
	c.Runtime = rt
	c.set = append([]string{}, cfg.set...)
	return c
}

// hosts returns a slice of copies of this config, one for each host.
func (cfg Config) hosts() []Config {
	cfgs := make([]Config, 1, len(cfg.Multi)+1)
	cfgs[0] = cfg.Clone()
	for _, m := range cfg.Multi {
		c := cfg.Clone()
		c.Host, c.Hostaddr, c.Port = m.Host, m.Hostaddr, m.Port
		cfgs = append(cfgs, c)
	}

	if cfg.LoadBalanceHosts == LoadBalanceHostsRandom {
		rand.Shuffle(len(cfgs), func(i, j int) { cfgs[i], cfgs[j] = cfgs[j], cfgs[i] })
	}

	return cfgs
}

func newConfig(dsn string, env []string) (Config, error) {
	cfg := Config{Host: "localhost", Port: 5432, SSLSNI: true}
	if err := cfg.fromEnv(env); err != nil {
		return Config{}, err
	}
	if err := cfg.fromDSN(dsn); err != nil {
		return Config{}, err
	}

	// Need to have exactly the same number of host and hostaddr, or only specify one.
	if cfg.isset("host") && cfg.Host != "" && cfg.Hostaddr != (netip.Addr{}) && len(cfg.multiHost) != len(cfg.multiHostaddr) {
		return Config{}, fmt.Errorf("pq: could not match %d host names to %d hostaddr values",
			len(cfg.multiHost)+1, len(cfg.multiHostaddr)+1)
	}
	// Need one port that applies to all or exactly the same number of ports as hosts.
	l, ll := max(len(cfg.multiHost), len(cfg.multiHostaddr)), len(cfg.multiPort)
	if l > 0 && ll > 0 && l != ll {
		return Config{}, fmt.Errorf("pq: could not match %d port numbers to %d hosts", ll+1, l+1)
	}

	// Populate Multi
	if len(cfg.multiHostaddr) > len(cfg.multiHost) {
		cfg.multiHost = make([]string, len(cfg.multiHostaddr))
	}
	for i, h := range cfg.multiHost {
		p := cfg.Port
		if len(cfg.multiPort) > 0 {
			p = cfg.multiPort[i]
		}
		var addr netip.Addr
		if len(cfg.multiHostaddr) > 0 {
			addr = cfg.multiHostaddr[i]
		}
		cfg.Multi = append(cfg.Multi, ConfigMultihost{
			Host:     h,
			Port:     p,
			Hostaddr: addr,
		})
	}

	// Use the "fallback" application name if necessary
	if cfg.isset("fallback_application_name") && !cfg.isset("application_name") {
		cfg.ApplicationName = cfg.FallbackApplicationName
	}

	// We can't work with any client_encoding other than UTF-8 currently.
	// However, we have historically allowed the user to set it to UTF-8
	// explicitly, and there's no reason to break such programs, so allow that.
	// Note that the "options" setting could also set client_encoding, but
	// parsing its value is not worth it.  Instead, we always explicitly send
	// client_encoding as a separate run-time parameter, which should override
	// anything set in options.
	if cfg.isset("client_encoding") && !isUTF8(cfg.ClientEncoding) {
		return Config{}, fmt.Errorf(`pq: unsupported client_encoding %q: must be absent or "UTF8"`, cfg.ClientEncoding)
	}
	// DateStyle needs a similar treatment.
	if cfg.isset("datestyle") && cfg.Datestyle != "ISO, MDY" {
		return Config{}, fmt.Errorf(`pq: unsupported datestyle %q: must be absent or "ISO, MDY"`, cfg.Datestyle)
	}
	cfg.ClientEncoding, cfg.Datestyle = "UTF8", "ISO, MDY"

	// Set default user if not explicitly provided.
	if !cfg.isset("user") {
		u, err := pqutil.User()
		if err != nil {
			return Config{}, err
		}
		cfg.User = u
	}

	// SSL is not necessary or supported over UNIX domain sockets.
	if nw, _ := cfg.network(); nw == "unix" {
		cfg.SSLMode = SSLModeDisable
	}

	return cfg, nil
}

func (cfg Config) network() (string, string) {
	if cfg.Hostaddr != (netip.Addr{}) {
		return "tcp", net.JoinHostPort(cfg.Hostaddr.String(), strconv.Itoa(int(cfg.Port)))
	}
	// UNIX domain sockets are either represented by an (absolute) file system
	// path or they live in the abstract name space (starting with an @).
	if filepath.IsAbs(cfg.Host) || strings.HasPrefix(cfg.Host, "@") {
		sockPath := filepath.Join(cfg.Host, ".s.PGSQL."+strconv.Itoa(int(cfg.Port)))
		return "unix", sockPath
	}
	return "tcp", net.JoinHostPort(cfg.Host, strconv.Itoa(int(cfg.Port)))
}

func (cfg *Config) fromEnv(env []string) error {
	e := make(map[string]string)
	for _, v := range env {
		k, v, ok := strings.Cut(v, "=")
		if !ok {
			continue
		}
		switch k {
		case "PGREQUIREAUTH", "PGCHANNELBINDING", "PGSERVICE", "PGSERVICEFILE", "PGREALM",
			"PGSSLCERTMODE", "PGSSLCOMPRESSION", "PGREQUIRESSL", "PGSSLCRL", "PGREQUIREPEER",
			"PGSYSCONFDIR", "PGLOCALEDIR", "PGSSLCRLDIR", "PGSSLMINPROTOCOLVERSION", "PGSSLMAXPROTOCOLVERSION",
			"PGGSSENCMODE", "PGGSSDELEGATION", "PGMINPROTOCOLVERSION", "PGMAXPROTOCOLVERSION", "PGGSSLIB":
			return fmt.Errorf("pq: environment variable $%s is not supported", k)
		case "PGKRBSRVNAME":
			if newGss == nil {
				return fmt.Errorf("pq: environment variable $%s is not supported as Kerberos is not enabled", k)
			}
		}
		e[k] = v
	}
	return cfg.setFromTag(e, "env")
}

// parseOpts parses the options from name and adds them to the values.
//
// The parsing code is based on conninfo_parse from libpq's fe-connect.c
func (cfg *Config) fromDSN(dsn string) error {
	if strings.HasPrefix(dsn, "postgres://") || strings.HasPrefix(dsn, "postgresql://") {
		var err error
		dsn, err = convertURL(dsn)
		if err != nil {
			return err
		}
	}

	var (
		opt  = make(map[string]string)
		s    = []rune(dsn)
		i    int
		next = func() (rune, bool) {
			if i >= len(s) {
				return 0, false
			}
			r := s[i]
			i++
			return r, true
		}
		skipSpaces = func() (rune, bool) {
			r, ok := next()
			for unicode.IsSpace(r) && ok {
				r, ok = next()
			}
			return r, ok
		}
	)

	for {
		var (
			keyRunes, valRunes []rune
			r                  rune
			ok                 bool
		)

		if r, ok = skipSpaces(); !ok {
			break
		}

		// Scan the key
		for !unicode.IsSpace(r) && r != '=' {
			keyRunes = append(keyRunes, r)
			if r, ok = next(); !ok {
				break
			}
		}

		// Skip any whitespace if we're not at the = yet
		if r != '=' {
			r, ok = skipSpaces()
		}

		// The current character should be =
		if r != '=' || !ok {
			return fmt.Errorf(`missing "=" after %q in connection info string"`, string(keyRunes))
		}

		// Skip any whitespace after the =
		if r, ok = skipSpaces(); !ok {
			// If we reach the end here, the last value is just an empty string as per libpq.
			opt[string(keyRunes)] = ""
			break
		}

		if r != '\'' {
			for !unicode.IsSpace(r) {
				if r == '\\' {
					if r, ok = next(); !ok {
						return fmt.Errorf(`missing character after backslash`)
					}
				}
				valRunes = append(valRunes, r)

				if r, ok = next(); !ok {
					break
				}
			}
		} else {
		quote:
			for {
				if r, ok = next(); !ok {
					return fmt.Errorf(`unterminated quoted string literal in connection string`)
				}
				switch r {
				case '\'':
					break quote
				case '\\':
					r, _ = next()
					fallthrough
				default:
					valRunes = append(valRunes, r)
				}
			}
		}

		opt[string(keyRunes)] = string(valRunes)
	}

	return cfg.setFromTag(opt, "postgres")
}

func (cfg *Config) setFromTag(o map[string]string, tag string) error {
	f := "pq: wrong value for %q: "
	if tag == "env" {
		f = "pq: wrong value for $%s: "
	}
	var (
		types  = reflect.TypeOf(cfg).Elem()
		values = reflect.ValueOf(cfg).Elem()
	)
	for i := 0; i < types.NumField(); i++ {
		var (
			rt                 = types.Field(i)
			rv                 = values.Field(i)
			k                  = rt.Tag.Get(tag)
			connectTimeout     = (tag == "postgres" && k == "connect_timeout") || (tag == "env" && k == "PGCONNECT_TIMEOUT")
			host               = (tag == "postgres" && k == "host") || (tag == "env" && k == "PGHOST")
			hostaddr           = (tag == "postgres" && k == "hostaddr") || (tag == "env" && k == "PGHOSTADDR")
			port               = (tag == "postgres" && k == "port") || (tag == "env" && k == "PGPORT")
			sslmode            = (tag == "postgres" && k == "sslmode") || (tag == "env" && k == "PGSSLMODE")
			sslnegotiation     = (tag == "postgres" && k == "sslnegotiation") || (tag == "env" && k == "PGSSLNEGOTIATION")
			targetsessionattrs = (tag == "postgres" && k == "target_session_attrs") || (tag == "env" && k == "PGTARGETSESSIONATTRS")
			loadbalancehosts   = (tag == "postgres" && k == "load_balance_hosts") || (tag == "env" && k == "PGLOADBALANCEHOSTS")
		)
		if k == "" || k == "-" {
			continue
		}

		v, ok := o[k]
		delete(o, k)
		if ok {
			if t, ok := rt.Tag.Lookup("postgres"); ok && t != "" && t != "-" {
				cfg.set = append(cfg.set, t)
			}
			switch rt.Type.Kind() {
			default:
				return fmt.Errorf("don't know how to set %s: unknown type %s", rt.Name, rt.Type.Kind())
			case reflect.Struct:
				if rt.Type == reflect.TypeOf(netip.Addr{}) {
					if hostaddr {
						vv := strings.Split(v, ",")
						v = vv[0]
						for _, vvv := range vv[1:] {
							if vvv == "" {
								cfg.multiHostaddr = append(cfg.multiHostaddr, netip.Addr{})
							} else {
								ip, err := netip.ParseAddr(vvv)
								if err != nil {
									return fmt.Errorf(f+"%w", k, err)
								}
								cfg.multiHostaddr = append(cfg.multiHostaddr, ip)
							}
						}
					}
					ip, err := netip.ParseAddr(v)
					if err != nil {
						return fmt.Errorf(f+"%w", k, err)
					}
					rv.Set(reflect.ValueOf(ip))
				} else {
					return fmt.Errorf("don't know how to set %s: unknown type %s", rt.Name, rt.Type)
				}
			case reflect.String:
				if sslmode && !slices.Contains(sslModes, SSLMode(v)) && !(strings.HasPrefix(v, "pqgo-") && hasTLSConfig(v[5:])) {
					return fmt.Errorf(f+`%q is not supported; supported values are %s`, k, v, pqutil.Join(sslModes))
				}
				if sslnegotiation && !slices.Contains(sslNegotiations, SSLNegotiation(v)) {
					return fmt.Errorf(f+`%q is not supported; supported values are %s`, k, v, pqutil.Join(sslNegotiations))
				}
				if targetsessionattrs && !slices.Contains(targetSessionAttrs, TargetSessionAttrs(v)) {
					return fmt.Errorf(f+`%q is not supported; supported values are %s`, k, v, pqutil.Join(targetSessionAttrs))
				}
				if loadbalancehosts && !slices.Contains(loadBalanceHosts, LoadBalanceHosts(v)) {
					return fmt.Errorf(f+`%q is not supported; supported values are %s`, k, v, pqutil.Join(loadBalanceHosts))
				}
				if host {
					vv := strings.Split(v, ",")
					v = vv[0]
					for i, vvv := range vv[1:] {
						if vvv == "" {
							vv[i+1] = "localhost"
						}
					}
					cfg.multiHost = append(cfg.multiHost, vv[1:]...)
				}
				rv.SetString(v)
			case reflect.Int64:
				n, err := strconv.ParseInt(v, 10, 64)
				if err != nil {
					return fmt.Errorf(f+"%w", k, err)
				}
				if connectTimeout {
					n = int64(time.Duration(n) * time.Second)
				}
				rv.SetInt(n)
			case reflect.Uint16:
				if port {
					vv := strings.Split(v, ",")
					v = vv[0]
					for _, vvv := range vv[1:] {
						if vvv == "" {
							vvv = "5432"
						}
						n, err := strconv.ParseUint(vvv, 10, 16)
						if err != nil {
							return fmt.Errorf(f+"%w", k, err)
						}
						cfg.multiPort = append(cfg.multiPort, uint16(n))
					}
				}
				n, err := strconv.ParseUint(v, 10, 16)
				if err != nil {
					return fmt.Errorf(f+"%w", k, err)
				}
				rv.SetUint(n)
			case reflect.Bool:
				b, err := pqutil.ParseBool(v)
				if err != nil {
					return fmt.Errorf(f+"%w", k, err)
				}
				rv.SetBool(b)
			}
		}
	}

	// Set run-time; we delete map keys as they're set in the struct.
	if tag == "postgres" {
		// Make sure database= sets dbname=, as that previously worked (kind of
		// by accident).
		// TODO(v2): remove
		if d, ok := o["database"]; ok {
			cfg.Database = d
			delete(o, "database")
		}
		cfg.Runtime = o
	}

	return nil
}

func (cfg Config) isset(name string) bool {
	return slices.Contains(cfg.set, name)
}

// Convert to a map; used only in tests.
func (cfg Config) tomap() map[string]string {
	var (
		o      = make(map[string]string)
		values = reflect.ValueOf(cfg)
		types  = reflect.TypeOf(cfg)
	)
	for i := 0; i < types.NumField(); i++ {
		var (
			rt = types.Field(i)
			rv = values.Field(i)
			k  = rt.Tag.Get("postgres")
		)
		if k == "" || k == "-" {
			continue
		}
		if !rv.IsZero() || slices.Contains(cfg.set, k) {
			switch rt.Type.Kind() {
			default:
				if s, ok := rv.Interface().(fmt.Stringer); ok {
					o[k] = s.String()
				} else {
					o[k] = rv.String()
				}
			case reflect.Uint16:
				n := rv.Uint()
				o[k] = strconv.FormatUint(n, 10)
			case reflect.Int64:
				n := rv.Int()
				if k == "connect_timeout" {
					n = int64(time.Duration(n) / time.Second)
				}
				o[k] = strconv.FormatInt(n, 10)
			case reflect.Bool:
				if rv.Bool() {
					o[k] = "yes"
				} else {
					o[k] = "no"
				}
			}
		}
	}
	for k, v := range cfg.Runtime {
		o[k] = v
	}
	return o
}

// Create DSN for this config; used only in tests.
func (cfg Config) string() string {
	var (
		m    = cfg.tomap()
		keys = make([]string, 0, len(m))
	)
	for k := range m {
		switch k {
		case "datestyle", "client_encoding":
			continue
		case "host", "port", "user", "sslsni":
			if !cfg.isset(k) {
				continue
			}
		}
		if k == "host" && len(cfg.multiHost) > 0 {
			m[k] += "," + strings.Join(cfg.multiHost, ",")
		}
		if k == "hostaddr" && len(cfg.multiHostaddr) > 0 {
			for _, ha := range cfg.multiHostaddr {
				m[k] += ","
				if ha != (netip.Addr{}) {
					m[k] += ha.String()
				}
			}
		}
		if k == "port" && len(cfg.multiPort) > 0 {
			for _, p := range cfg.multiPort {
				m[k] += "," + strconv.Itoa(int(p))
			}
		}
		keys = append(keys, k)
	}
	sort.Strings(keys)

	var b strings.Builder
	for i, k := range keys {
		if i > 0 {
			b.WriteByte(' ')
		}
		b.WriteString(k)
		b.WriteByte('=')
		var (
			v     = m[k]
			nv    = make([]rune, 0, len(v)+2)
			quote = v == ""
		)
		for _, c := range v {
			if c == ' ' {
				quote = true
			}
			if c == '\'' {
				nv = append(nv, '\\')
			}
			nv = append(nv, c)
		}
		if quote {
			b.WriteByte('\'')
		}
		b.WriteString(string(nv))
		if quote {
			b.WriteByte('\'')
		}
	}
	return b.String()
}

// Recognize all sorts of silly things as "UTF-8", like Postgres does
func isUTF8(name string) bool {
	s := strings.Map(func(c rune) rune {
		if 'A' <= c && c <= 'Z' {
			return c + ('a' - 'A')
		}
		if 'a' <= c && c <= 'z' || '0' <= c && c <= '9' {
			return c
		}
		return -1 // discard
	}, name)
	return s == "utf8" || s == "unicode"
}

func convertURL(url string) (string, error) {
	u, err := neturl.Parse(url)
	if err != nil {
		return "", err
	}

	if u.Scheme != "postgres" && u.Scheme != "postgresql" {
		return "", fmt.Errorf("invalid connection protocol: %s", u.Scheme)
	}

	var kvs []string
	escaper := strings.NewReplacer(`'`, `\'`, `\`, `\\`)
	accrue := func(k, v string) {
		if v != "" {
			kvs = append(kvs, k+"='"+escaper.Replace(v)+"'")
		}
	}

	if u.User != nil {
		pw, _ := u.User.Password()
		accrue("user", u.User.Username())
		accrue("password", pw)
	}

	if host, port, err := net.SplitHostPort(u.Host); err != nil {
		accrue("host", u.Host)
	} else {
		accrue("host", host)
		accrue("port", port)
	}

	if u.Path != "" {
		accrue("dbname", u.Path[1:])
	}

	q := u.Query()
	for k := range q {
		accrue(k, q.Get(k))
	}

	sort.Strings(kvs) // Makes testing easier (not a performance concern)
	return strings.Join(kvs, " "), nil
}
