package core

import (
	"bytes"
	"database/sql"
	"encoding/json/v2"
	"errors"
	"fmt"
	"slices"
	"strings"

	"github.com/pocketbase/dbx"
	"github.com/pocketbase/pocketbase/tools/list"
)

const StoreKeyCachedCollections = "pbAppCachedCollections"

// CollectionQuery returns a new Collection select query.
func (app *BaseApp) CollectionQuery() *dbx.SelectQuery {
	return app.ModelQuery(&Collection{})
}

// FindCollections finds all collections by the given type(s).
//
// If collectionTypes is not set, it returns all collections.
//
// Example:
//
//	app.FindAllCollections() // all collections
//	app.FindAllCollections("auth", "view") // only auth and view collections
func (app *BaseApp) FindAllCollections(collectionTypes ...string) ([]*Collection, error) {
	collections := []*Collection{}

	q := app.CollectionQuery()

	types := list.NonzeroUniques(collectionTypes)
	if len(types) > 0 {
		q.AndWhere(dbx.In("type", list.ToInterfaceSlice(types)...))
	}

	err := q.OrderBy("rowid ASC").All(&collections)
	if err != nil {
		return nil, err
	}

	return collections, nil
}

// ReloadCachedCollections fetches all collections and caches them into the app store.
func (app *BaseApp) ReloadCachedCollections() error {
	collections, err := app.FindAllCollections()
	if err != nil {
		return err
	}

	app.Store().Set(StoreKeyCachedCollections, collections)

	return nil
}

// FindCollectionByNameOrId finds a single collection by its name (case insensitive) or id.
func (app *BaseApp) FindCollectionByNameOrId(nameOrId string) (*Collection, error) {
	m := &Collection{}

	err := app.CollectionQuery().
		AndWhere(dbx.NewExp("[[id]]={:id} OR LOWER([[name]])={:name}", dbx.Params{
			"id":   nameOrId,
			"name": strings.ToLower(nameOrId),
		})).
		Limit(1).
		One(m)
	if err != nil {
		return nil, err
	}

	return m, nil
}

// FindCachedCollectionByNameOrId is similar to [BaseApp.FindCollectionByNameOrId]
// but retrieves the Collection from the app cache instead of making a db call.
//
// NB! This method is suitable for read-only Collection operations.
//
// Returns [sql.ErrNoRows] if no Collection is found for consistency
// with the [BaseApp.FindCollectionByNameOrId] method.
//
// If you plan making changes to the returned Collection model,
// use [BaseApp.FindCollectionByNameOrId] instead.
//
// Caveats:
//
//   - The returned Collection should be used only for read-only operations.
//     Avoid directly modifying the returned cached Collection as it will affect
//     the global cached value even if you don't persist the changes in the database!
//   - If you are updating a Collection in a transaction and then call this method before commit,
//     it'll return the cached Collection state and not the one from the uncommitted transaction.
//   - The cache is automatically updated on collections db change (create/update/delete).
//     To manually reload the cache you can call [BaseApp.ReloadCachedCollections].
func (app *BaseApp) FindCachedCollectionByNameOrId(nameOrId string) (*Collection, error) {
	collections, _ := app.Store().Get(StoreKeyCachedCollections).([]*Collection)
	if collections == nil {
		// cache is not initialized yet (eg. run in a system migration)
		return app.FindCollectionByNameOrId(nameOrId)
	}

	for _, c := range collections {
		if strings.EqualFold(c.Name, nameOrId) || c.Id == nameOrId {
			return c, nil
		}
	}

	return nil, sql.ErrNoRows
}

// FindCollectionReferences returns information for all relation fields
// referencing the provided collection.
//
// If the provided collection has reference to itself then it will be
// also included in the result. To exclude it, pass the collection id
// as the excludeIds argument.
func (app *BaseApp) FindCollectionReferences(collection *Collection, excludeIds ...string) (map[*Collection][]Field, error) {
	collections := []*Collection{}

	query := app.CollectionQuery()

	if uniqueExcludeIds := list.NonzeroUniques(excludeIds); len(uniqueExcludeIds) > 0 {
		query.AndWhere(dbx.NotIn("id", list.ToInterfaceSlice(uniqueExcludeIds)...))
	}

	if err := query.All(&collections); err != nil {
		return nil, err
	}

	result := map[*Collection][]Field{}

	for _, c := range collections {
		for _, rawField := range c.Fields {
			f, ok := rawField.(*RelationField)
			if ok && f.CollectionId == collection.Id {
				result[c] = append(result[c], f)
			}
		}
	}

	return result, nil
}

