package modrinth

import (
	modrinthApi "codeberg.org/jmansfield/go-modrinth/modrinth"
	"errors"
	"fmt"
	"github.com/packwiz/packwiz/cmdshared"
	"github.com/spf13/viper"
	"golang.org/x/exp/slices"
	"os"
	"path/filepath"
	"strings"

	"github.com/packwiz/packwiz/core"
	"github.com/spf13/cobra"
	"gopkg.in/dixonwille/wmenu.v4"
)

// installCmd represents the install command
var installCmd = &cobra.Command{
	Use:     "add [URL|slug|search]",
	Short:   "Add a project from a Modrinth URL, slug/project ID or search",
	Aliases: []string{"install", "get"},
	Args:    cobra.ArbitraryArgs,
	Run: func(cmd *cobra.Command, args []string) {
		pack, err := core.LoadPack()
		if err != nil {
			fmt.Println(err)
			os.Exit(1)
		}

		index, err := pack.LoadIndex()
		if err != nil {
			fmt.Println(err)
			os.Exit(1)
		}

		// If project/version IDs/version file name is provided in command line, use those
		var projectID, versionID, versionFilename string
		if projectIDFlag != "" {
			projectID = projectIDFlag
			if len(args) != 0 {
				fmt.Println("--project-id cannot be used with a separately specified URL/slug/search term")
				os.Exit(1)
			}
		}
		if versionIDFlag != "" {
			versionID = versionIDFlag
			if len(args) != 0 {
				fmt.Println("--version-id cannot be used with a separately specified URL/slug/search term")
				os.Exit(1)
			}
		}
		if versionFilenameFlag != "" {
			versionFilename = versionFilenameFlag
		}

		if (len(args) == 0 || len(args[0]) == 0) && projectID == "" {
			fmt.Println("You must specify a project; with the ID flags, or by passing a URL, slug or search term directly.")
			os.Exit(1)
		}

		var version string
		var parsedSlug bool
		if projectID == "" && versionID == "" && len(args) == 1 {
			// Try interpreting the argument as a slug/project ID, or project/version/CDN URL
			parsedSlug, err = parseSlugOrUrl(args[0], &projectID, &version, &versionID, &versionFilename)
			if err != nil {
				fmt.Printf("Failed to parse URL: %v\n", err)
				os.Exit(1)
			}
		}

		// Got version ID; install using this ID
		if versionID != "" {
			err = installVersionById(versionID, versionFilename, pack, &index)
			if err != nil {
				fmt.Printf("Failed to add project: %s\n", err)
				os.Exit(1)
			}
			return
		}

		// Look up project ID
		if projectID != "" {
			// Modrinth transparently handles slugs/project IDs in their API; we don't have to detect which one it is.
			var project *modrinthApi.Project
			project, err = mrDefaultClient.Projects.Get(projectID)
			if err == nil {
				// We found a project with that id/slug
				if version != "" {
					// Try to look up version number
					versionData, err := resolveVersion(project, version)
					if err != nil {
						fmt.Printf("Failed to add project: %s\n", err)
						os.Exit(1)
					}
					err = installVersion(project, versionData, versionFilename, pack, &index)
					if err != nil {
						fmt.Printf("Failed to add project: %s\n", err)
						os.Exit(1)
					}
					return
				}

				// No version specified; find latest
				err = installProject(project, versionFilename, pack, &index)
				if err != nil {
					fmt.Printf("Failed to add project: %s\n", err)
					os.Exit(1)
				}
				return
			}
		}

		// Arguments weren't a valid slug/project ID, try to search for it instead (if it was not parsed as a URL)
		if projectID == "" || parsedSlug {
			err = installViaSearch(strings.Join(args, " "), versionFilename, !parsedSlug, pack, &index)
			if err != nil {
				fmt.Printf("Failed to add project: %s\n", err)
				os.Exit(1)
			}
		} else {
			fmt.Printf("Failed to add project: %s\n", err)
			os.Exit(1)
		}
	},
}

func installVersionById(versionId string, versionFilename string, pack core.Pack, index *core.Index) error {
	version, err := mrDefaultClient.Versions.Get(versionId)
	if err != nil {
		return fmt.Errorf("failed to fetch version %s: %v", versionId, err)
	}

	project, err := mrDefaultClient.Projects.Get(*version.ProjectID)
	if err != nil {
		return fmt.Errorf("failed to fetch project %s: %v", *version.ProjectID, err)
	}

	return installVersion(project, version, versionFilename, pack, index)
}

