/**
 * MediaSniper 3.0 (2008-08-02)
 * Copyright 2007 - 2008 Zach Scrivena
 * zachscrivena@gmail.com
 * http://mediasniper.sourceforge.net/
 *
 * Simple program for downloading media files from popular websites.
 *
 * TERMS AND CONDITIONS:
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */

package org.freeshell.zs.mediasniper;

import java.io.File;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import org.freeshell.zs.common.HtmlManipulator;
import org.freeshell.zs.common.StringManipulator;


/**
 * Manage the local filenames used for downloads.
 */
class LocalFilenameManager
{
    /** illegal filename characters */
    private static final String ILLEGAL_FILENAME_CHARS =  "\\/:*?\"<>|";

    /** reserved device names; must all be in upper case */
    private static final String[] RESERVED_DEVICE_NAMES =
    {
        "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7",
        "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9"
    };

    /** legal filename characters */
    private static final String RESTRICTED_ASCII_CHARS =
            "ABCDEFGHIJKLMNOPQRSTUVWXYZ" +
            "abcdefghijklmnopqrstuvwxyz" +
            "0123456789 `~!@#$%^&()-=_+[]{};',.";

    /** character substitution table (raw format) */
    private static final String[] CHAR_SUBSTITUTION_TABLE_RAW =
    {
        /* Array of "source-target" pairs, where any character in the "source" string is to be */
        /* substituted by the "target" string.                                                 */
        /* Example: {"*?", "_"} means that the characters * and ? are to be substituted by _   */
        "\"", "'",
        "<", "(",
        ">", ")",
        ":", "-",
        "?*|/\\", " "
    };

    /** character substitution table */
    private static final Map<Character,String> CHAR_SUBSTITUTION_TABLE = new HashMap<Character,String>();

    /** registered filenames (canonical full pathnames) */
    private final Set<File> filenames = new HashSet<File>();


    /**
    * Static initialization block.
    */
    static
    {
        /* simple check to ensure RAW_TABLE is not accidentally modified */
        if ((CHAR_SUBSTITUTION_TABLE_RAW.length % 2) != 0)
        {
            throw new RuntimeException("(INTERNAL) Malformed CHAR_SUBSTITUTION_TABLE_RAW in LocalFilenameManager.");
        }

        for (int i = 0; i < CHAR_SUBSTITUTION_TABLE_RAW.length; i += 2)
        {
            for (char c : CHAR_SUBSTITUTION_TABLE_RAW[i].toCharArray())
            {
                CHAR_SUBSTITUTION_TABLE.put(c, CHAR_SUBSTITUTION_TABLE_RAW[i + 1]);
            }
        }
    }


    /**
    * Check if the specified pathname is valid according to file-naming conventions
    * (see http://msdn2.microsoft.com/en-us/library/aa365247.aspx).
    *
    * @param pathname
    *     Pathname to be checked
    * @return
    *     null if the specified pathname is valid; an error message otherwise
    */
    static String isValidPathname(
            final String pathname)
    {
        /* check for degenerate filename */
        if (pathname.isEmpty())
            return "Empty pathname";

        /* check each component of the pathname */

        /* initialize parent pathname to the neutral-form of the specified pathname */
        String parent = (File.separatorChar == '/') ? pathname : pathname.replace(File.separatorChar, '/');

        /* do not allow double file separator (e.g. // or \\) */
        final int doubleFileSeparator = parent.indexOf("//", 1); // skip UNC-style names beginning with "//"
        if (doubleFileSeparator >= 0)
            return "Pathname contains double file separator \"" +
                    pathname.substring(doubleFileSeparator, doubleFileSeparator + 2) + "\"";

        /* drop trailing '/' character */
        if (!parent.isEmpty() && !"/".equals(parent) && !"//".equals(parent) &&
                parent.endsWith("/"))
        {
            parent = parent.substring(0, parent.length() - 1);
        }

        while (true)
        {
            /* check for root directory */
            if (parent.isEmpty() || "/".equals(parent) || "//".equals(parent) ||
                    ((parent.length() == 2) && (parent.charAt(1) == ':'))) // Windows-style prefix (e.g. "C:")
            {
                break;
            }

            /* get name of the last component */
            String component = null;
            final int lastSlash = parent.lastIndexOf('/');

            if (lastSlash >= 0)
            {
                component = parent.substring(lastSlash + 1);
                parent = parent.substring(0, lastSlash);
            }
            else
            {
                component = parent;
                parent = "";
            }

            /* check name length (1 <= len <= 255) */
            if (component.isEmpty())
                return "Pathname contains empty component";

            if (component.length() > 255)
                return "Pathname component \"" + component + "\" is longer than 255 characters";

            /* check for illegal characters */
            for (char c : component.toCharArray())
            {
                if (c < 32)
                    return "Pathname component \"" + component + "\" contains illegal character of value " +
                            ((int) c) + " (i.e. in the range 0 to 31)";

                if (ILLEGAL_FILENAME_CHARS.contains(c + ""))
                    return "Pathname component \"" + component + "\" contains illegal character '" + c +
                            "' (characters " + ILLEGAL_FILENAME_CHARS + " are not allowed)";
            }

            /* check for illegal trailing characters */
            if (component.endsWith(" "))
                return "Pathname component \"" + component + "\" contains trailing space ' '";

            if (component.endsWith("."))
                return "Pathname component \"" + component + "\" contains trailing period '.'";

            /* check if filename is a device name (e.g. "COM1"), or */
            /* a device name with an extension (e.g. "COM1.txt")    */
            final String nameUpperCase = component.toUpperCase(Locale.ENGLISH);

            for (String s : RESERVED_DEVICE_NAMES)
            {
                if (nameUpperCase.equals(s) || nameUpperCase.startsWith(s + "."))
                    return "Pathname component \"" + component + "\" clashes with reserved device name \"" + s + "\"";
            }
        }

        /* check if Java can create the corresponding File object */
        File f = null;

        try
        {
            f = (new File(pathname)).getCanonicalFile();
        }
        catch (Exception e)
        {
            final String error = e.getMessage();
            return "Failed to create Java File object for pathname" +
                    ((error == null) ? "" : (" (" + error + ")"));
        }

        /* specified pathname is valid */
        return null;
    }


