package options

import (
	"fmt"
	"regexp"
	"strings"

	"github.com/apache/cloudberry-backup/utils"
	"github.com/apache/cloudberry-go-libs/dbconn"
	"github.com/apache/cloudberry-go-libs/gplog"
	"github.com/apache/cloudberry-go-libs/iohelper"
	"github.com/pkg/errors"
	"github.com/spf13/pflag"
)

// This is meant to be a read only package. Values inside should only be
// modified by setters, it's method functions, or initialization function.
// This package is meant to make mocking flags easier.
type Options struct {
	IncludedRelations         []string
	ExcludedRelations         []string
	isLeafPartitionData       bool
	ExcludedSchemas           []string
	IncludedSchemas           []string
	originalIncludedRelations []string
	RedirectSchema            string
}

func NewOptions(initialFlags *pflag.FlagSet) (*Options, error) {
	includedRelations, err := setFiltersFromFile(initialFlags, INCLUDE_RELATION, INCLUDE_RELATION_FILE)
	if err != nil {
		return nil, err
	}
	err = utils.ValidateFQNs(includedRelations)
	if err != nil {
		return nil, err
	}

	excludedRelations, err := setFiltersFromFile(initialFlags, EXCLUDE_RELATION, EXCLUDE_RELATION_FILE)
	if err != nil {
		return nil, err
	}
	err = utils.ValidateFQNs(excludedRelations)
	if err != nil {
		return nil, err
	}

	includedSchemas, err := setFiltersFromFile(initialFlags, INCLUDE_SCHEMA, INCLUDE_SCHEMA_FILE)
	if err != nil {
		return nil, err
	}

	excludedSchemas, err := setFiltersFromFile(initialFlags, EXCLUDE_SCHEMA, EXCLUDE_SCHEMA_FILE)
	if err != nil {
		return nil, err
	}

	leafPartitionData, err := initialFlags.GetBool(LEAF_PARTITION_DATA)
	if err != nil {
		return nil, err
	}

	redirectSchema := ""
	if initialFlags.Lookup(REDIRECT_SCHEMA) != nil {
		redirectSchema, err = initialFlags.GetString(REDIRECT_SCHEMA)
		if err != nil {
			return nil, err
		}
	}

	return &Options{
		IncludedRelations:         includedRelations,
		ExcludedRelations:         excludedRelations,
		IncludedSchemas:           includedSchemas,
		ExcludedSchemas:           excludedSchemas,
		isLeafPartitionData:       leafPartitionData,
		originalIncludedRelations: includedRelations,
		RedirectSchema:            redirectSchema,
	}, nil
}

func setFiltersFromFile(initialFlags *pflag.FlagSet, filterFlag string, filterFileFlag string) ([]string, error) {
	filters, err := initialFlags.GetStringArray(filterFlag)
	if err != nil {
		return nil, err
	}
	// values obtained from file filterFileFlag are copied to values in filterFlag
	// values are mutually exclusive so this is not an overwrite, it is a "fresh" setting
	filename, err := initialFlags.GetString(filterFileFlag)
	if err != nil {
		return nil, err
	}
	if filename != "" {
		filterLines, err := iohelper.ReadLinesFromFile(filename)
		if err != nil {
			return nil, err
		}
		// copy any values for flag filterFileFlag into global flag for filterFlag
		for _, fqn := range filterLines {
			if fqn != "" {
				filters = append(filters, fqn)          //This appends filter to options
				err = initialFlags.Set(filterFlag, fqn) //This appends to the slice underlying the flag.
				if err != nil {
					return nil, err
				}
			}
		}
		if err != nil {
			return nil, err
		}
	}
	return filters, nil
}

func (o Options) GetIncludedTables() []string {
	return o.IncludedRelations
}

func (o Options) GetOriginalIncludedTables() []string {
	return o.originalIncludedRelations
}

func (o Options) GetExcludedTables() []string {
	return o.ExcludedRelations
}

func (o Options) IsLeafPartitionData() bool {
	return o.isLeafPartitionData
}

