package com.platymuus.bukkit.permissions;

import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.configuration.InvalidConfigurationException;
import org.bukkit.configuration.file.FileConfiguration;
import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.command.ConsoleCommandSender;
import org.bukkit.entity.Player;
import org.bukkit.permissions.Permissible;
import org.bukkit.permissions.PermissionAttachment;
import org.bukkit.plugin.java.JavaPlugin;
import org.bukkit.util.FileUtil;
import org.bukkit.Server;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.PrintStream;
import java.lang.reflect.Field;
import java.util.*;
import java.util.logging.Level;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.lang.reflect.Method;

/**
 * Main class for PermissionsBukkit.
 */
public final class PermissionsPlugin extends JavaPlugin {
	private static Method get_online_players_method;

	private final PlayerListener playerListener = new PlayerListener(this);
	private final PermissionsCommand commandExecutor = new PermissionsCommand(this);
	private final PermissionsTabComplete tabCompleter = new PermissionsTabComplete(this);
	private final PermissionsMetrics metrics = new PermissionsMetrics(this);

	private final HashMap<UUID, PermissionAttachment> permissions = new HashMap<UUID, PermissionAttachment>();
	private PermissionAttachment console_permissions;

	private Server server;
	private File configFile;
	private FileConfiguration config;

	public boolean configLoadError = false;

	@Override
	public void onLoad() {
		server = getServer();
	}

	// -- Basic stuff
	@Override
	public void onEnable() {
		// Take care of configuration
		configFile = new File(getDataFolder(), "config.yml");
		saveDefaultConfig();
		reloadConfig();

		// Register stuff
		getCommand("permissions").setExecutor(commandExecutor);
		getCommand("permissions").setTabCompleter(tabCompleter);
		server.getPluginManager().registerEvents(playerListener, this);

		ConsoleCommandSender console = server.getConsoleSender();
		console_permissions = console.addAttachment(this);
		calculateAttachment(console);

		int player_count = 0;
		try {
			if(get_online_players_method == null) {
				get_online_players_method = Server.class.getDeclaredMethod("getOnlinePlayers");
			}
			if(get_online_players_method.getReturnType().isArray()) {
				Player[] players = (Player[])get_online_players_method.invoke(server);
				for(Player p : players) registerPlayer(p);
				player_count = players.length;
			} else {
				Collection<Player> players = (Collection<Player>)get_online_players_method.invoke(server);
				for(Player p : players) registerPlayer(p);
				player_count = players.size();
			}
		} catch(Exception e) {
			e.printStackTrace();
		}

		// Metrics are fun!
		try {
			metrics.start();
		} catch (IOException ex) {
			getLogger().warning("Failed to connect to plugin metrics: " + ex.getMessage());
		}

		// How are you gentlemen
		if(player_count > 0) {
			getLogger().info(String.format("Enabled successfully, %d online player%s registered",
				player_count, player_count > 1 ? "s" : ""));
		} else {
			// "0 players registered" sounds too much like an error
			getLogger().info("Enabled successfully");
		}
	}

    @Override
    public FileConfiguration getConfig() {
        return config;
    }