// FindCachedCollectionReferences is similar to [BaseApp.FindCollectionReferences]
// but retrieves the Collection from the app cache instead of making a db call.
//
// NB! This method is suitable for read-only Collection operations.
//
// If you plan making changes to the returned Collection model,
// use [BaseApp.FindCollectionReferences] instead.
//
// Caveats:
//
//   - The returned Collection should be used only for read-only operations.
//     Avoid directly modifying the returned cached Collection as it will affect
//     the global cached value even if you don't persist the changes in the database!
//   - If you are updating a Collection in a transaction and then call this method before commit,
//     it'll return the cached Collection state and not the one from the uncommitted transaction.
//   - The cache is automatically updated on collections db change (create/update/delete).
//     To manually reload the cache you can call [BaseApp.ReloadCachedCollections].
func (app *BaseApp) FindCachedCollectionReferences(collection *Collection, excludeIds ...string) (map[*Collection][]Field, error) {
	collections, _ := app.Store().Get(StoreKeyCachedCollections).([]*Collection)
	if collections == nil {
		// cache is not initialized yet (eg. run in a system migration)
		return app.FindCollectionReferences(collection, excludeIds...)
	}

	result := map[*Collection][]Field{}

	for _, c := range collections {
		if slices.Contains(excludeIds, c.Id) {
			continue
		}

		for _, rawField := range c.Fields {
			f, ok := rawField.(*RelationField)
			if ok && f.CollectionId == collection.Id {
				result[c] = append(result[c], f)
			}
		}
	}

	return result, nil
}

// IsCollectionNameUnique checks that there is no existing collection
// with the provided name (case insensitive!).
//
// Note: case insensitive check because the name is used also as
// table name for the records.
func (app *BaseApp) IsCollectionNameUnique(name string, excludeIds ...string) bool {
	if name == "" {
		return false
	}

	query := app.CollectionQuery().
		Select("count(*)").
		AndWhere(dbx.NewExp("LOWER([[name]])={:name}", dbx.Params{"name": strings.ToLower(name)})).
		Limit(1)

	if uniqueExcludeIds := list.NonzeroUniques(excludeIds); len(uniqueExcludeIds) > 0 {
		query.AndWhere(dbx.NotIn("id", list.ToInterfaceSlice(uniqueExcludeIds)...))
	}

	var total int

	return query.Row(&total) == nil && total == 0
}

// TruncateCollection deletes all records associated with the provided collection.
//
// The truncate operation is executed in a single transaction,
// aka. either everything is deleted or none.
//
// Note that this method will also trigger the records related
// cascade and file delete actions.
func (app *BaseApp) TruncateCollection(collection *Collection) error {
	if collection.IsView() {
		return errors.New("view collections cannot be truncated since they don't store their own records")
	}

	return app.RunInTransaction(func(txApp App) error {
		records := make([]*Record, 0, 500)

		for {
			err := txApp.RecordQuery(collection).Limit(500).All(&records)
			if err != nil {
				return err
			}

			if len(records) == 0 {
				return nil
			}

			for _, record := range records {
				err = txApp.Delete(record)
				if err != nil && !errors.Is(err, sql.ErrNoRows) {
					return err
				}
			}

			records = records[:0]
		}
	})
}

// -------------------------------------------------------------------

// normalizeViewQueryId wraps (if necessary) the provided view query
// with a subselect to ensure that the id column is a text since
// currently we don't support non-string model ids
// (see https://github.com/pocketbase/pocketbase/issues/3110).
func normalizeViewQueryId(app App, query string) (string, error) {
	query = strings.Trim(strings.TrimSpace(query), ";")

	info, err := getQueryTableInfo(app, query)
	if err != nil {
		return "", err
	}

	for _, row := range info {
		if strings.EqualFold(row.Name, FieldNameId) && strings.EqualFold(row.Type, "TEXT") {
			return query, nil // no wrapping needed
		}
	}

	// raw parse to preserve the columns order
	rawParsed := new(identifiersParser)
	if err := rawParsed.parse(query); err != nil {
		return "", err
	}

	columns := make([]string, 0, len(rawParsed.columns))
	for _, col := range rawParsed.columns {
		if col.alias == FieldNameId {
			columns = append(columns, fmt.Sprintf("CAST([[%s]] as TEXT) [[%s]]", col.alias, col.alias))
		} else {
			columns = append(columns, "[["+col.alias+"]]")
		}
	}

	query = fmt.Sprintf("SELECT %s FROM (%s)", strings.Join(columns, ","), query)

	return query, nil
}

// resaveViewsWithChangedFields updates all view collections with changed fields.
func resaveViewsWithChangedFields(app App, excludeIds ...string) error {
	collections, err := app.FindAllCollections(CollectionTypeView)
	if err != nil {
		return err
	}

	return app.RunInTransaction(func(txApp App) error {
		var collectionErrors []error

		for _, collection := range collections {
			if len(excludeIds) > 0 && list.ExistInSlice(collection.Id, excludeIds) {
				continue
			}

			check := func() error {
				// clone the existing fields for temp modifications
				oldFields, err := collection.Fields.Clone()
				if err != nil {
					return err
				}

				// generate new fields from the query
				newFields, err := txApp.CreateViewFields(collection.ViewQuery)
				if err != nil {
					return err
				}

				// unset the fields' ids to exclude from the comparison
				for _, f := range oldFields {
					f.SetId("")
				}
				for _, f := range newFields {
					f.SetId("")
				}

				encodedNewFields, err := json.Marshal(newFields, json.Deterministic(true))
				if err != nil {
					return err
				}

				encodedOldFields, err := json.Marshal(oldFields, json.Deterministic(true))
				if err != nil {
					return err
				}

				if bytes.EqualFold(encodedNewFields, encodedOldFields) {
					return nil // no changes
				}

				return txApp.Save(collection)
			}

			if err := check(); err != nil {
				collectionErrors = append(
					collectionErrors,
					fmt.Errorf("[%s] %w", collection.Name, err),
				)
			}
		}

		return errors.Join(collectionErrors...)
	})
}