func (o Options) GetIncludedSchemas() []string {
	return o.IncludedSchemas
}

func (o Options) GetExcludedSchemas() []string {
	return o.ExcludedSchemas
}

func (o *Options) AddIncludedRelation(relation string) {
	o.IncludedRelations = append(o.IncludedRelations, relation)
}

type Relation struct {
	SchemaOid uint32
	Oid       uint32
	Schema    string
	Name      string
}

func QuoteTableNames(conn *dbconn.DBConn, tableNames []string) ([]string, error) {
	if len(tableNames) == 0 {
		return []string{}, nil
	}

	fqnSlice, err := SeparateSchemaAndTable(tableNames)
	if err != nil {
		return nil, err
	}
	result := make([]string, 0)

	quoteIdentTableFQNQuery := `SELECT quote_ident('%s') AS schemaname, quote_ident('%s') AS tablename`
	for _, fqn := range fqnSlice {
		queryResultTable := make([]struct {
			SchemaName string
			TableName  string
		}, 0)
		query := fmt.Sprintf(quoteIdentTableFQNQuery, fqn.Schema, fqn.Name)
		err := conn.Select(&queryResultTable, query)
		if err != nil {
			return nil, err
		}
		quoted := queryResultTable[0].SchemaName + "." + queryResultTable[0].TableName
		result = append(result, quoted)
	}

	return result, nil
}

func SeparateSchemaAndTable(tableNames []string) ([]Relation, error) {
	fqnSlice := make([]Relation, 0)

	for _, fqn := range tableNames {

		var quotesPattern *regexp.Regexp
		schemaNameIsQuoted := strings.HasPrefix(fqn, "\"")
		tableNameIsQuoted := strings.HasSuffix(fqn, "\"")

		if schemaNameIsQuoted && tableNameIsQuoted {
			quotesPattern = regexp.MustCompile(`^"|"$|"\."`)
		} else if schemaNameIsQuoted {
			quotesPattern = regexp.MustCompile(`^"|"\.`)
		} else if tableNameIsQuoted {
			quotesPattern = regexp.MustCompile(`"$|\."`)
		} else {
			quotesPattern = regexp.MustCompile(`\.`)
		}
		parts := quotesPattern.Split(fqn, -1)

		fqnParts := make([]string, 0)
		for _, part := range parts {
			if len(part) > 0 {
				fqnParts = append(fqnParts, part)
			}
		}

		if len(fqnParts) != 2 {
			return nil, errors.Errorf("cannot process this Fully Qualified Name: %s", fqn)
		}

		// Properly escape quotes before running quote ident. Postgres
		// quote_ident escapes quotes by doubling them
		schema := utils.EscapeSingleQuotes(fqnParts[0])
		table := utils.EscapeSingleQuotes(fqnParts[1])

		currFqn := Relation{
			SchemaOid: 0,
			Oid:       0,
			Schema:    schema,
			Name:      table,
		}

		fqnSlice = append(fqnSlice, currFqn)
	}

	return fqnSlice, nil
}

func (o *Options) QuoteIncludeRelations(conn *dbconn.DBConn) error {
	var err error
	o.IncludedRelations, err = QuoteTableNames(conn, o.GetIncludedTables())
	if err != nil {
		return err
	}

	return nil
}

func (o *Options) QuoteExcludeRelations(conn *dbconn.DBConn) error {
	var err error
	o.ExcludedRelations, err = QuoteTableNames(conn, o.GetExcludedTables())
	if err != nil {
		return err
	}

	return nil
}

