/**
 * This file is part of muCommander, http://www.mucommander.com
 *
 * muCommander is free software; you can redistribute it and/or modify
 * it under the terms of the GNU Lesser General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * muCommander 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 Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */


package com.mucommander.commons.file.protocol.smb;

import com.mucommander.commons.file.*;
import com.mucommander.commons.file.filter.FilenameFilter;
import com.mucommander.commons.file.protocol.FileProtocols;
import com.mucommander.commons.file.protocol.ProtocolFile;
import com.mucommander.commons.io.RandomAccessInputStream;
import com.mucommander.commons.io.RandomAccessOutputStream;
import jcifs.smb.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.MalformedURLException;
import java.util.Collections;


/**
 * SMBFile provides access to files located on an SMB/CIFS server.
 * <p>
 * The associated {@link FileURL} scheme is {@link FileProtocols#SMB}. The host part of the URL designates the
 * SMB server. Credentials are specified in the login and password parts. The path separator is '/'.
 * </p>
 * <p>
 * Here are a few examples of valid SMB URLs:
 * <code>
 * smb://server/path/to/file<br>
 * smb://domain;username:password@server/path/to/file<br>
 * smb://workgroup/<br>
 * </code>
 * </p>
 * <p>
 * The special 'smb://' URL represents the SMB root and lists all workgroups that are available on the network,
 * akin to Windows' network neighborhood.
 * </p>
 * <p>
 * Access to SMB files is provided by the <code>jCIFS</code> library distributed under the LGPL license.
 * The {@link #getUnderlyingFileObject()} method allows to retrieve a <code>jcifs.smb.SmbFile</code> instance
 * corresponding to this <code>SMBFile</code>.
 * </p>
 *
 * @author Maxence Bernard
 */
 public class SMBFile extends ProtocolFile {
    private static final Logger LOGGER = LoggerFactory.getLogger(SMBFile.class);

    private SmbFile file;
    private FilePermissions permissions;

    private AbstractFile parent;
    private boolean parentValSet;

    /** Bit mask that indicates which permissions can be changed. Only the 'write' permission for 'user' access can
     *  be changed. */
    private final static PermissionBits CHANGEABLE_PERMISSIONS = new GroupedPermissionBits(128);   // -w------- (200 octal)

    
    protected SMBFile(FileURL fileURL) throws IOException {
        this(fileURL, null);
    }

    protected SMBFile(FileURL fileURL, SmbFile smbFile) throws IOException {
        super(fileURL);

        if(!fileURL.containsCredentials())
            throw new AuthException(fileURL, "Authentication required");

        if(smbFile==null) {
            while(true) {
                file = createSmbFile(fileURL);

                // The following test comes at a cost, so it's only used by the public constructor, SmbFile instances
                // created by this class are considered OK.
                try {
                    // SmbFile requires a trailing slash for directories otherwise listFiles() will throw an SmbException.
                    // As we cannot guarantee that the path will contain a trailing slash for directories, we test if the
                    // SmbFile is a directory and if it doesn't contain a trailing slash, we create a new SmbFile with
                    // a trailing slash.
                    // SmbFile.isDirectory() will throw an SmbAuthException if access to the file requires different credentials.
                    if(file.isDirectory() && !getURL().getPath().endsWith("/")) {
                        // Add trailing slash and loop to create a new SmbFile
                        fileURL.setPath(fileURL.getPath()+'/');
                        continue;
                    }

                    break;
                }
                catch(SmbException e) {
                    // SmbFile.isDirectory() threw an exception. We distinguish 2 types of SmbException:
                    // 1) SmbAuthException, caused by a credentials problem -> turn it into an AuthException and throw it
                    // 2) any other SmbException -> this may happen if access to the file was denied for example, this
                    //    shouldn't prevent this SMBFile from being created.

                    // 1) Create an AuthException out of the SmbAuthException and throw it
                    if(e instanceof SmbAuthException)
                        throw new AuthException(fileURL, e.getMessage());

                    // 2) Swallow the exception to let this SMBFile be created
                    break;
                }
            }
        }
        else {                      // The private constructor was called directly
            file = smbFile;
        }

        permissions = new SMBFilePermissions(file);
    }


    /**
     * Creates and returns a <code>jcifs.smb.SmbFile</code> for the given location. The credentials contained by
     * the {@link FileURL} (if any) are passed along to the <code>SmbFile</code>.
     *
     * @param url the location to the SmbFile file to create
     * @return an SmbFile corresponding to the given location
     * @throws MalformedURLException if an error occurred while creating the SmbFile instance
     */
    private static SmbFile createSmbFile(FileURL url) throws MalformedURLException {
        Credentials credentials = url.getCredentials();
        if(credentials==null)
            return new SmbFile(url.toString(false));

        // Extract the domain (if any) from the username
        String login = credentials.getLogin();
        String domain;
        int domainStart = login.indexOf(";");
        if(domainStart!=-1) {
            domain = login.substring(0, domainStart);
            login = login.substring(domainStart+1, login.length());
        }
        else {
            domain = null;
        }

        // A NtlmPasswordAuthentication is created from the FileURL credentials and passed to a specific SmbFile constructor.
        // The reason for doing this rather than using the SmbFile(String) constructor is that SmbFile uses java.net.URL
        // for the URL parsing which is unable to properly parse urls where the password contains a '@' character,
        // such as smb://user:p@ssword@host/path . 
        return new SmbFile(url.toString(false), new NtlmPasswordAuthentication(domain, login, credentials.getPassword()));
    }


    /**
     * Background information: <code>jcifs.smb.SmbFile</code> is a tad cumbersome to work with because it requires its
     * file path to end with '/' when the file is a directory and vice-versa.
     * This method ensures that the path of the current <code>jcifs.smb.SmbFile</code> instance matches the
     * <code>directory</code> argument and if not, recreates it with the proper path.
     *
     * @param directory true if the current <code>jcifs.smb.SmbFile</code> designates a directory
     */
    private void checkSmbFile(boolean directory) {
        try {
            String path = file.getURL().getPath();
            boolean endsWithSeparator = path.endsWith("/");

            if(directory) {
                if(!endsWithSeparator) {
                    fileURL.setPath(path+"/");
                    file = createSmbFile(fileURL);
                }
            }
            else {
                if(endsWithSeparator) {
                    fileURL.setPath(removeTrailingSeparator(path));
                    file = createSmbFile(fileURL);
                }
            }
        }
        catch(MalformedURLException e) {
            // This should never happen. If some reason wicked reason it ever did, SmbFile would just not be changed.
        }
    }

    /**
     * Sets the time period during which attributes values (e.g. isDirectory, last modified, ...) are cached by
     * jcifs.smb.SmbFile. The higher this value, the lower the number of network requests but also the longer it takes
     * before those attributes can be refreshed.
     *
     * @param period time period during which attributes values are cached, in milliseconds
     */
    public static void setAttributeCachingPeriod(long period) {
        jcifs.Config.setProperty("jcifs.smb.client.attrExpirationPeriod", ""+period);
    }


    /////////////////////////////////////////
    // AbstractFile methods implementation //
    /////////////////////////////////////////

    @Override
    public long getDate() {
        try {
            return file.lastModified();
        }
        catch(SmbException e) {
            return 0;
        }
    }

    @Override
    public void changeDate(long lastModified) throws IOException {
        file.setLastModified(lastModified);
    }

    @Override
    public long getSize() {
        try {
            return file.length();
        }
        catch(SmbException e) {
            return 0;
        }
    }

    @Override
    public AbstractFile getParent() {
        if(!parentValSet) {
            FileURL parentURL = fileURL.getParent();
            if(parentURL!=null) {
                parent = FileFactory.getFile(parentURL);
                // Note: parent may be null if it can't be resolved
            }
            // Note: do not make the special smb:// file a parent of smb://host/, this would cause parent unit tests to fail

            parentValSet = true;
        }

        return parent;
    }

    @Override
    public void setParent(AbstractFile parent) {
        this.parent = parent;
        this.parentValSet = true;
    }

    @Override
    public boolean exists() {
        // Unlike java.io.File, SmbFile.exists() can throw an SmbException
        try {
            return file.exists();
        }
        catch(IOException e) {
            LOGGER.info("Exception caught while calling SmbFile#exists(): " +   e.getMessage());

            return e instanceof SmbAuthException;
        }
    }

    @Override
    public FilePermissions getPermissions() {
        return permissions;
    }

    @Override
    public PermissionBits getChangeablePermissions() {
        return CHANGEABLE_PERMISSIONS;
    }

    @Override
    public void changePermission(PermissionAccess access, PermissionType permission, boolean enabled) throws IOException {
        if(access!=PermissionAccess.USER || permission!=PermissionType.WRITE)
            throw new IOException();

        if(enabled)
            file.setReadWrite();
        else
            file.setReadOnly();
    }

    /**
     * Always returns <code>null</code>, this information is not available unfortunately.
     */
    @Override
    public String getOwner() {
        return null;
    }

    /**
     * Always returns <code>false</code>, this information is not available unfortunately.
     */
    @Override
    public boolean canGetOwner() {
        return false;
    }

    /**
     * Always returns <code>null</code>, this information is not available unfortunately.
     */
    @Override
    public String getGroup() {
        return null;
    }

    /**
     * Always returns <code>false</code>, this information is not available unfortunately.
     */
    @Override
    public boolean canGetGroup() {
        return false;
    }

    @Override
    public boolean isDirectory() {
        try {
            return file.isDirectory();
        }
        catch(SmbException e) {
            return false;
        }
    }

    @Override
    public boolean isSymlink() {
        // Symlinks are not supported by jCIFS (or maybe by CIFS/SMB?)
        return false;
    }

    @Override
    public boolean isSystem() {
        return false;
    }

    @Override
    public InputStream getInputStream() throws IOException {
        return new SmbFileInputStream(file);
    }

    @Override
    public OutputStream getOutputStream() throws IOException {
        return new SmbFileOutputStream(file, false);
    }

    @Override
    public OutputStream getAppendOutputStream() throws IOException {
        return new SmbFileOutputStream(file, true);
    }

    @Override
    public RandomAccessInputStream getRandomAccessInputStream() throws IOException {
        // This needs to be checked explicitly (SmbRandomAccessFile can be created even if the file does not exist)
        if(!exists())
            throw new IOException();

//        // Explicitly allow the file to be read/write/delete by another random access file while this one is open
//        return new SMBRandomAccessInputStream(new SmbRandomAccessFile(fileURL.toString(true), "r", SmbFile.FILE_SHARE_READ | SmbFile.FILE_SHARE_WRITE | SmbFile.FILE_SHARE_DELETE));
        return new SMBRandomAccessInputStream(new SmbRandomAccessFile(file, "r"));
    }

    @Override
    public RandomAccessOutputStream getRandomAccessOutputStream() throws IOException {
//        // Explicitly allow the file to be read/write/delete by another random access file while this one is open
//        return new SMBRandomAccessOutputStream(new SmbRandomAccessFile(fileURL.toString(true), "rw", SmbFile.FILE_SHARE_READ | SmbFile.FILE_SHARE_WRITE | SmbFile.FILE_SHARE_DELETE));
        return new SMBRandomAccessOutputStream(new SmbRandomAccessFile(file, "rw"));
    }

    @Override
    public void delete() throws IOException {
        file.delete();
        checkSmbFile(false);
    }

    @Override
    public AbstractFile[] ls() throws IOException {
        return ls(null);
    }

    @Override
    public void mkdir() throws IOException {
        // Ensure that the jcifs.smb.SmbFile's path ends with a '/' otherwise it will throw an exception
        checkSmbFile(true);

        // Note: unlike java.io.File.mkdir(), SmbFile does not return a boolean value
        // to indicate if the folder could be created
        file.mkdir();
    }

    @Override
    public void copyRemotelyTo(AbstractFile destFile) throws IOException {
        // Throw an exception if the file cannot be renamed to the specified destination.
        // This method fails in situations where SmbFile#copyTo() doesn't, for instance:
        // - when the destination file exists (the destination is simply overwritten)
        // - when the source file doesn't exist
        checkCopyRemotelyPrerequisites(destFile, false, false);

        // Reuse the destination SmbFile instance
        SmbFile destSmbFile = ((SMBFile)destFile).file;

        // Remotely copy the file
        file.copyTo(destSmbFile);

        // Ensure that the destination jcifs.smb.SmbFile's path is consistent with its new directory/non-directory state
        ((SMBFile)destFile).checkSmbFile(file.isDirectory());
    }

    /**
     * Implementation notes: server-to-server renaming will work if the destination file also uses the 'SMB' scheme.
     * Hosts do not necessarily have to be the same for this operation to succeed.
     */
    @Override
    public void renameTo(AbstractFile destFile) throws IOException {
        // Throw an exception if the file cannot be renamed to the specified destination.
        // This method fails in situations where SFTPFile#renameTo() doesn't, for instance:
        // - when the source and destination are the same
        // - when the source file doesn't exist
        checkRenamePrerequisites(destFile, true, true);

        // Attempt to move the file using jcifs.smb.SmbFile#renameTo.

        boolean isDirectory = file.isDirectory();

        // Rename the file
        file.renameTo(((SMBFile)destFile).file);

        // Ensure that the destination jcifs.smb.SmbFile's path is consistent with its new directory/non-directory state
        ((SMBFile)destFile).checkSmbFile(isDirectory);
    }

    @Override
    public long getFreeSpace() throws IOException {
        return file.getDiskFreeSpace();
    }

    /**
     * Always throws {@link UnsupportedFileOperationException} when called.
     *
     * @throws UnsupportedFileOperationException, always
     */
    @Override
    @UnsupportedFileOperation
    public long getTotalSpace() throws UnsupportedFileOperationException {
        // No way to retrieve this information with jCIFS
        throw new UnsupportedFileOperationException(FileOperation.GET_TOTAL_SPACE);
    }

    /**
     * Returns a <code>jcifs.smb.SmbFile</code> instance corresponding to this file.
     */
    @Override
    public Object getUnderlyingFileObject() {
        return file;
    }


    ////////////////////////
    // Overridden methods //
    ////////////////////////

    @Override
    public AbstractFile[] ls(FilenameFilter filenameFilter) throws IOException {
        try {
            SmbFile smbFiles[] = file.listFiles(filenameFilter==null?null:new SMBFilenameFilter(filenameFilter));

            if(smbFiles==null)
                throw new IOException();

            // Count the number of files to exclude: excluded files are those that are not file share/ not browsable
            // (Printers, named pipes, comm ports)
            int nbSmbFiles = smbFiles.length;
            int nbSmbFilesToExclude = 0;
            int smbFileType;
            for(int i=0; i<nbSmbFiles; i++) {
                smbFileType = smbFiles[i].getType();
                if(smbFileType==SmbFile.TYPE_PRINTER || smbFileType==SmbFile.TYPE_NAMED_PIPE || smbFileType==SmbFile.TYPE_COMM)
                    nbSmbFilesToExclude++;
            }

            // Create SMBFile by using SmbFile instance and sharing parent instance among children
            AbstractFile children[] = new AbstractFile[nbSmbFiles-nbSmbFilesToExclude];
            FileURL childURL;
            SmbFile smbFile;
            int currentIndex = 0;

            for(int i=0; i<nbSmbFiles; i++) {
                smbFile = smbFiles[i];
                smbFileType = smbFile.getType();
                if(smbFileType==SmbFile.TYPE_PRINTER || smbFileType==SmbFile.TYPE_NAMED_PIPE || smbFileType==SmbFile.TYPE_COMM)
                    continue;

                // Note: properties and credentials are cloned for every children's url
                childURL = (FileURL)fileURL.clone();
                childURL.setHost(smbFile.getServer());
                childURL.setPath(smbFile.getURL().getPath());

                // Use SMBFile private constructor to recycle the SmbFile instance
                children[currentIndex++] = FileFactory.getFile(childURL, this, Collections.singletonMap("parentSmbFile", smbFile));
            }

            return children;
        }
        catch(SmbAuthException e) {
            throw new AuthException(fileURL, e.getMessage());
        }
    }

    @Override
    public boolean isHidden() {
        try {
            return file.isHidden();
        }
        catch(SmbException e) {
            return false;
        }
    }


    @Override
    public boolean equalsCanonical(Object f) {
        if(!(f instanceof SMBFile))
            return super.equalsCanonical(f);		// could be equal to an AbstractArchiveFile

        // SmbFile's equals method is just perfect: compares canonical paths
        // and IP addresses
        return file.equals(((SMBFile)f).file);
    }


    ///////////////////
    // Inner classes //
    ///////////////////

    /**
     * SMBRandomAccessInputStream extends RandomAccessInputStream to provide random read access to an SMBFile.
     */
    public static class SMBRandomAccessInputStream extends RandomAccessInputStream {

        private SmbRandomAccessFile raf;

        public SMBRandomAccessInputStream(SmbRandomAccessFile raf) {
            this.raf = raf;
        }

        @Override
        public int read() throws IOException {
            return raf.read();
        }

        @Override
        public int read(byte b[], int off, int len) throws IOException {
            return raf.read(b, off, len);
        }

        @Override
        public void close() throws IOException {
            raf.close();
        }

        public long getOffset() throws IOException {
            return raf.getFilePointer();
        }

        public long getLength() throws IOException {
            return raf.length();
        }

        public void seek(long offset) throws IOException {
            raf.seek(offset);
        }
    }

    /**
     * SMBRandomAccessOutputStream extends RandomAccessOutputStream to provide random write access to an SMBFile.
     */
    public static class SMBRandomAccessOutputStream extends RandomAccessOutputStream {

        private SmbRandomAccessFile raf;

        public SMBRandomAccessOutputStream(SmbRandomAccessFile raf) {
            this.raf = raf;
        }

        @Override
        public void write(int i) throws IOException {
            raf.write(i);
        }

        @Override
        public void write(byte b[]) throws IOException {
            raf.write(b);
        }

        @Override
        public void write(byte b[], int off, int len) throws IOException {
            raf.write(b, off, len);
        }

        @Override
        public void close() throws IOException {
            raf.close();
        }

        public long getOffset() throws IOException {
            return raf.getFilePointer();
        }

        public long getLength() throws IOException {
            return raf.length();
        }

        public void seek(long offset) throws IOException {
            raf.seek(offset);
        }

        @Override
        public void setLength(long newLength) throws IOException {
            raf.setLength(newLength);

            // jCIFS doesn't automatically position the offset to the end of the file when it is truncated.
            // We have to do it ourselves to honour this method's contract.   
            if(getOffset()>newLength)
                raf.seek(newLength);
        }
    }


    /**
     * A Permissions implementation for SMBFile.
     */
    private static class SMBFilePermissions extends IndividualPermissionBits implements FilePermissions {

        private SmbFile file;

        private final static PermissionBits MASK = new GroupedPermissionBits(384);  // rw------- (300 octal)

        public SMBFilePermissions(SmbFile file) {
            this.file = file;
        }

        public boolean getBitValue(PermissionAccess access, PermissionType type) {
            if(access!=PermissionAccess.USER)
                return false;

            try {
            	switch(type) {
            	case READ:
            		return file.canRead();
            	case WRITE:
            		return file.canWrite();
            	default:
            		return false;
            	}
            }
            // Unlike java.io.File, SmbFile#canRead() and SmbFile#canWrite() can throw an SmbException
            catch(SmbException e) {
                return false;
            }
        }

        public PermissionBits getMask() {
            return MASK;
        }
    }


    /**
     * Turns a {@link FilenameFilter} into a {@link jcifs.smb.SmbFilenameFilter}.
     */
    private static class SMBFilenameFilter implements jcifs.smb.SmbFilenameFilter {

        private FilenameFilter filter;

        private SMBFilenameFilter(FilenameFilter filter) {
            this.filter = filter;
        }


        ////////////////////////////////////////////////
        // jicfs.smb.SmbFilenameFilter implementation //
        ////////////////////////////////////////////////

        public boolean accept(SmbFile dir, String name) throws SmbException {
            return filter.accept(name);
        }
    }
}
