package collections

import (
	"context"
	"fmt"
	"regexp"
	"sort"
	"strings"

	core "cosmossdk.io/collections/corecompat"
)

// SchemaBuilder is used for building schemas. The Build method should always
// be called after all collections have been initialized. Initializing new
// collections with the builder after initialization will result in panics.
type SchemaBuilder struct {
	schema *Schema
	err    error
}

// NewSchemaBuilderFromAccessor creates a new schema builder from the provided store accessor function.
func NewSchemaBuilderFromAccessor(accessorFunc func(ctx context.Context) core.KVStore) *SchemaBuilder {
	return &SchemaBuilder{
		schema: &Schema{
			storeAccessor:       accessorFunc,
			collectionsByName:   map[string]Collection{},
			collectionsByPrefix: map[string]Collection{},
		},
	}
}

// NewSchemaBuilder creates a new schema builder from the provided store key.
// Callers should always call the SchemaBuilder.Build method when they are
// done adding collections to the schema.
func NewSchemaBuilder(service core.KVStoreService) *SchemaBuilder {
	return NewSchemaBuilderFromAccessor(service.OpenKVStore)
}

// Build should be called after all collections that are part of the schema
// have been initialized in order to get a reference to the Schema. It is
// important to check the returned error for any initialization errors.
// The SchemaBuilder CANNOT be used after Build is called - doing so will
// result in panics.
func (s *SchemaBuilder) Build() (Schema, error) {
	if s.err != nil {
		return Schema{}, s.err
	}

	// check for any overlapping prefixes
	for prefix := range s.schema.collectionsByPrefix {
		for prefix2 := range s.schema.collectionsByPrefix {
			// don't compare the prefix to itself
			if prefix == prefix2 {
				continue
			}

			// if one prefix is the prefix of the other we have an overlap and
			// this schema is corrupt
			if strings.HasPrefix(prefix, prefix2) {
				return Schema{}, fmt.Errorf("schema has overlapping prefixes 0x%x and 0x%x", prefix, prefix2)
			}
		}
	}

	// compute ordered collections
	collectionsOrdered := make([]string, 0, len(s.schema.collectionsByName))
	for name := range s.schema.collectionsByName {
		collectionsOrdered = append(collectionsOrdered, name)
	}
	sort.Strings(collectionsOrdered)
	s.schema.collectionsOrdered = collectionsOrdered

	if s.schema == nil {
		// explicit panic to avoid nil pointer dereference
		panic("builder already used to construct a schema")
	}

	schema := *s.schema

	s.schema = nil // this makes the builder unusable

	return schema, nil
}

func (s *SchemaBuilder) addCollection(collection Collection) {
	prefix := collection.GetPrefix()
	name := collection.GetName()

	if _, ok := s.schema.collectionsByPrefix[string(prefix)]; ok {
		s.appendError(fmt.Errorf("prefix %v already taken within schema", prefix))
		return
	}

	if _, ok := s.schema.collectionsByName[name]; ok {
		s.appendError(fmt.Errorf("name %s already taken within schema", name))
		return
	}

	if !nameRegex.MatchString(name) {
		s.appendError(fmt.Errorf("name must match regex %s, got %s", NameRegex, name))
		return
	}

	s.schema.collectionsByPrefix[string(prefix)] = collection
	s.schema.collectionsByName[name] = collection
}

func (s *SchemaBuilder) appendError(err error) {
	if s.err == nil {
		s.err = err
		return
	}
	s.err = fmt.Errorf("%w\n%w", s.err, err)
}

// NameRegex is the regular expression that all valid collection names must match.
const NameRegex = "[A-Za-z][A-Za-z0-9_]*"

var nameRegex = regexp.MustCompile("^" + NameRegex + "$")

// Schema specifies a group of collections stored within the storage specified
// by a single store key. All the collections within the schema must have a
// unique binary prefix and human-readable name. Schema will eventually include
// methods for importing/exporting genesis data and for schema reflection for
// clients.
type Schema struct {
	storeAccessor       func(context.Context) core.KVStore
	collectionsOrdered  []string
	collectionsByPrefix map[string]Collection
	collectionsByName   map[string]Collection
}

// NewSchema creates a new schema for the provided KVStoreService.
func NewSchema(service core.KVStoreService) Schema {
	return NewSchemaFromAccessor(func(ctx context.Context) core.KVStore {
		return service.OpenKVStore(ctx)
	})
}