// given a set of table oids, return a deduplicated set of other tables that EITHER depend
// on them, OR that they depend on. The behavior for which is set with recurseDirection.
func (o *Options) recurseTableDepend(conn *dbconn.DBConn, includeOids []string, tablesToRetrieve string, getLeafPartitions bool) ([]string, error) {
	var err error
	var dependQuery string

	expandedIncludeOids := make(map[string]bool)
	for _, oid := range includeOids {
		expandedIncludeOids[oid] = true
	}

	parentsQuery := `
			SELECT dep.refobjid
			FROM
				pg_depend dep
				INNER JOIN pg_class cls ON dep.refobjid = cls.oid
			WHERE
				dep.objid IN (%[1]s)
				AND cls.relkind IN ('r', 'p', 'f')`
	childrenQuery := `
			SELECT dep.objid
			FROM
				pg_depend dep
				INNER JOIN pg_class cls ON dep.objid = cls.oid
			WHERE
				dep.refobjid IN (%[1]s)
				AND cls.relkind IN ('r', 'p', 'f')`

	if conn.Version.IsGPDB() && conn.Version.Before("7") {
		if getLeafPartitions {
			// Get external partitions and leaves in the include list
			// Also get leaves of root partitions that currently have no leaves in the include list,
			// i.e. those that have no filtering on their leaves and so should be fully expanded.
			childrenQuery += `
				AND (
					-- External partitions
					dep.objid NOT IN (SELECT parchildrelid FROM pg_partition_rule WHERE parchildrelid NOT IN (SELECT reloid FROM pg_exttable))
					-- Leaf partitions in the include list
					OR dep.objid IN (%[1]s)
					-- Leaves of root partitions that currently have no leaves in the include list
					OR dep.refobjid NOT IN (SELECT parrelid FROM pg_partition p JOIN pg_partition_rule r ON p.oid = r.paroid WHERE r.parchildrelid in (%[1]s))
				)`
		} else {
			// Only get external partitions; specifically, exclude non-external leaf partitions, since we can't just
			// grab external partitions because we need to deal with non-partition tables as well.
			childrenQuery += `
				AND dep.objid NOT IN (SELECT parchildrelid FROM pg_partition_rule WHERE parchildrelid NOT IN (SELECT reloid FROM pg_exttable))`
		}
	}

	if tablesToRetrieve == "parents" {
		dependQuery = parentsQuery
	} else if tablesToRetrieve == "children" {
		dependQuery = childrenQuery
	} else {
		gplog.Error(`Please fix calling of this function recurseTableDepend. Argument tablesToRetrieve only accepts "parents" or "children".`)
	}

	// here we loop until no further table dependencies are found.  implemented iteratively, but functions like a recursion
	foundDeps := true
	loopOids := includeOids
	for foundDeps {
		foundDeps = false
		depOids := make([]string, 0)
		loopDepQuery := fmt.Sprintf(dependQuery, strings.Join(loopOids, ", "))
		err = conn.Select(&depOids, loopDepQuery)
		if err != nil {
			gplog.Warn("Table dependency query failed: %s", loopDepQuery)
			return nil, err
		}

		// confirm that any table dependencies are found
		// save the table dependencies for both output and for next recursion
		loopOids = loopOids[:]
		for _, depOid := range depOids {
			// must exclude oids already captured to avoid circular dependencies
			// causing an infinite loop
			if !expandedIncludeOids[depOid] {
				foundDeps = true
				loopOids = append(loopOids, depOid)
				expandedIncludeOids[depOid] = true
			}
		}
	}

	// capture deduplicated oids from map keys, return as array
	// done as a direct array assignment loop because it's faster and we know the length
	expandedIncludeOidsArr := make([]string, len(expandedIncludeOids))
	arrayIdx := 0
	for idx := range expandedIncludeOids {
		expandedIncludeOidsArr[arrayIdx] = idx
		arrayIdx++
	}
	return expandedIncludeOidsArr, err
}

