package main

import (
	"os"
	"path/filepath"
	"strings"

	"github.com/hashicorp/go-multierror"
	"github.com/pingcap/log"
	"github.com/pkg/errors"
	"github.com/tidbcloud/config-service/sdk/api"
	"go.uber.org/zap"
	"gopkg.in/yaml.v2"
)

// visit walks the file tree and construct its programmatic representation
func visit(root string) (*Tree, error) {
	var errs *multierror.Error

	// ./versions.yaml
	version, err := readMeta(filepath.Join(root, metaFile))
	errs = multierror.Append(errs, err)

	// ./versions/
	vConfigs, err := visitVersionedConfigs(filepath.Join(root, versionsDir))
	errs = multierror.Append(errs, err)

	// ./tenants/
	tOverlays, err := visitTenantOverlays(filepath.Join(root, tenantsDir))
	errs = multierror.Append(errs, err)

	return &Tree{
		GlobalMeta:       version,
		VersionedConfigs: vConfigs,
		TenantOverlays:   tOverlays,
	}, errs.ErrorOrNil()
}

func visitVersionedConfigs(root string) ([]VersionedConfig, error) {
	fis, err := os.ReadDir(root)
	if err != nil {
		return nil, errors.Wrapf(err, "error reading %s", root)
	}

	var errs *multierror.Error
	var vConfigs []VersionedConfig
	for _, fi := range fis {
		if !fi.IsDir() {
			errs = multierror.Append(errs, errors.Errorf("unexpected file %s", filepath.Join(root, fi.Name())))
			continue
		}
		version := fi.Name()
		// validate eagerly so we avoid unnecessary processing if the version if invalid
		if err := validateVersion(version); err != nil {
			errs = multierror.Append(errs, err)
			continue
		}
		vConfig, err := visitVersionedConfig(filepath.Join(root, version))
		if err != nil {
			errs = multierror.Append(errs, err)
			continue
		}
		// set version of this versioned config
		if vConfig.Meta == nil {
			vConfig.Meta = &Meta{
				Version: version,
			}
		}
		// validate the versions.yaml
		if vConfig.Meta.Version != "" && vConfig.Meta.Version != version {
			errs = multierror.Append(errs, errors.Errorf("the version defined %s is inconsistent with version dir %s", vConfig.Meta.Version, version))
			continue
		}
		vConfig.Meta.Version = version
		vConfigs = append(vConfigs, vConfig)
	}
	return vConfigs, errs.ErrorOrNil()
}

func visitVersionedConfig(root string) (VersionedConfig, error) {
	var errs *multierror.Error

	// ${version}/versions.yaml
	version, err := readMeta(filepath.Join(root, metaFile))
	errs = multierror.Append(errs, err)

	// ${version}/base/{configs}
	configs, err := readComponentConfigs(filepath.Join(root, baseDir))
	errs = multierror.Append(errs, err)

	// ${version}/instances
	instanceOverlays, err := visitInstanceOverlays(filepath.Join(root, instancesDir))
	errs = multierror.Append(errs, err)

	return VersionedConfig{
		Meta:             version,
		Configs:          configs,
		InstanceOverlays: instanceOverlays,
	}, errs.ErrorOrNil()
}

func visitInstanceOverlays(root string) ([]InstanceOverlay, error) {
	fis, err := os.ReadDir(root)
	if err != nil {
		// it is okay to have no instance overlays
		if os.IsNotExist(err) {
			return nil, nil
		}
		return nil, errors.Wrapf(err, "error reading %s", root)
	}

	var errs *multierror.Error
	var instanceOverlays []InstanceOverlay
	for _, fi := range fis {
		if !fi.IsDir() {
			errs = multierror.Append(errs, errors.Errorf("unexpected file %s", filepath.Join(root, fi.Name())))
			continue
		}
		if configs, err := readComponentConfigs(filepath.Join(root, fi.Name())); err != nil {
			errs = multierror.Append(errs, err)
		} else if configs != nil {
			instanceOverlays = append(instanceOverlays, InstanceOverlay{
				InstanceType: fi.Name(),
				Configs:      configs,
			})
		} else {
			log.Warn("instance overlay has no actual overlay files", zap.String("path", filepath.Join(root, fi.Name())))
		}
	}
	return instanceOverlays, errs.ErrorOrNil()
}

func visitTenantOverlays(root string) ([]TenantOverlay, error) {
	fis, err := os.ReadDir(root)
	if err != nil {
		// it is okay to have no tenant overlays
		if os.IsNotExist(err) {
			return nil, nil
		}
		return nil, errors.Wrapf(err, "error reading %s", root)
	}

	var errs *multierror.Error
	var tenantOverlays []TenantOverlay
	for _, fi := range fis {
		if !fi.IsDir() {
			errs = multierror.Append(errs, errors.Errorf("unexpected file %s", filepath.Join(root, fi.Name())))
			continue
		}
		tenantID, err := parseTenantDirName(fi.Name())
		if err != nil {
			errs = multierror.Append(errs, err)
			continue
		}
		if tenantOverlay, err := visitTenantOverlay(filepath.Join(root, fi.Name())); err != nil {
			errs = multierror.Append(errs, err)
		} else {
			tenantOverlay.TenantID = tenantID
			tenantOverlays = append(tenantOverlays, tenantOverlay)
		}
	}
	return tenantOverlays, errs.ErrorOrNil()
}

