/**
	Stuff with dependencies.

	Copyright: © 2012-2013 Matthias Dondorff, © 2012-2015 Sönke Ludwig
	License: Subject to the terms of the MIT license, as written in the included LICENSE.txt file.
	Authors: Matthias Dondorff
*/
module dub.package_;

public import dub.recipe.packagerecipe;

import dub.compilers.compiler;
import dub.dependency;
import dub.description;
import dub.recipe.json;
import dub.recipe.sdl;

import dub.internal.utils;
import dub.internal.vibecompat.core.log;
import dub.internal.vibecompat.core.file;
import dub.internal.vibecompat.data.json;
import dub.internal.vibecompat.inet.url;

import std.algorithm;
import std.array;
import std.conv;
import std.exception;
import std.file;
import std.range;
import std.string;
import std.typecons : Nullable;


enum PackageFormat {
	json,
	sdl
}

struct FilenameAndFormat {
	string filename;
	PackageFormat format;
}

struct PathAndFormat {
	Path path;
	PackageFormat format;

	@property bool empty() { return path.empty; }

	string toString() const { return path.toString(); }
}


// Supported package descriptions in decreasing order of preference.
static immutable FilenameAndFormat[] packageInfoFiles = [
	{"dub.json", PackageFormat.json},
	{"dub.sdl", PackageFormat.sdl},
	{"package.json", PackageFormat.json}
];

@property string[] packageInfoFilenames() { return packageInfoFiles.map!(f => cast(string)f.filename).array; }

@property string defaultPackageFilename() { return packageInfoFiles[0].filename; }


/**
	Represents a package, including its sub packages

	Documentation of the dub.json can be found at
	http://registry.vibed.org/package-format
*/
class Package {
	private {
		Path m_path;
		PathAndFormat m_infoFile;
		PackageRecipe m_info;
		Package m_parentPackage;
	}

	static PathAndFormat findPackageFile(Path path)
	{
		foreach(file; packageInfoFiles) {
			auto filename = path ~ file.filename;
			if(existsFile(filename)) return PathAndFormat(filename, file.format);
		}
		return PathAndFormat(Path());
	}

	this(Path root, PathAndFormat infoFile = PathAndFormat(), Package parent = null, string versionOverride = "")
	{
		RawPackage raw_package;
		m_infoFile = infoFile;

		try {
			if(m_infoFile.empty) {
				m_infoFile = findPackageFile(root);
				if(m_infoFile.empty)
					throw new Exception(
						"No package file found in %s, expected one of %s"
							.format(root.toNativeString(), packageInfoFiles.map!(f => cast(string)f.filename).join("/")));
			}
			raw_package = rawPackageFromFile(m_infoFile);
		} catch (Exception ex) throw ex;//throw new Exception(format("Failed to load package %s: %s", m_infoFile.toNativeString(), ex.msg));

		enforce(raw_package !is null, format("Missing package description for package at %s", root.toNativeString()));
		this(raw_package, root, parent, versionOverride);
	}

	this(Json package_info, Path root = Path(), Package parent = null, string versionOverride = "")
	{
		this(new JsonPackage(package_info), root, parent, versionOverride);
	}

	this(RawPackage raw_package, Path root = Path(), Package parent = null, string versionOverride = "")
	{
		PackageRecipe recipe;

		// parse the Package description
		if(raw_package !is null)
		{
			scope(failure) logError("Failed to parse package description for %s %s in %s.",
				raw_package.package_name, versionOverride.length ? versionOverride : raw_package.version_,
				root.length ? root.toNativeString() : "remote location");
			raw_package.parseInto(recipe, parent ? parent.name : null);

			if (!versionOverride.empty)
				recipe.version_ = versionOverride;

			// try to run git to determine the version of the package if no explicit version was given
			if (recipe.version_.length == 0 && !parent) {
				try recipe.version_ = determineVersionFromSCM(root);
				catch (Exception e) logDebug("Failed to determine version by SCM: %s", e.msg);

				if (recipe.version_.length == 0) {
					logDiagnostic("Note: Failed to determine version of package %s at %s. Assuming ~master.", recipe.name, this.path.toNativeString());
					// TODO: Assume unknown version here?
					// recipe.version_ = Version.UNKNOWN.toString();
					recipe.version_ = Version.MASTER.toString();
				} else logDiagnostic("Determined package version using GIT: %s %s", recipe.name, recipe.version_);
			}
		}

		this(recipe, root, parent);
	}