    /**
    * Return a "clean" filename, derived from the specified base filename and file extension.
    * A "clean" filename satisfies three conditions:
    *  - it is valid according to file naming conventions
    *    (see http://msdn2.microsoft.com/en-us/library/aa365247.aspx)
    *  - it is not already taken by an earlier request
    *  - it does not already exist
    *
    * @param base
    *     Base filename
    * @param ext
    *     File extension (assumed to be nonempty and valid)
    * @param parentDirectory
    *     Parent directory for this file (canonical full pathname)
    * @param restrictedAsciiFilenamesMode
    *     true if restricted ASCII filename is to be generated; false otherwise
    * @param shortFilenamesMode
    *     true if short (8.3) filename is to be generated; false otherwise
    * @return
    *     File object (canonical full pathname) representing the "clean" filename;
    *     null if such a filename cannot be generated
    */
    File getCleanFilename(
            final String base,
            final String ext,
            final File parentDirectory,
            final boolean restrictedAsciiFilenamesMode,
            final boolean shortFilenamesMode)
    {
        /* initialize desired "clean" base filename */
        String cleanBase = base;

        /* replace HTML entities with Unicode characters */
        cleanBase = HtmlManipulator.replaceHtmlEntities(cleanBase);

        /* perform character substitutions */
        final StringBuilder t = new StringBuilder();

        for (char c : cleanBase.toCharArray())
        {
            final String s = CHAR_SUBSTITUTION_TABLE.get(c);

            if (s == null)
            {
                t.append(c);
            }
            else
            {
                t.append(s);
            }
        }

        cleanBase = t.toString();

        /* drop all illegal characters */
        t.delete(0, t.length()); // reset

        for (char c : cleanBase.toCharArray())
        {
            if ((c >= 32) &&
                    (!ILLEGAL_FILENAME_CHARS.contains(c + "")) &&
                    (!restrictedAsciiFilenamesMode || RESTRICTED_ASCII_CHARS.contains(c + "")))
            {
                t.append(c);
            }
        }

        cleanBase = t.toString();

        /* replace contiguous whitespace with a single space */
        cleanBase = StringManipulator.deleteExtraWhitespace(cleanBase);

        /* trim off undesirable trailing and leading strings */
        cleanBase = StringManipulator.trimTrailingStrings(cleanBase, new String[] {".", " ", ext});
        cleanBase = StringManipulator.trimLeadingStrings(cleanBase, new String[] {".", " "});

        /* check if filename is a device name (e.g. "COM1"), or */
        /* a device name with an extension (e.g. "COM1.txt")    */
        String cleanBaseUpperCase = cleanBase.toUpperCase(Locale.ENGLISH);

        for (String s : RESERVED_DEVICE_NAMES)
        {
            if (cleanBaseUpperCase.equals(s) || cleanBaseUpperCase.startsWith(s + "."))
            {
                /* add an underscore '_' prefix */
                cleanBase = "_" + cleanBase;
                cleanBaseUpperCase = "_" + cleanBaseUpperCase;
            }
        }

        /* initialize desired "clean" file extension */
        String cleanExt = ext;

        /* truncate filename */
        if (shortFilenamesMode)
        {
            /* allow up to "8.3" characters */
            if (cleanBase.length() > 8)
            {
                cleanBase = cleanBase.substring(0, 8);
            }

            if (cleanExt.length() > 3)
            {
                cleanExt = cleanExt.substring(0, 3);
            }
        }
        else
        {
            /* allow up to 255 characters (including file extension) */
            if (cleanBase.length() + 1 + cleanExt.length() > 255)
            {
                final int truncatedBaseLength = 255 - (1 + cleanExt.length());

                if (truncatedBaseLength < 1)
                {
                    /* failed to generate filename */
                    return null;
                }

                cleanBase = cleanBase.substring(0, truncatedBaseLength);
            }
        }

        /* validate filename */
        String name = cleanBase + "." + cleanExt;

        if (cleanBase.isEmpty() || (isValidPathname(name) != null))
            return null;

        File f = new File(parentDirectory, name);

        synchronized (filenames)
        {
            if (filenames.contains(f) || f.exists())
            {
                /* conflict found; append an integer suffix to the base filename */
                f = null;

                for (int i = 1; i < Integer.MAX_VALUE; i++)
                {
                    final String baseSuffix = "-" + i;

                    if (shortFilenamesMode)
                    {
                        /* allow up to "8.3" characters */
                        if (cleanBase.length() + baseSuffix.length() > 8)
                        {
                            final int truncatedBaseLength = 8 - baseSuffix.length();

                            if (truncatedBaseLength < 1)
                                return null; // failed to generate filename

                            /* truncate base filename */
                            name = cleanBase.substring(0, truncatedBaseLength) + baseSuffix + "." + cleanExt;
                        }
                        else
                        {
                            name = cleanBase + baseSuffix + "." + cleanExt;
                        }
                    }
                    else
                    {
                        /* allow up to 255 characters (including file extension) */
                        if (cleanBase.length() + baseSuffix.length() + 1 + cleanExt.length() > 255)
                        {
                            final int truncatedBaseLength = 255 - (baseSuffix.length() + 1 + cleanExt.length());

                            if (truncatedBaseLength < 1)
                                return null; // failed to generate filename

                            /* truncate base filename */
                            name = cleanBase.substring(0, truncatedBaseLength) + baseSuffix + "." + cleanExt;
                        }
                        else
                        {
                            name = cleanBase + baseSuffix + "." + cleanExt;
                        }
                    }

                    f = new File(parentDirectory, name);

                    if (filenames.contains(f) || f.exists())
                    {
                        /* conflict still exists */
                        f = null;
                    }
                    else
                    {
                        /* no conflict; found an unused filename */
                        break;
                    }
                }
            }

            if (f != null)
            {
                /* no conflict; register this filename */
                filenames.add(f);
            }
        }

        return f;
    }