    @Override
    public void reloadConfig() {
        config = new YamlConfiguration();
        config.options().pathSeparator('/');
        try {
            config.load(configFile);
        } catch (InvalidConfigurationException ex) {
            configLoadError = true;

            // extract line numbers from the exception if we can
            ArrayList<String> lines = new ArrayList<String>();
            Pattern pattern = Pattern.compile("line (\\d+), column");
            Matcher matcher = pattern.matcher(ex.getMessage());
            while (matcher.find()) {
                String lineNo = matcher.group(1);
                if (!lines.contains(lineNo)) {
                    lines.add(lineNo);
                }
            }

            // make a nice message
            String msg = "Your configuration is invalid! ";
            if (lines.size() == 0) {
                msg += "Unable to find any line numbers.";
            } else {
                msg += "Take a look at line(s): " + lines.get(0);
                for (String lineNo : lines.subList(1, lines.size())) {
                    msg += ", " + lineNo;
                }
            }
            getLogger().severe(msg);

            // save the whole error to config_error.txt
            try {
                File outFile = new File(getDataFolder(), "config_error.txt");
                PrintStream out = new PrintStream(new FileOutputStream(outFile));
                out.println("Use the following website to help you find and fix configuration errors:");
                out.println("https://yaml-online-parser.appspot.com/");
                out.println();
                out.println(ex.toString());
                out.close();
                getLogger().info("Saved the full error message to " + outFile);
            } catch (IOException ex2) {
                getLogger().severe("Failed to save the full error message!");
            }

            // save a backup
            File backupFile = new File(getDataFolder(), "config_backup.yml");
            File sourceFile = new File(getDataFolder(), "config.yml");
            if (FileUtil.copy(sourceFile, backupFile)) {
                getLogger().info("Saved a backup of your configuration to " + backupFile);
            } else {
                getLogger().severe("Failed to save a configuration backup!");
            }
        } catch (Exception ex) {
            getLogger().log(Level.SEVERE, "Failed to load configuration", ex);
        }
    }

    @Override
    public void saveConfig() {
        // If there's no keys (such as in the event of a load failure) don't save
        if (config.getKeys(true).size() > 0) {
            try {
                config.save(configFile);
            } catch (IOException ex) {
                getLogger().log(Level.SEVERE, "Failed to save configuration", ex);
            }
        }
    }

	@Override
	public void onDisable() {
		int player_count = 0;
		try {
			if(get_online_players_method.getReturnType().isArray()) {
				Player[] players = (Player[])get_online_players_method.invoke(server);
				for(Player p : players) unregisterPlayer(p);
				player_count = players.length;
			} else {
				Collection<Player> players = (Collection<Player>)get_online_players_method.invoke(server);
				for(Player p : players) unregisterPlayer(p);
				player_count = players.size();
			}
		} catch(Exception e) {
			e.printStackTrace();
		}

		// Good day to you! I said good day!
		if(player_count > 0) {
			getLogger().info(String.format("Disabled successfully, %d online players unregistered", player_count));
		}
	}

    // -- External API

    /**
     * Get the group with the given name.
     *
     * @param groupName The name of the group.
     * @return A Group if it exists or null otherwise.
     */
    public Group getGroup(String groupName) {
        metrics.apiUsed();
        if (getNode("groups") != null) {
            for (String key : getNode("groups").getKeys(false)) {
                if (key.equalsIgnoreCase(groupName)) {
                    return new Group(this, key);
                }
            }
        }
        return null;
    }

    /**
     * Returns a list of groups a player is in.
     *
     * @param playerName The name of the player.
     * @return The groups this player is in. May be empty.
     * @deprecated Use UUIDs instead.
     */
    @Deprecated
    public List<Group> getGroups(String playerName) {
        metrics.apiUsed();
        ArrayList<Group> result = new ArrayList<Group>();
        ConfigurationSection node = getUsernameNode(playerName);
        if (node != null) {
            for (String key : node.getStringList("groups")) {
                result.add(new Group(this, key));
            }
        } else {
            result.add(new Group(this, "default"));
        }
        return result;
    }

    /**
     * Returns a list of groups a player is in.
     *
     * @param player The uuid of the player.
     * @return The groups this player is in. May be empty.
     */
    public List<Group> getGroups(UUID player) {
        metrics.apiUsed();
        ArrayList<Group> result = new ArrayList<Group>();
        if (getNode("users/" + player) != null) {
            for (String key : getNode("users/" + player).getStringList("groups")) {
                result.add(new Group(this, key));
            }
        } else {
            result.add(new Group(this, "default"));
        }
        return result;
    }

    /**
     * Returns permission info on the given player.
     *
     * @param playerName The name of the player.
     * @return A PermissionsInfo about this player.
     * @deprecated Use UUIDs instead.
     */
    @Deprecated
    public PermissionInfo getPlayerInfo(String playerName) {
        metrics.apiUsed();
        ConfigurationSection node = getUsernameNode(playerName);
        if (node == null) {
            return null;
        } else {
            return new PermissionInfo(this, node, "groups");
        }
    }