func visitTenantOverlay(root string) (TenantOverlay, error) {
	var errs *multierror.Error

	fis, err := os.ReadDir(root)
	if err != nil {
		if !os.IsNotExist(err) {
			return TenantOverlay{}, errors.Wrapf(err, "error reading %s", root)
		}
	}
	var dirs []string
	for _, fi := range fis {
		if fi.IsDir() {
			dirs = append(dirs, fi.Name())
		}
	}
	if len(dirs) > 1 {
		return TenantOverlay{}, errors.New("tenant directory can only has one subdirectory")
	}
	if len(dirs) == 1 && dirs[0] != "clusters" {
		return TenantOverlay{}, errors.New("the subdirectory of tenant directtory must be clusters")
	}
	// tenant${id}/versions.yaml
	version, err := readMeta(filepath.Join(root, metaFile))
	errs = multierror.Append(errs, err)

	// tenant${id}/[configs]
	configs, err := readComponentConfigs(root)
	errs = multierror.Append(errs, err)

	// tenant${id}/clusters
	clusterOverlays, err := visitClusterOverlays(filepath.Join(root, clustersDir))
	errs = multierror.Append(errs, err)

	return TenantOverlay{
		Meta:            version,
		Configs:         configs,
		ClusterOverlays: clusterOverlays,
	}, errs.ErrorOrNil()
}

func visitClusterOverlays(root string) ([]ClusterOverlay, error) {
	fis, err := os.ReadDir(root)
	if err != nil {
		if os.IsNotExist(err) {
			return nil, nil
		}
		return nil, errors.Wrapf(err, "error reading %s", root)
	}

	var errs *multierror.Error
	var clusterOverlays []ClusterOverlay
	for _, fi := range fis {
		if !fi.IsDir() {
			errs = multierror.Append(errs, errors.Errorf("unexpected file %s", filepath.Join(root, fi.Name())))
			continue
		}
		clusterID, err := parseClusterDirName(fi.Name())
		if err != nil {
			errs = multierror.Append(errs, err)
			continue
		}
		// cluster{id}/[configs]
		configs, err := readComponentConfigs(filepath.Join(root, fi.Name()))
		if err != nil {
			errs = multierror.Append(errs, err)
			continue
		}
		// cluster{id}/versions.yaml
		versions, err := readMeta(filepath.Join(root, fi.Name(), metaFile))
		if err != nil {
			errs = multierror.Append(errs, err)
			continue
		}
		if configs == nil && versions == nil {
			log.Warn("cluster overlay has no actual overlay files", zap.String("path", filepath.Join(root, fi.Name())))
			continue
		}
		clusterOverlays = append(clusterOverlays, ClusterOverlay{
			Meta:      versions,
			ClusterID: clusterID,
			Configs:   configs,
		})
	}
	return clusterOverlays, errs.ErrorOrNil()
}

func readComponentConfigs(dir string) ([]ComponentConfig, error) {
	var configs []ComponentConfig
	var errs *multierror.Error
	for comp, filename := range componentToFile {
		b, err := os.ReadFile(filepath.Join(dir, filename))
		if err != nil {
			if os.IsNotExist(err) {
				log.Debug("component config not found, ignore", zap.String("component", string(comp)), zap.String("dir", dir))
			} else {
				errs = multierror.Append(errs, err)
			}
			continue
		}
		conf := api.NewTomlConfig(make(map[string]interface{}))
		if strings.HasSuffix(filename, "toml") {
			if err := conf.UnmarshalTOML(b); err != nil {
				errs = multierror.Append(errs, errors.Wrapf(err, "error unmarshal component config"))
			}
		} else {
			if err := yaml.Unmarshal(b, &conf.MP); err != nil {
				errs = multierror.Append(errs, errors.Wrapf(err, "error unmarshal component config"))
			}
		}
		configs = append(configs, ComponentConfig{
			Component: comp,
			Config:    conf,
		})
	}
	return configs, errs.ErrorOrNil()
}

func readMeta(filename string) (*Meta, error) {
	b, err := os.ReadFile(filename)
	if err != nil {
		if os.IsNotExist(err) {
			return nil, nil
		}
		return nil, errors.Wrapf(err, "error reading %s", filename)
	}
	v := &Meta{}
	if err := yaml.Unmarshal(b, v); err != nil {
		return nil, err
	}
	return v, nil
}