    /**
    * Replace a previously assigned filename with the given filename.
    *
    * @param oldFile
    *     File object that was previously assigned
    * @param parentDirectory
    *     Parent directory for the new file (canonical full pathname)
    * @param newFilename
    *     Replacement filename
    * @return
    *     File object (canonical full pathname) representing the replacement filename
    *     if successful; null otherwise
    */
    File replaceFilename(
            final File oldFile,
            final File parentDirectory,
            final String newFilename)
    {
        if (isValidPathname(newFilename) != null)
        {
            return null;
        }

        File newFile = new File(newFilename);

        /* get canonical full pathname of the new file */
        try
        {
            if (newFile.isAbsolute())
            {
                newFile = newFile.getCanonicalFile();
            }
            else
            {
                /* resolve against specified parent directory */
                newFile = new File(parentDirectory, newFilename).getCanonicalFile();
            }
        }
        catch (Exception e)
        {
            return null;
        }

        if (newFile.equals(oldFile) && newFile.getPath().equals(oldFile.getPath()))
            return oldFile; // no change

        if (newFile.exists())
            return null;

        synchronized (filenames)
        {
            if (!filenames.contains(oldFile))
            {
                return null;
            }

            if (!newFile.equals(oldFile) &&
                    filenames.contains(newFile))
            {
                return null;
            }

            /* release previously assigned filename; error if not found */
            if (!filenames.remove(oldFile))
            {
                return null;
            }

            /* register replacement filename */
            filenames.add(newFile);
        }

        return newFile;
    }


    /**
    * Release a previously assigned filename.
    *
    * @param releaseFile
    *     File object that was previously assigned
    */
    void releaseFilename(
            final File releaseFile)
    {
        synchronized (filenames)
        {
            filenames.remove(releaseFile);
        }
    }
}