func installViaSearch(query string, versionFilename string, autoAcceptFirst bool, pack core.Pack, index *core.Index) error {
	mcVersions, err := pack.GetSupportedMCVersions()
	if err != nil {
		return err
	}

	fmt.Println("Searching Modrinth...")

	results, err := getProjectIdsViaSearch(query, mcVersions)
	if err != nil {
		return err
	}

	if len(results) == 0 {
		return errors.New("no projects found")
	}

	if viper.GetBool("non-interactive") || (len(results) == 1 && autoAcceptFirst) {
		// Install the first project found
		project, err := mrDefaultClient.Projects.Get(*results[0].ProjectID)
		if err != nil {
			return err
		}

		return installProject(project, versionFilename, pack, index)
	}

	// Create menu for the user to choose the correct project
	menu := wmenu.NewMenu("Choose a number:")
	menu.Option("Cancel", nil, false, nil)
	for i, v := range results {
		// Should be non-nil (Title is a required field)
		menu.Option(*v.Title, v, i == 0, nil)
	}

	menu.Action(func(menuRes []wmenu.Opt) error {
		if len(menuRes) != 1 || menuRes[0].Value == nil {
			return errors.New("project selection cancelled")
		}

		// Get the selected project
		selectedProject, ok := menuRes[0].Value.(*modrinthApi.SearchResult)
		if !ok {
			return errors.New("error converting interface from wmenu")
		}

		// Install the selected project
		project, err := mrDefaultClient.Projects.Get(*selectedProject.ProjectID)
		if err != nil {
			return err
		}

		return installProject(project, versionFilename, pack, index)
	})

	return menu.Run()
}

func installProject(project *modrinthApi.Project, versionFilename string, pack core.Pack, index *core.Index) error {
	latestVersion, err := getLatestVersion(*project.ID, *project.Title, pack)
	if err != nil {
		return fmt.Errorf("failed to get latest version: %v", err)
	}
	if latestVersion.ID == nil {
		return errors.New("mod not available for the configured Minecraft version(s) (use the 'packwiz settings acceptable-versions' command to accept more) or loader")
	}

	return installVersion(project, latestVersion, versionFilename, pack, index)
}

const maxCycles = 20

type depMetadataStore struct {
	projectInfo *modrinthApi.Project
	versionInfo *modrinthApi.Version
	fileInfo    *modrinthApi.File
}

func installVersion(project *modrinthApi.Project, version *modrinthApi.Version, versionFilename string, pack core.Pack, index *core.Index) error {
	if len(version.Files) == 0 {
		return errors.New("version doesn't have any files attached")
	}

	if len(version.Dependencies) > 0 {
		// TODO: could get installed version IDs, and compare to install the newest - i.e. preferring pinned versions over getting absolute latest?
		installedProjects := getInstalledProjectIDs(index)
		isQuilt := slices.Contains(pack.GetCompatibleLoaders(), "quilt")
		mcVersion, err := pack.GetMCVersion()
		if err != nil {
			return err
		}

		var depMetadata []depMetadataStore
		var depProjectIDPendingQueue []string
		var depVersionIDPendingQueue []string

		for _, dep := range version.Dependencies {
			// TODO: recommend optional dependencies?
			if dep.DependencyType != nil && *dep.DependencyType == "required" {
				if dep.VersionID != nil {
					depVersionIDPendingQueue = append(depVersionIDPendingQueue, *dep.VersionID)
				} else {
					if dep.ProjectID != nil {
						depProjectIDPendingQueue = append(depProjectIDPendingQueue, mapDepOverride(*dep.ProjectID, isQuilt, mcVersion))
					}
				}
			}
		}

		if len(depProjectIDPendingQueue)+len(depVersionIDPendingQueue) > 0 {
			fmt.Println("Finding dependencies...")

			cycles := 0
			for len(depProjectIDPendingQueue)+len(depVersionIDPendingQueue) > 0 && cycles < maxCycles {
				// Look up version IDs
				if len(depVersionIDPendingQueue) > 0 {
					depVersions, err := mrDefaultClient.Versions.GetMultiple(depVersionIDPendingQueue)
					if err == nil {
						for _, v := range depVersions {
							// Add project ID to queue
							depProjectIDPendingQueue = append(depProjectIDPendingQueue, mapDepOverride(*v.ProjectID, isQuilt, mcVersion))
						}
					} else {
						fmt.Printf("Error retrieving dependency data: %s\n", err.Error())
					}
					depVersionIDPendingQueue = depVersionIDPendingQueue[:0]
				}

				// Remove installed project IDs from dep queue
				i := 0
				for _, id := range depProjectIDPendingQueue {
					contains := slices.Contains(installedProjects, id)
					for _, dep := range depMetadata {
						if *dep.projectInfo.ID == id {
							contains = true
							break
						}
					}
					if !contains {
						depProjectIDPendingQueue[i] = id
						i++
					}
				}
				depProjectIDPendingQueue = depProjectIDPendingQueue[:i]

				// Clean up duplicates from dep queue (from deps on both QFAPI + FAPI)
				slices.Sort(depProjectIDPendingQueue)
				depProjectIDPendingQueue = slices.Compact(depProjectIDPendingQueue)

				if len(depProjectIDPendingQueue) == 0 {
					break
				}
				depProjects, err := mrDefaultClient.Projects.GetMultiple(depProjectIDPendingQueue)
				if err != nil {
					fmt.Printf("Error retrieving dependency data: %s\n", err.Error())
				}
				depProjectIDPendingQueue = depProjectIDPendingQueue[:0]

				for _, project := range depProjects {
					if project.ID == nil {
						return errors.New("failed to get dependency data: invalid response")
					}
					// Get latest version - could reuse version lookup data but it's not as easy (particularly since the version won't necessarily be the latest)
					latestVersion, err := getLatestVersion(*project.ID, *project.Title, pack)
					if err != nil {
						fmt.Printf("Failed to get latest version of dependency %v: %v\n", *project.Title, err)
						continue
					}

					for _, dep := range version.Dependencies {
						// TODO: recommend optional dependencies?
						if dep.DependencyType != nil && *dep.DependencyType == "required" {
							if dep.ProjectID != nil {
								depProjectIDPendingQueue = append(depProjectIDPendingQueue, mapDepOverride(*dep.ProjectID, isQuilt, mcVersion))
							}
							if dep.VersionID != nil {
								depVersionIDPendingQueue = append(depVersionIDPendingQueue, *dep.VersionID)
							}
						}
					}

					var file = latestVersion.Files[0]
					// Prefer the primary file
					for _, v := range latestVersion.Files {
						if *v.Primary {
							file = v
						}
					}

					depMetadata = append(depMetadata, depMetadataStore{
						projectInfo: project,
						versionInfo: latestVersion,
						fileInfo:    file,
					})
				}

				cycles++
			}
			if cycles >= maxCycles {
				return errors.New("dependencies recurse too deeply, try increasing maxCycles")
			}

			if len(depMetadata) > 0 {
				fmt.Println("Dependencies found:")
				for _, v := range depMetadata {
					fmt.Println(*v.projectInfo.Title)
				}

				if cmdshared.PromptYesNo("Would you like to add them? [Y/n]: ") {
					for _, v := range depMetadata {
						err := createFileMeta(v.projectInfo, v.versionInfo, v.fileInfo, pack, index)
						if err != nil {
							return err
						}
						fmt.Printf("Dependency \"%s\" successfully added! (%s)\n", *v.projectInfo.Title, *v.fileInfo.Filename)
					}
				}
			} else {
				fmt.Println("All dependencies are already added!")
			}
		}
	}

	var file = version.Files[0]
	// Prefer the primary file
	for _, v := range version.Files {
		if (*v.Primary) || (versionFilename != "" && versionFilename == *v.Filename) {
			file = v
		}
	}
	// TODO: handle optional/required resource pack files

	// Create the metadata file
	err := createFileMeta(project, version, file, pack, index)
	if err != nil {
		return err
	}

	err = index.Write()
	if err != nil {
		return err
	}
	err = pack.UpdateIndexHash()
	if err != nil {
		return err
	}
	err = pack.Write()
	if err != nil {
		return err
	}

	fmt.Printf("Project \"%s\" successfully added! (%s)\n", *project.Title, *file.Filename)
	return nil
}