	this(PackageRecipe recipe, Path root = Path(), Package parent = null)
	{
		m_parentPackage = parent;
		m_path = root;
		m_path.endsWithSlash = true;

		// use the given recipe as the basis
		m_info = recipe;

		fillWithDefaults();
		simpleLint();
	}

	@property string name()
	const {
		if (m_parentPackage) return m_parentPackage.name ~ ":" ~ m_info.name;
		else return m_info.name;
	}
	@property string vers() const { return m_parentPackage ? m_parentPackage.vers : m_info.version_; }
	@property Version ver() const { return Version(this.vers); }
	@property void ver(Version ver) { assert(m_parentPackage is null); m_info.version_ = ver.toString(); }
	@property ref inout(PackageRecipe) info() inout { return m_info; }
	@property Path path() const { return m_path; }
	@property Path packageInfoFilename() const { return m_infoFile.path; }
	@property const(Dependency[string]) dependencies() const { return m_info.dependencies; }
	@property inout(Package) basePackage() inout { return m_parentPackage ? m_parentPackage.basePackage : this; }
	@property inout(Package) parentPackage() inout { return m_parentPackage; }
	@property inout(SubPackage)[] subPackages() inout { return m_info.subPackages; }

	@property string[] configurations()
	const {
		auto ret = appender!(string[])();
		foreach( ref config; m_info.configurations )
			ret.put(config.name);
		return ret.data;
	}

	const(Dependency[string]) getDependencies(string config)
	const {
		Dependency[string] ret;
		foreach (k, v; m_info.buildSettings.dependencies)
			ret[k] = v;
		foreach (ref conf; m_info.configurations)
			if (conf.name == config) {
				foreach (k, v; conf.buildSettings.dependencies)
					ret[k] = v;
				break;
			}
		return ret;
	}

	/** Overwrites the packge description file using the default filename with the current information.
	*/
	void storeInfo()
	{
		storeInfo(m_path);
		m_infoFile = PathAndFormat(m_path ~ defaultPackageFilename);
	}
	/// ditto
	void storeInfo(Path path)
	const {
		enforce(!ver.isUnknown, "Trying to store a package with an 'unknown' version, this is not supported.");
		auto filename = path ~ defaultPackageFilename;
		auto dstFile = openFile(filename.toNativeString(), FileMode.createTrunc);
		scope(exit) dstFile.close();
		dstFile.writePrettyJsonString(m_info.toJson());
	}

	Nullable!PackageRecipe getInternalSubPackage(string name)
	{
		foreach (ref p; m_info.subPackages)
			if (p.path.empty && p.recipe.name == name)
				return Nullable!PackageRecipe(p.recipe);
		return Nullable!PackageRecipe();
	}

	void warnOnSpecialCompilerFlags()
	{
		// warn about use of special flags
		m_info.buildSettings.warnOnSpecialCompilerFlags(m_info.name, null);
		foreach (ref config; m_info.configurations)
			config.buildSettings.warnOnSpecialCompilerFlags(m_info.name, config.name);
	}

	const(BuildSettingsTemplate) getBuildSettings(string config = null)
	const {
		if (config.length) {
			foreach (ref conf; m_info.configurations)
				if (conf.name == config)
					return conf.buildSettings;
			assert(false, "Unknown configuration: "~config);
		} else {
			return m_info.buildSettings;
		}
	}

	/// Returns all BuildSettings for the given platform and config.
	BuildSettings getBuildSettings(in BuildPlatform platform, string config)
	const {
		BuildSettings ret;
		m_info.buildSettings.getPlatformSettings(ret, platform, this.path);
		bool found = false;
		foreach(ref conf; m_info.configurations){
			if( conf.name != config ) continue;
			conf.buildSettings.getPlatformSettings(ret, platform, this.path);
			found = true;
			break;
		}
		assert(found || config is null, "Unknown configuration for "~m_info.name~": "~config);

		// construct default target name based on package name
		if( ret.targetName.empty ) ret.targetName = this.name.replace(":", "_");

		// special support for DMD style flags
		getCompiler("dmd").extractBuildOptions(ret);

		return ret;
	}

	/// Returns the combination of all build settings for all configurations and platforms
	BuildSettings getCombinedBuildSettings()
	const {
		BuildSettings ret;
		m_info.buildSettings.getPlatformSettings(ret, BuildPlatform.any, this.path);
		foreach(ref conf; m_info.configurations)
			conf.buildSettings.getPlatformSettings(ret, BuildPlatform.any, this.path);

		// construct default target name based on package name
		if (ret.targetName.empty) ret.targetName = this.name.replace(":", "_");

		// special support for DMD style flags
		getCompiler("dmd").extractBuildOptions(ret);

		return ret;
	}