// NewMemoryStoreSchema creates a new schema for the provided MemoryStoreService.
func NewMemoryStoreSchema(service core.MemoryStoreService) Schema {
	return NewSchemaFromAccessor(func(ctx context.Context) core.KVStore {
		return service.OpenMemoryStore(ctx)
	})
}

// NewSchemaFromAccessor creates a new schema for the provided store accessor
// function. Modules built against versions of the SDK which do not support
// the cosmossdk.io/core/appmodule APIs should use this method.
// Ex:

//	NewSchemaFromAccessor(func(ctx context.Context) store.KVStore {
//			return sdk.UnwrapSDKContext(ctx).KVStore(kvStoreKey)
//	}
func NewSchemaFromAccessor(accessor func(context.Context) core.KVStore) Schema {
	return Schema{
		storeAccessor:       accessor,
		collectionsByName:   map[string]Collection{},
		collectionsByPrefix: map[string]Collection{},
	}
}

// IsOnePerModuleType implements the depinject.OnePerModuleType interface.
func (s Schema) IsOnePerModuleType() {}

// IsAppModule implements the appmodule.AppModule interface.
func (s Schema) IsAppModule() {}

// DefaultGenesis implements the appmodule.HasGenesis.DefaultGenesis method.
func (s Schema) DefaultGenesis(target core.GenesisTarget) error {
	for _, name := range s.collectionsOrdered {
		err := s.defaultGenesis(target, name)
		if err != nil {
			return fmt.Errorf("failed to instantiate default genesis for %s: %w", name, err)
		}
	}

	return nil
}

func (s Schema) defaultGenesis(target core.GenesisTarget, name string) error {
	wc, err := target(name)
	if err != nil {
		return err
	}
	defer wc.Close()

	coll, err := s.getCollection(name)
	if err != nil {
		return err
	}

	return coll.defaultGenesis(wc)
}

// ValidateGenesis implements the appmodule.HasGenesis.ValidateGenesis method.
func (s Schema) ValidateGenesis(source core.GenesisSource) error {
	for _, name := range s.collectionsOrdered {
		err := s.validateGenesis(source, name)
		if err != nil {
			return fmt.Errorf("failed genesis validation of %s: %w", name, err)
		}
	}
	return nil
}

func (s Schema) validateGenesis(source core.GenesisSource, name string) error {
	rc, err := source(name)
	if err != nil {
		return err
	}
	defer rc.Close()

	coll, err := s.getCollection(name)
	if err != nil {
		return err
	}

	err = coll.validateGenesis(rc)
	if err != nil {
		return err
	}

	return nil
}

// InitGenesis implements the appmodule.HasGenesis.InitGenesis method.
func (s Schema) InitGenesis(ctx context.Context, source core.GenesisSource) error {
	for _, name := range s.collectionsOrdered {
		err := s.initGenesis(ctx, source, name)
		if err != nil {
			return fmt.Errorf("failed genesis initialisation of %s: %w", name, err)
		}
	}

	return nil
}

func (s Schema) initGenesis(ctx context.Context, source core.GenesisSource, name string) error {
	rc, err := source(name)
	if err != nil {
		return err
	}
	defer rc.Close()

	coll, err := s.getCollection(name)
	if err != nil {
		return err
	}

	err = coll.importGenesis(ctx, rc)
	if err != nil {
		return err
	}

	return nil
}

// ExportGenesis implements the appmodule.HasGenesis.ExportGenesis method.
func (s Schema) ExportGenesis(ctx context.Context, target core.GenesisTarget) error {
	for _, name := range s.collectionsOrdered {
		err := s.exportGenesis(ctx, target, name)
		if err != nil {
			return fmt.Errorf("failed to export genesis for %s: %w", name, err)
		}
	}

	return nil
}

func (s Schema) exportGenesis(ctx context.Context, target core.GenesisTarget, name string) error {
	wc, err := target(name)
	if err != nil {
		return err
	}
	defer wc.Close()

	coll, err := s.getCollection(name)
	if err != nil {
		return err
	}

	return coll.exportGenesis(ctx, wc)
}

func (s Schema) getCollection(name string) (Collection, error) {
	coll, ok := s.collectionsByName[name]
	if !ok {
		return nil, fmt.Errorf("unknown collection: %s", name)
	}
	return coll, nil
}

func (s Schema) ListCollections() []Collection {
	colls := make([]Collection, len(s.collectionsOrdered))
	for i, name := range s.collectionsOrdered {
		colls[i] = s.collectionsByName[name]
	}
	return colls
}
