// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package db

import (
	"errors"
	"fmt"
	"net"
	"net/url"
	"os"
	"path/filepath"
	"slices"
	"strings"

	"gitea.dev/modules/setting"
	"gitea.dev/modules/util"
)

type ConnOptions struct {
	Type     setting.DatabaseType
	Host     string
	Database string
	User     string
	Passwd   string
	Schema   string
	SSLMode  string

	SQLitePath        string
	SQLiteBusyTimeout int
	SQLiteJournalMode string
}

type SQLiteConnStrOptions struct {
	FilePath string
	// how long a concurrent query can wait for others (milliseconds),
	// if timeout is reached, the error is something like "database is locked (SQLITE_BUSY)"
	BusyTimeout int
	JournalMode string
}

func GlobalConnOptions() ConnOptions {
	return ConnOptions{
		Type:     setting.Database.Type,
		Host:     setting.Database.Host,
		Database: setting.Database.Name,
		User:     setting.Database.User,
		Passwd:   setting.Database.Passwd,
		Schema:   setting.Database.Schema,
		SSLMode:  setting.Database.SSLMode,

		SQLitePath:        setting.Database.Path,
		SQLiteBusyTimeout: setting.Database.SQLiteBusyTimeout,
		SQLiteJournalMode: setting.Database.SQLiteJournalMode,
	}
}

const (
	sqlDriverPostgresSchema = "postgresschema"
	sqlDriverSQLite3        = "sqlite3" // although database type also has "sqlite3", they are different, for different purposes
)

var makeSQLiteConnStr = func(opts SQLiteConnStrOptions) (string, string, error) {
	return "", "", errors.New(`this Gitea binary was not built with SQLite3 support, get an official release or rebuild with correct "-tags"`)
}

func registerSQLiteConnStrMaker(fn func(opts SQLiteConnStrOptions) (string, string, error)) {
	if slices.Contains(setting.SupportedDatabaseTypes, setting.DatabaseTypeSQLite3) {
		panic("another sqlite3 driver has been registered")
	}
	setting.SupportedDatabaseTypes = append(setting.SupportedDatabaseTypes, setting.DatabaseTypeSQLite3)
	makeSQLiteConnStr = fn
}

func ConnStrDefaultDatabase(opts ConnOptions) (string, string, error) {
	opts.Database, opts.Schema = "", ""
	return ConnStr(opts)
}

func ConnStr(opts ConnOptions) (string, string, error) {
	switch {
	case opts.Type.IsMySQL():
		// use unix socket or tcp socket
		connType := util.Iif(strings.HasPrefix(opts.Host, "/"), "unix", "tcp")
		// allow (Postgres-inspired) default value to work in MySQL
		tls := util.Iif(opts.SSLMode == "disable", "false", opts.SSLMode)
		// in case the database name is a partial connection string which contains "?" parameters
		paramSep := util.Iif(strings.Contains(opts.Database, "?"), "&", "?")
		connStr := fmt.Sprintf("%s:%s@%s(%s)/%s%sparseTime=true&tls=%s", opts.User, opts.Passwd, connType, opts.Host, opts.Database, paramSep, tls)
		return "mysql", connStr, nil

	case opts.Type.IsPostgreSQL():
		connStr := makePgSQLConnStr(opts.Host, opts.User, opts.Passwd, opts.Database, opts.SSLMode)
		driver := util.Iif(opts.Schema == "", "postgres", sqlDriverPostgresSchema)
		registerPostgresSchemaDriver()
		return driver, connStr, nil

	case opts.Type.IsMSSQL():
		host, port := parseMSSQLHostPort(opts.Host)
		connStr := fmt.Sprintf("server=%s; port=%s; user id=%s; password=%s;", host, port, opts.User, opts.Passwd)
		if opts.Database != "" {
			connStr += "; database=" + opts.Database
		}
		return "mssql", connStr, nil

	case opts.Type.IsSQLite3():
		if opts.SQLitePath == "" {
			return "", "", errors.New("sqlite3 database path cannot be empty")
		}
		if err := os.MkdirAll(filepath.Dir(opts.SQLitePath), os.ModePerm); err != nil {
			return "", "", fmt.Errorf("failed to create directories: %w", err)
		}
		return makeSQLiteConnStr(SQLiteConnStrOptions{
			FilePath:    opts.SQLitePath,
			JournalMode: opts.SQLiteJournalMode,
			BusyTimeout: opts.SQLiteBusyTimeout,
		})
	}
	return "", "", fmt.Errorf("unknown database type: %s", opts.Type)
}

// parsePgSQLHostPort parses given input in various forms defined in
// https://www.postgresql.org/docs/current/static/libpq-connect.html#LIBPQ-CONNSTRING
// and returns proper host and port number.
func parsePgSQLHostPort(info string) (host, port string) {
	if h, p, err := net.SplitHostPort(info); err == nil {
		host, port = h, p
	} else {
		// treat the "info" as "host", if it's an IPv6 address, remove the wrapper
		host = info
		if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
			host = host[1 : len(host)-1]
		}
	}

	// set fallback values
	if host == "" {
		host = "127.0.0.1"
	}
	if port == "" {
		port = "5432"
	}
	return host, port
}

func makePgSQLConnStr(dbHost, dbUser, dbPasswd, dbName, dbsslMode string) (connStr string) {
	dbName, dbParam, _ := strings.Cut(dbName, "?")
	host, port := parsePgSQLHostPort(dbHost)
	connURL := url.URL{
		Scheme:   "postgres",
		User:     url.UserPassword(dbUser, dbPasswd),
		Host:     net.JoinHostPort(host, port),
		Path:     dbName,
		OmitHost: false,
		RawQuery: dbParam,
	}
	query := connURL.Query()
	if strings.HasPrefix(host, "/") { // looks like a unix socket
		query.Add("host", host)
		connURL.Host = ":" + port
	}
	query.Set("sslmode", dbsslMode)
	connURL.RawQuery = query.Encode()
	return connURL.String()
}

// parseMSSQLHostPort splits the host into host and port
func parseMSSQLHostPort(info string) (string, string) {
	// the default port "0" might be related to MSSQL's dynamic port, maybe it should be double-confirmed in the future
	host, port := "127.0.0.1", "0"
	if strings.Contains(info, ":") {
		host = strings.Split(info, ":")[0]
		port = strings.Split(info, ":")[1]
	} else if strings.Contains(info, ",") {
		host = strings.Split(info, ",")[0]
		port = strings.TrimSpace(strings.Split(info, ",")[1])
	} else if len(info) > 0 {
		host = info
	}
	if host == "" {
		host = "127.0.0.1"
	}
	if port == "" {
		port = "0"
	}
	return host, port
}