	void addBuildTypeSettings(ref BuildSettings settings, in BuildPlatform platform, string build_type)
	const {
		if (build_type == "$DFLAGS") {
			import std.process;
			string dflags = environment.get("DFLAGS");
			settings.addDFlags(dflags.split());
			return;
		}

		if (auto pbt = build_type in m_info.buildTypes) {
			logDiagnostic("Using custom build type '%s'.", build_type);
			pbt.getPlatformSettings(settings, platform, this.path);
		} else {
			with(BuildOption) switch (build_type) {
				default: throw new Exception(format("Unknown build type for %s: '%s'", this.name, build_type));
				case "plain": break;
				case "debug": settings.addOptions(debugMode, debugInfo); break;
				case "release": settings.addOptions(releaseMode, optimize, inline); break;
				case "release-nobounds": settings.addOptions(releaseMode, optimize, inline, noBoundsCheck); break;
				case "unittest": settings.addOptions(unittests, debugMode, debugInfo); break;
				case "docs": settings.addOptions(syntaxOnly); settings.addDFlags("-c", "-Dddocs"); break;
				case "ddox": settings.addOptions(syntaxOnly); settings.addDFlags("-c", "-Df__dummy.html", "-Xfdocs.json"); break;
				case "profile": settings.addOptions(profile, optimize, inline, debugInfo); break;
				case "profile-gc": settings.addOptions(profileGC, debugInfo); break;
				case "cov": settings.addOptions(coverage, debugInfo); break;
				case "unittest-cov": settings.addOptions(unittests, coverage, debugMode, debugInfo); break;
			}
		}
	}

	string getSubConfiguration(string config, in Package dependency, in BuildPlatform platform)
	const {
		bool found = false;
		foreach(ref c; m_info.configurations){
			if( c.name == config ){
				if( auto pv = dependency.name in c.buildSettings.subConfigurations ) return *pv;
				found = true;
				break;
			}
		}
		assert(found || config is null, "Invalid configuration \""~config~"\" for "~this.name);
		if( auto pv = dependency.name in m_info.buildSettings.subConfigurations ) return *pv;
		return null;
	}

	/// Returns the default configuration to build for the given platform
	string getDefaultConfiguration(in BuildPlatform platform, bool allow_non_library = false)
	const {
		foreach (ref conf; m_info.configurations) {
			if (!conf.matchesPlatform(platform)) continue;
			if (!allow_non_library && conf.buildSettings.targetType == TargetType.executable) continue;
			return conf.name;
		}
		return null;
	}

	/// Returns a list of configurations suitable for the given platform
	string[] getPlatformConfigurations(in BuildPlatform platform, bool is_main_package = false)
	const {
		auto ret = appender!(string[]);
		foreach(ref conf; m_info.configurations){
			if (!conf.matchesPlatform(platform)) continue;
			if (!is_main_package && conf.buildSettings.targetType == TargetType.executable) continue;
			ret ~= conf.name;
		}
		if (ret.data.length == 0) ret.put(null);
		return ret.data;
	}

	/// Human readable information of this package and its dependencies.
	string generateInfoString() const {
		string s;
		s ~= m_info.name ~ ", version '" ~ m_info.version_ ~ "'";
		s ~= "\n  Dependencies:";
		foreach(string p, ref const Dependency v; m_info.dependencies)
			s ~= "\n    " ~ p ~ ", version '" ~ v.toString() ~ "'";
		return s;
	}

	bool hasDependency(string depname, string config)
	const {
		if (depname in m_info.buildSettings.dependencies) return true;
		foreach (ref c; m_info.configurations)
			if ((config.empty || c.name == config) && depname in c.buildSettings.dependencies)
				return true;
		return false;
	}