func createFileMeta(project *modrinthApi.Project, version *modrinthApi.Version, file *modrinthApi.File, pack core.Pack, index *core.Index) error {
	updateMap := make(map[string]map[string]interface{})

	var err error
	updateMap["modrinth"], err = mrUpdateData{
		ProjectID:        *project.ID,
		InstalledVersion: *version.ID,
	}.ToMap()
	if err != nil {
		return err
	}

	side := getSide(project)
	if side == "" {
		return errors.New("version doesn't have a side that's supported. Server: " + *project.ServerSide + " Client: " + *project.ClientSide)
	}

	algorithm, hash := getBestHash(file)
	if algorithm == "" {
		return errors.New("file doesn't have a hash")
	}

	modMeta := core.Mod{
		Name:     *project.Title,
		FileName: *file.Filename,
		Side:     side,
		Download: core.ModDownload{
			URL:        *file.URL,
			HashFormat: algorithm,
			Hash:       hash,
		},
		Update: updateMap,
	}
	var path string
	folder := viper.GetString("meta-folder")
	if folder == "" {
		folder, err = getProjectTypeFolder(*project.ProjectType, version.Loaders, pack.GetCompatibleLoaders())
		if err != nil {
			return err
		}
	}
	if project.Slug != nil {
		path = modMeta.SetMetaPath(filepath.Join(viper.GetString("meta-folder-base"), folder, *project.Slug+core.MetaExtension))
	} else {
		path = modMeta.SetMetaPath(filepath.Join(viper.GetString("meta-folder-base"), folder, core.SlugifyName(*project.Title)+core.MetaExtension))
	}

	// If the file already exists, this will overwrite it!!!
	// TODO: Should this be improved?
	// Current strategy is to go ahead and do stuff without asking, with the assumption that you are using
	// VCS anyway.

	format, hash, err := modMeta.Write()
	if err != nil {
		return err
	}
	return index.RefreshFileWithHash(path, format, hash, true)
}

var projectIDFlag string
var versionIDFlag string
var versionFilenameFlag string

func init() {
	modrinthCmd.AddCommand(installCmd)

	installCmd.Flags().StringVar(&projectIDFlag, "project-id", "", "The Modrinth project ID to use")
	installCmd.Flags().StringVar(&versionIDFlag, "version-id", "", "The Modrinth version ID to use")
	installCmd.Flags().StringVar(&versionFilenameFlag, "version-filename", "", "The Modrinth version filename to use")
}