    /**
     * Returns permission info on the given player.
     *
     * @param player The uuid of the player.
     * @return A PermissionsInfo about this player.
     */
    public PermissionInfo getPlayerInfo(UUID player) {
        metrics.apiUsed();
        if (getNode("users/" + player) == null) {
            return null;
        } else {
            return new PermissionInfo(this, getNode("users/" + player), "groups");
        }
    }

    /**
     * Returns a list of all defined groups.
     *
     * @return The list of groups.
     */
    public List<Group> getAllGroups() {
        metrics.apiUsed();
        ArrayList<Group> result = new ArrayList<Group>();
        if (getNode("groups") != null) {
            for (String key : getNode("groups").getKeys(false)) {
                result.add(new Group(this, key));
            }
        }
        return result;
    }

    // -- Plugin stuff

    protected PermissionsMetrics getMetrics() {
        return metrics;
    }

    protected void registerPlayer(Player player) {
        if (permissions.containsKey(player.getUniqueId())) {
            debug("Registering " + player.getName() + ": was already registered");
            unregisterPlayer(player);
        }
        PermissionAttachment attachment = player.addAttachment(this);
        permissions.put(player.getUniqueId(), attachment);
        calculateAttachment(player);
    }

    protected void unregisterPlayer(Player player) {
        if (permissions.containsKey(player.getUniqueId())) {
            try {
                player.removeAttachment(permissions.get(player.getUniqueId()));
            } catch (IllegalArgumentException ex) {
                debug("Unregistering " + player.getName() + ": player did not have attachment");
            }
            permissions.remove(player.getUniqueId());
        } else {
            debug("Unregistering " + player.getName() + ": was not registered");
        }
    }

    protected void refreshForPlayer(UUID player) {
        saveConfig();
        debug("Refreshing for player " + player);

        Player onlinePlayer = server.getPlayer(player);
        if (onlinePlayer != null) {
            calculateAttachment(onlinePlayer);
        }
    }

    private void fillChildGroups(HashSet<String> childGroups, String group) {
        if (childGroups.contains(group)) return;
        childGroups.add(group);

        for (String key : getNode("groups").getKeys(false)) {
            for (String parent : getNode("groups/" + key).getStringList("inheritance")) {
                if (parent.equalsIgnoreCase(group)) {
                    fillChildGroups(childGroups, key);
                }
            }
        }
    }

    protected void refreshForGroup(String group) {
        saveConfig();

        // build the set of groups which are children of "group"
        // e.g. if Bob is only a member of "expert" which inherits "user", he
        // must be updated if the permissions of "user" change
        HashSet<String> childGroups = new HashSet<String>();
        fillChildGroups(childGroups, group);
        debug("Refreshing for group " + group + " (total " + childGroups.size() + " subgroups)");

        for (UUID uuid : permissions.keySet()) {
            Player player = server.getPlayer(uuid);
            ConfigurationSection node = getUserNode(player);

            // if the player isn't in the config, act like they're in default
            List<String> groupList = (node != null) ? node.getStringList("groups") : Arrays.asList("default");
            for (String userGroup : groupList) {
                if (childGroups.contains(userGroup)) {
                    calculateAttachment(player);
                    break;
                }
            }
        }
    }

	protected void refreshPermissions() {
		debug("Refreshing permissions for console");
		calculateAttachment(server.getConsoleSender());
		debug("Refreshing all permissions (for " + permissions.size() + " players)");
		for (UUID player : permissions.keySet()) {
			calculateAttachment(server.getPlayer(player));
		}
	}

    protected ConfigurationSection getNode(String node) {
        for (String entry : getConfig().getKeys(true)) {
            if (node.equalsIgnoreCase(entry) && getConfig().isConfigurationSection(entry)) {
                return getConfig().getConfigurationSection(entry);
            }
        }
        return null;
    }