	/** Returns a description of the package for use in IDEs or build tools.
	*/
	PackageDescription describe(BuildPlatform platform, string config)
	const {
		PackageDescription ret;
		ret.configuration = config;
		ret.path = m_path.toNativeString();
		ret.name = this.name;
		ret.version_ = this.ver;
		ret.description = m_info.description;
		ret.homepage = m_info.homepage;
		ret.authors = m_info.authors.dup;
		ret.copyright = m_info.copyright;
		ret.license = m_info.license;
		ret.dependencies = getDependencies(config).keys;

		// save build settings
		BuildSettings bs = getBuildSettings(platform, config);
		BuildSettings allbs = getCombinedBuildSettings();

		ret.targetType = bs.targetType;
		ret.targetPath = bs.targetPath;
		ret.targetName = bs.targetName;
		if (ret.targetType != TargetType.none)
			ret.targetFileName = getTargetFileName(bs, platform);
		ret.workingDirectory = bs.workingDirectory;
		ret.mainSourceFile = bs.mainSourceFile;
		ret.dflags = bs.dflags;
		ret.lflags = bs.lflags;
		ret.libs = bs.libs;
		ret.copyFiles = bs.copyFiles;
		ret.versions = bs.versions;
		ret.debugVersions = bs.debugVersions;
		ret.importPaths = bs.importPaths;
		ret.stringImportPaths = bs.stringImportPaths;
		ret.preGenerateCommands = bs.preGenerateCommands;
		ret.postGenerateCommands = bs.postGenerateCommands;
		ret.preBuildCommands = bs.preBuildCommands;
		ret.postBuildCommands = bs.postBuildCommands;

		// prettify build requirements output
		for (int i = 1; i <= BuildRequirement.max; i <<= 1)
			if (bs.requirements & cast(BuildRequirement)i)
				ret.buildRequirements ~= cast(BuildRequirement)i;

		// prettify options output
		for (int i = 1; i <= BuildOption.max; i <<= 1)
			if (bs.options & cast(BuildOption)i)
				ret.options ~= cast(BuildOption)i;

		// collect all possible source files and determine their types
		SourceFileRole[string] sourceFileTypes;
		foreach (f; allbs.stringImportFiles) sourceFileTypes[f] = SourceFileRole.unusedStringImport;
		foreach (f; allbs.importFiles) sourceFileTypes[f] = SourceFileRole.unusedImport;
		foreach (f; allbs.sourceFiles) sourceFileTypes[f] = SourceFileRole.unusedSource;
		foreach (f; bs.stringImportFiles) sourceFileTypes[f] = SourceFileRole.stringImport;
		foreach (f; bs.importFiles) sourceFileTypes[f] = SourceFileRole.import_;
		foreach (f; bs.sourceFiles) sourceFileTypes[f] = SourceFileRole.source;
		foreach (f; sourceFileTypes.byKey.array.sort()) {
			SourceFileDescription sf;
			sf.path = f;
			sf.type = sourceFileTypes[f];
			ret.files ~= sf;
		}

		return ret;
	}
	// ditto
	deprecated void describe(ref Json dst, BuildPlatform platform, string config)
	{
		auto res = describe(platform, config);
		foreach (string key, value; res.serializeToJson())
			dst[key] = value;
	}

	private void fillWithDefaults()
	{
		auto bs = &m_info.buildSettings;

		// check for default string import folders
		if ("" !in bs.stringImportPaths) {
			foreach(defvf; ["views"]){
				if( existsFile(m_path ~ defvf) )
					bs.stringImportPaths[""] ~= defvf;
			}
		}

		// check for default source folders
		immutable hasSP = ("" in bs.sourcePaths) !is null;
		immutable hasIP = ("" in bs.importPaths) !is null;
		if (!hasSP || !hasIP) {
			foreach (defsf; ["source/", "src/"]) {
				if (existsFile(m_path ~ defsf)) {
					if (!hasSP) bs.sourcePaths[""] ~= defsf;
					if (!hasIP) bs.importPaths[""] ~= defsf;
				}
			}
		}

		// check for default app_main
		string app_main_file;
		auto pkg_name = m_info.name.length ? m_info.name : "unknown";
		foreach(sf; bs.sourcePaths.get("", null)){
			auto p = m_path ~ sf;
			if( !existsFile(p) ) continue;
			foreach(fil; ["app.d", "main.d", pkg_name ~ "/main.d", pkg_name ~ "/" ~ "app.d"]){
				if( existsFile(p ~ fil) ) {
					app_main_file = (Path(sf) ~ fil).toNativeString();
					break;
				}
			}
		}

		// generate default configurations if none are defined
		if (m_info.configurations.length == 0) {
			if (bs.targetType == TargetType.executable) {
				BuildSettingsTemplate app_settings;
				app_settings.targetType = TargetType.executable;
				if (bs.mainSourceFile.empty) app_settings.mainSourceFile = app_main_file;
				m_info.configurations ~= ConfigurationInfo("application", app_settings);
			} else if (bs.targetType != TargetType.none) {
				BuildSettingsTemplate lib_settings;
				lib_settings.targetType = bs.targetType == TargetType.autodetect ? TargetType.library : bs.targetType;

				if (bs.targetType == TargetType.autodetect) {
					if (app_main_file.length) {
						lib_settings.excludedSourceFiles[""] ~= app_main_file;

						BuildSettingsTemplate app_settings;
						app_settings.targetType = TargetType.executable;
						app_settings.mainSourceFile = app_main_file;
						m_info.configurations ~= ConfigurationInfo("application", app_settings);
					}
				}

				m_info.configurations ~= ConfigurationInfo("library", lib_settings);
			}
		}
	}