func (o Options) GetUserTableRelationsWithIncludeFiltering(connectionPool *dbconn.DBConn, includeOids []string, no_inherits bool) ([]Relation, error) {

	oidStr := strings.Join(includeOids, ", ")
	var childPartitionFilter, parentAndExternalPartitionFilter string

	// If --no-inherits is passed, do not expand to parents or children, and just pass through the
	// initial list of filtered tables to populate the Relation structs.
	if !no_inherits {
		// GPDB7+ reworks the nature of partition tables.  It is no longer sufficient
		// to pull parents and children in one step.  Instead we must recursively climb/descend
		// the pg_depend ladder, filtering to only members of pg_class at each step, until the
		// full hierarchy has been retrieved.
		//
		// While we could query pg_partition and pg_partition_rule for this information in 6 and
		// earlier, this inheritance-based approach still works in those earlier versions and it's
		// good to keep the logic as similar as possible between the earlier and later versions.

		// Step 1: Get all children
		childOids, err := o.recurseTableDepend(connectionPool, includeOids, "children", o.isLeafPartitionData)
		if err != nil {
			return nil, err
		}
		if len(childOids) > 0 {
			childPartitionFilter = fmt.Sprintf(`OR c.oid IN (%s)`, strings.Join(childOids, ", "))
		}
		includeOids = childOids

		// Step 2: Get all parents, both of the original included tables and of the children retrieved in step 1.
		// This ensures that if e.g. a child table inherits from another table not in the include list then it, too,
		// will be backed up correctly.
		parentOids, err := o.recurseTableDepend(connectionPool, includeOids, "parents", o.isLeafPartitionData)
		if err != nil {
			return nil, err
		}
		if len(parentOids) > 0 {
			parentAndExternalPartitionFilter = fmt.Sprintf(`OR c.oid IN (%s)`, strings.Join(parentOids, ", "))
		}
		includeOids = parentOids

		// Step 3: In GPDB 6 and earlier, retrieve children a second time, as if any parents retrieved in step 2 are
		// partition roots we need to retrieve their included and external partitions.
		// This does not apply to GPDB 7 and later, because partition tables are created individually and so we don't
		// care about the metadata of sibling tables of any included leaf partitions.
		if connectionPool.Version.IsGPDB() && connectionPool.Version.Before("7") {
			childOids, err := o.recurseTableDepend(connectionPool, includeOids, "children", o.isLeafPartitionData)
			if err != nil {
				return nil, err
			}
			if len(childOids) > 0 {
				childPartitionFilter = fmt.Sprintf(`OR c.oid IN (%s)`, strings.Join(childOids, ", "))
			}
			includeOids = childOids
		}
	}

	query := fmt.Sprintf(`
SELECT
    n.oid as schemaoid,
    c.oid as oid,
	n.nspname AS schema,
	c.relname AS name
FROM pg_class c
JOIN pg_namespace n
	ON c.relnamespace = n.oid
WHERE %s
AND (
	-- Get tables in the include list
	c.oid IN (%s)
	%s
	%s
)
AND relkind IN ('r', 'f', 'p')
AND %s
ORDER BY c.oid;`, o.schemaFilterClause("n"), oidStr, parentAndExternalPartitionFilter, childPartitionFilter, ExtensionFilterClause("c"))

	results := make([]Relation, 0)
	err := connectionPool.Select(&results, query)

	return results, err
}

// A list of schemas we don't want to back up, formatted for use in a WHERE clause
func (o Options) schemaFilterClause(namespace string) string {
	schemaFilterClauseStr := ""
	if len(o.GetIncludedSchemas()) > 0 {
		schemaFilterClauseStr = fmt.Sprintf("\nAND %s.nspname IN (%s)", namespace, utils.SliceToQuotedString(o.GetIncludedSchemas()))
	}
	if len(o.GetExcludedSchemas()) > 0 {
		schemaFilterClauseStr = fmt.Sprintf("\nAND %s.nspname NOT IN (%s)", namespace, utils.SliceToQuotedString(o.GetExcludedSchemas()))
	}
	return fmt.Sprintf(`%s.nspname NOT LIKE 'pg_temp_%%' AND %s.nspname NOT LIKE 'pg_toast%%' AND %s.nspname NOT IN ('gp_toolkit', 'information_schema', 'pg_aoseg', 'pg_bitmapindex', 'pg_catalog', 'pg_ext_aux') %s`, namespace, namespace, namespace, schemaFilterClauseStr)
}

func ExtensionFilterClause(namespace string) string {
	oidStr := "oid"
	if namespace != "" {
		oidStr = fmt.Sprintf("%s.oid", namespace)
	}

	return fmt.Sprintf("%s NOT IN (select objid from pg_depend where deptype = 'e')", oidStr)
}