    protected ConfigurationSection getUserNode(Player player) {
        ConfigurationSection sec = getNode("users/" + player.getUniqueId());
        if (sec == null) {
            sec = getNode("users/" + player.getName());
            if (sec != null) {
                getConfig().set(sec.getCurrentPath(), null);
                getConfig().set("users/" + player.getUniqueId(), sec);
                sec.set("name", player.getName());
                debug("Migrated " + player.getName() + " to UUID " + player.getUniqueId());
                saveConfig();
            }
        }

        // make sure name field matches
        if (sec != null) {
            if (!player.getName().equals(sec.getString("name"))) {
                debug("Updating name of " + player.getUniqueId() + " to: " + player.getName());
                sec.set("name", player.getName());
                saveConfig();
            }
        }

        return sec;
    }

    protected ConfigurationSection getUsernameNode(String name) {
        // try to look up node based on username rather than UUID
        ConfigurationSection sec = getNode("users");
        if (sec != null) {
            for (String child : sec.getKeys(false)) {
                ConfigurationSection node = sec.getConfigurationSection(child);
                if (node != null && (name.equals(node.getString("name")) || name.equals("child"))) {
                    // either the "name" field matches or the key matches
                    return node;
                }
            }
        }
        return null;
    }

    protected ConfigurationSection createNode(String node) {
        ConfigurationSection sec = getConfig();
        for (String piece : node.split("/")) {
            ConfigurationSection sec2 = getNode(sec == getConfig() ? piece : sec.getCurrentPath() + "/" + piece);
            if (sec2 == null) {
                sec2 = sec.createSection(piece);
            }
            sec = sec2;
        }
        return sec;
    }

    protected HashMap<String, Boolean> getAllPerms(String desc, String path) {
        ConfigurationSection node = getNode(path);

        int failures = 0;
        String firstFailure = "";

        // Make an attempt to autofix incorrect nesting
        boolean fixed = false, fixedNow = true;
        while (fixedNow) {
            fixedNow = false;
            for (String key : node.getKeys(true)) {
                if (node.isBoolean(key) && key.contains("/")) {
                    node.set(key.replace("/", "."), node.getBoolean(key));
                    node.set(key, null);
                    fixed = fixedNow = true;
                } else if (node.isConfigurationSection(key) && node.getConfigurationSection(key).getKeys(true).size() == 0) {
                    node.set(key, null);
                    fixed = fixedNow = true;
                }
            }
        }
        if (fixed) {
            getLogger().info("Fixed broken nesting in " + desc + ".");
            saveConfig();
        }

        LinkedHashMap<String, Boolean> result = new LinkedHashMap<String, Boolean>();
        // Do the actual getting of permissions
        for (String key : node.getKeys(false)) {
            if (node.isBoolean(key)) {
                result.put(key, node.getBoolean(key));
            } else {
                ++failures;
                if (firstFailure.length() == 0) {
                    firstFailure = key;
                }
            }
        }

        if (failures == 1) {
            getLogger().warning("In " + desc + ": " + firstFailure + " is non-boolean.");
        } else if (failures > 1) {
            getLogger().warning("In " + desc + ": " + firstFailure + " is non-boolean (+" + (failures - 1) + " more).");
        }

        return result;
    }

    protected void debug(String message) {
        if (getConfig().getBoolean("debug", false)) {
            getLogger().info("Debug: " + message);
        }
    }

	protected void calculateAttachment(Permissible permissible) {
		if (permissible == null) return;

		PermissionAttachment attachment;
		Map<String, Boolean> values;
		String name;

		if(permissible instanceof Player) {
			Player player = (Player)permissible;
			attachment = permissions.get(player.getUniqueId());
			if (attachment == null) {
				debug("Calculating permissions on " + player.getName() + ": attachment was null");
				return;
			}
        		values = calculatePlayerPermissions(player, player.getWorld().getName());
			name = player.getName();
		} else if(permissible instanceof ConsoleCommandSender) {
			attachment = console_permissions;
			values = calculate_console_permissions();
			name = "CONSOLE";
		} else return;


		// Fill the attachment reflectively so we don't recalculate for each permission
		// it turns out there's a lot of permissions!
		Map<String, Boolean> dest = reflectMap(attachment);
		dest.clear();
		dest.putAll(values);
		debug(String.format("Calculated permissions on %s: %d values", name, dest.size()));
		permissible.recalculatePermissions();
	}