	private void simpleLint() const {
		if (m_parentPackage) {
			if (m_parentPackage.path != path) {
				if (info.license.length && info.license != m_parentPackage.info.license)
					logWarn("License in subpackage %s is different than it's parent package, this is discouraged.", name);
			}
		}
		if (name.empty) logWarn("The package in %s has no name.", path);
	}

	private static RawPackage rawPackageFromFile(PathAndFormat file, bool silent_fail = false) {
		if( silent_fail && !existsFile(file.path) ) return null;

		string text;

		{
			auto f = openFile(file.path.toNativeString(), FileMode.read);
			scope(exit) f.close();
			text = stripUTF8Bom(cast(string)f.readAll());
		}

		final switch(file.format) {
			case PackageFormat.json:
				return new JsonPackage(parseJsonString(text, file.path.toNativeString()));
			case PackageFormat.sdl:
				return new SdlPackage(text, file.path.toNativeString());
		}
	}

	static abstract class RawPackage
	{
		string package_name; // Should already be lower case
		string version_;
		abstract void parseInto(ref PackageRecipe package_, string parent_name);
	}
	private static class JsonPackage : RawPackage
	{
		Json json;
		this(Json json) {
			this.json = json;

			string nameLower;
			if(json.type == Json.Type.string) {
				nameLower = json.get!string.toLower();
				this.json = nameLower;
			} else {
				nameLower = json.name.get!string.toLower();
				this.package_name = nameLower;

				Json versionJson = json["version"];
				this.version_ = (versionJson.type == Json.Type.undefined) ? null : versionJson.get!string;
			}

			this.package_name = nameLower;
		}
		override void parseInto(ref PackageRecipe recipe, string parent_name)
		{
			recipe.parseJson(json, parent_name);
		}
	}

	private static class SdlPackage : RawPackage
	{
		import dub.internal.sdlang;
		Tag sdl;

		this(string sdl_text, string filename)
		{
			this.sdl = parseSource(sdl_text, filename);
			foreach (t; this.sdl.tags) {
				switch (t.name) {
					default: break;
					case "name":
						this.package_name = t.values[0].get!string.toLower();
						break;
					case "version":
						this.version_ = t.values[0].get!string;
						break;
				}
			}
		}

		override void parseInto(ref PackageRecipe recipe, string parent_name)
		{
			recipe.parseSDL(sdl, parent_name);
		}
	}
}

private string determineVersionFromSCM(Path path)
{
	import std.process;
	import dub.semver;

	auto git_dir = path ~ ".git";
	if (!existsFile(git_dir) || !isDir(git_dir.toNativeString)) return null;
	auto git_dir_param = "--git-dir=" ~ git_dir.toNativeString();

	static string exec(scope string[] params...) {
		auto ret = executeShell(escapeShellCommand(params));
		if (ret.status == 0) return ret.output.strip;
		logDebug("'%s' failed with exit code %s: %s", params.join(" "), ret.status, ret.output.strip);
		return null;
	}

	auto tag = exec("git", git_dir_param, "describe", "--long", "--tags");
	if (tag !is null) {
		auto parts = tag.split("-");
		auto commit = parts[$-1];
		auto num = parts[$-2].to!int;
		tag = parts[0 .. $-2].join("-");
		if (tag.startsWith("v") && isValidVersion(tag[1 .. $])) {
			if (num == 0) return tag[1 .. $];
			else if (tag.canFind("+")) return format("%s.commit.%s.%s", tag[1 .. $], num, commit);
			else return format("%s+commit.%s.%s", tag[1 .. $], num, commit);
		}
	}

	auto branch = exec("git", git_dir_param, "rev-parse", "--abbrev-ref", "HEAD");
	if (branch !is null) {
		if (branch != "HEAD") return "~" ~ branch;
	}

	return null;
}