    // -- Private stuff

    private Field pField;

    @SuppressWarnings("unchecked")
    private Map<String, Boolean> reflectMap(PermissionAttachment attachment) {
        try {
            if (pField == null) {
                pField = PermissionAttachment.class.getDeclaredField("permissions");
                pField.setAccessible(true);
            }
            return (Map<String, Boolean>) pField.get(attachment);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    // normally, LinkedHashMap.put (and thus putAll) will not reorder the list
    // if that key is already in the map, which we don't want - later puts should
    // always be bumped to the end of the list
    private <K, V> void put(Map<K, V> dest, K key, V value) {
        dest.remove(key);
        dest.put(key, value);
    }

    private <K, V> void putAll(Map<K, V> dest, Map<K, V> src) {
        for (Map.Entry<K, V> entry : src.entrySet()) {
            put(dest, entry.getKey(), entry.getValue());
        }
    }

    private Map<String, Boolean> calculatePlayerPermissions(Player player, String world) {
        ConfigurationSection node = getUserNode(player);

        // if the player isn't in the config, act like they're in default
        if (node == null) {
            return calculateGroupPermissions("default", world);
        }

        String nodePath = node.getCurrentPath();
        Map<String, Boolean> perms = new LinkedHashMap<String, Boolean>();

        // first, apply the player's groups (getStringList returns an empty list if not found)
        // later groups override earlier groups
        for (String group : node.getStringList("groups")) {
            putAll(perms, calculateGroupPermissions(group, world));
        }

        // now apply user-specific permissions
        if (getNode(nodePath + "/permissions") != null) {
            putAll(perms, getAllPerms("user " + player, nodePath + "/permissions"));
        }

        // now apply world- and user-specific permissions
        if (getNode(nodePath + "/worlds/" + world) != null) {
            putAll(perms, getAllPerms("user " + player + " world " + world, nodePath + "/worlds/" + world));
        }

        return perms;
    }

    private Map<String, Boolean> calculateGroupPermissions(String group, String world) {
        return calculateGroupPermissions0(new HashSet<String>(), group, world);
    }

    private Map<String, Boolean> calculateGroupPermissions0(Set<String> recursionBuffer, String group, String world) {
        String groupNode = "groups/" + group;

        // if the group's not in the config, nothing
        if (getNode(groupNode) == null) {
            return new LinkedHashMap<String, Boolean>();
        }

        recursionBuffer.add(group);
        Map<String, Boolean> perms = new LinkedHashMap<String, Boolean>();

        // first apply any parent groups (see calculatePlayerPermissions for more)
        for (String parent : getNode(groupNode).getStringList("inheritance")) {
            if (recursionBuffer.contains(parent)) {
                getLogger().warning("In group " + group + ": recursive inheritance from " + parent);
                continue;
            }

            putAll(perms, calculateGroupPermissions0(recursionBuffer, parent, world));
        }

        // now apply the group's permissions
        if (getNode(groupNode + "/permissions") != null) {
            putAll(perms, getAllPerms("group " + group, groupNode + "/permissions"));
        }

        // now apply world-specific permissions
        if (world != null && getNode(groupNode + "/worlds/" + world) != null) {
            putAll(perms, getAllPerms("group " + group + " world " + world, groupNode + "/worlds/" + world));
        }

        return perms;
    }

	private Map<String, Boolean> calculate_console_permissions() {
		ConfigurationSection node = getNode("console");
		if(node == null) return null;
		Map<String, Boolean> perms = new LinkedHashMap<String, Boolean>();
		for(String group : node.getStringList("groups")) {
			putAll(perms, calculateGroupPermissions(group, null));
		}
		String permissions_path = node.getCurrentPath() + "/permissions";
		if(getNode(permissions_path) != null) {
			putAll(perms, getAllPerms("console", permissions_path));
		}
		return perms;
	}
}
