/*
 * Created by Angel Leon (@gubatron), Alden Torres (aldenml)
 * Copyright (c) 2011-2016, FrostWire(R). All rights reserved.
 *
 * 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.dkf.jmule;

import android.content.ContentResolver;
import android.content.Context;
import android.net.Uri;
import android.os.ParcelFileDescriptor;
import android.os.storage.StorageManager;
import android.util.Pair;

import androidx.core.content.ContextCompat;
import androidx.documentfile.provider.DocumentFile;

import org.apache.commons.io.FileUtils;
import org.apache.commons.io.IOUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.*;
import java.lang.reflect.Array;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;

/**
 * @author gubatron
 * @author aldenml
 */
public final class LollipopFileSystem implements FileSystem {

    private static final Logger LOG = LoggerFactory.getLogger(LollipopFileSystem.class);

    private static List<String> FIXED_SDCARD_PATHS = buildFixedSdCardPaths();

    private final Context app;

    public LollipopFileSystem(Context app) {
        this.app = app;
    }

    @Override
    public boolean isDirectory(File file) {
        if (file.isDirectory()) {
            return true;
        }

        DocumentFile f = getDocument(app, file);

        return f != null && f.isDirectory();
    }

    @Override
    public boolean isFile(File file) {
        if (file.isFile()) {
            return true;
        }

        DocumentFile f = getDocument(app, file);

        return f != null && f.isFile();
    }

    @Override
    public boolean canRead(File file) {
        if (file.canRead()) {
            return true;
        }

        DocumentFile f = getDocument(app, file);

        return f != null && f.canRead();
    }

    @Override
    public boolean canWrite(File file) {
        if (file.canWrite()) {
            return true;
        }

        DocumentFile f = getDocument(app, file);

        return f != null && f.canWrite();
    }

    @Override
    public long length(File file) {
        long r = file.length();
        if (r > 0) {
            return r;
        }

        DocumentFile f = getDocument(app, file);

        return f != null ? f.length() : 0;
    }

    @Override
    public long lastModified(File file) {
        long r = file.lastModified();
        if (r > 0) {
            return r;
        }

        DocumentFile f = getDocument(app, file);

        return f != null ? f.lastModified() : 0;
    }

    @Override
    public boolean exists(File file) {
        if (file.exists()) {
            return true;
        }

        DocumentFile f = getDocument(app, file);

        return f != null && f.exists();
    }

    @Override
    public boolean mkdirs(File file) {
        if (file.mkdirs()) {
            return true;
        }

        DocumentFile f = getDirectory(app, file, false);
        if (f != null) {
            return false; // already exists
        }

        f = getDirectory(app, file, true);

        return f != null;
    }

    @Override
    public boolean delete(File file) {
        if (file.delete()) {
            return true;
        }

        DocumentFile f = getDocument(app, file);

        return f != null && f.delete();
    }

    @Override
    public File[] listFiles(File file, org.dkf.jmule.FileFilter filter) {
        try {
            File[] files = file.listFiles(filter);
            if (files != null) {
                return files;
            }
        } catch (Exception e) {
            // ignore, try with SAF
        }

        LOG.warn("Using SAF for listFiles, could be a costly operation");

        DocumentFile f = getDirectory(app, file, false);
        if (f == null) {
            return null; // does not exists
        }

        DocumentFile[] files = f.listFiles();
        if (filter != null && files != null) {
            List<File> result = new ArrayList<>(files.length);
            for (int i = 0; i < files.length; i++) {
                Uri uri = files[i].getUri();
                String path = getDocumentPath(uri);
                if (path == null) {
                    continue;
                }
                File child = new File(path);
                if (filter.accept(child)) {
                    result.add(child);
                }
            }

            return result.toArray(new File[0]);
        }

        return new File[0];
    }

    @Override
    public boolean copy(File src, File dest) {
        try {
            FileUtils.copyFile(src, dest);
            return true;
        } catch (Exception e) {
            // ignore
        }

        DocumentFile srcF = getFile(app, src, false);
        DocumentFile destF = getFile(app, dest, true);

        if (srcF == null) {
            LOG.error("Unable to obtain document for file: {}", src);
            return false;
        }

        if (destF == null) {
            LOG.error("Unable to obtain or create document for file: {}", dest);
            return false;
        }

        return copy(app, srcF, destF);
    }

    @Override
    public boolean write(File file, byte[] data) {
        try {
            FileUtils.writeByteArrayToFile(file, data);
            return true;
        } catch (IOException e) {
            // ignore
        }

        DocumentFile f = getFile(app, file, true);

        if (f == null) {
            LOG.error("Unable to obtain document for file: ", file);
            return false;
        }

        return write(app, f, data);
    }

    @Override
    public void scan(File file) {
        try {
            final List<String> paths = new LinkedList<>();
            if (isDirectory(file)) {
                walk(file, new org.dkf.jmule.FileFilter() {
                    @Override
                    public boolean accept(File file) {
                        return true;
                    }

                    @Override
                    public void file(File file) {
                        if (!file.isDirectory()) {
                            paths.add(file.getPath());
                        }
                    }
                });
            } else {
                paths.add(file.getPath()); // in case we are in a SD and it's an actual file
            }

            //if (paths.size() > 0) {
            //    MediaScanner.scanFiles(app, paths);
            //    UIUtils.broadcastAction(app, Constants.ACTION_FILE_ADDED_OR_REMOVED);
            //}
        } catch (Exception e) {
            LOG.error("Error scanning file/directory: {} {} ", file, e);
        }
    }

    @Override
    public void scan(List<File> files) {
        for(File file: files) {
            scan(file);
        }
    }

    @Override
    public void walk(File file, org.dkf.jmule.FileFilter filter) {
        DefaultFileSystem.walkFiles(this, file, filter);
    }

    public String getTreePath(Uri treeUri) {
        return getPath(app, treeUri, true);
    }

    public String getDocumentPath(Uri treeUri) {
        return getPath(app, treeUri, false);
    }

    public DocumentFile getDocument(File file) {
        return getDocument(app, file);
    }

    public String getExtSdCardFolder(File file) {
        return getExtSdCardFolder(app, file);
    }

    public Pair<ParcelFileDescriptor, DocumentFile> openFD(File file, String mode) {
        if (!("r".equals(mode) || "w".equals(mode) || "rw".equals(mode))) {
            LOG.error("Only r, w or rw modes supported");
            return null;
        }

        DocumentFile f = getFile(app, file, true);
        if (f == null) {
            LOG.error("Unable to obtain or create document for file: {}", file);
            return null;
        }

        try {
            ContentResolver cr = app.getContentResolver();
            return Pair.create(cr.openFileDescriptor(f.getUri(), mode), f);
        } catch (Exception e) {
            LOG.error("Unable to get native fd", e);
            return null;
        }
    }

    private static DocumentFile getDirectory(Context context, File dir, boolean create) {
        try {
            String path = dir.getAbsolutePath();

            String baseFolder = getExtSdCardFolder(context, dir);
            if (baseFolder == null) {
                if (create) {
                    return dir.mkdirs() ? DocumentFile.fromFile(dir) : null;
                } else {
                    return dir.isDirectory() ? DocumentFile.fromFile(dir) : null;
                }
            }

            baseFolder = combineRoot(baseFolder);
            String fullPath = dir.getAbsolutePath();
            String relativePath = baseFolder.length() < fullPath.length() ? fullPath.substring(baseFolder.length() + 1) : "";

            String[] segments = relativePath.isEmpty()?new String[0]:relativePath.split("/");

            Uri rootUri = getDocumentUri(context, new File(baseFolder));
            DocumentFile f = DocumentFile.fromTreeUri(context, rootUri);

            for (int i = 0; i < segments.length; i++) {
                String segment = segments[i];
                DocumentFile child = f.findFile(segment);
                if (child != null) {
                    f = child;
                } else {
                    if (create) {
                        f = f.createDirectory(segment);
                        if (f == null) {
                            return null;
                        }
                    } else {
                        return null;
                    }
                }
            }

            return f.isDirectory() ? f : null;
        } catch (Exception e) {
            LOG.error("Error getting directory: {} {}", dir, e);
            return null;
        }
    }

    private static DocumentFile getFile(Context context, File file, boolean create) {
        try {
            String path = file.getAbsolutePath();
            File parent = file.getParentFile();
            if (parent == null) {
                return DocumentFile.fromFile(file);
            }

            DocumentFile f = getDirectory(context, parent, false);
            if (f == null && create) {
                f = getDirectory(context, parent, create);
            }

            if (f != null) {
                String name = file.getName();
                DocumentFile child = f.findFile(name);
                if (child != null) {
                    if (child.isFile()) {
                        f = child;
                    } else {
                        f = null;
                    }
                } else {
                    if (create) {
                        f = f.createFile("application/octet-stream", name);
                        if (!f.getName().equals(name)) {
                            boolean renamed = f.renameTo(name);
                            if (!renamed) {
                                LOG.warn("Unable to rename file {} to {}", f.getName(), name);
                            }
                        }
                    } else {
                        f = null;
                    }
                }
            }

            return f;
        } catch (Exception e) {
            LOG.error("Error getting file: {} {}", file, e);
            return null;
        }
    }

    private static DocumentFile getDocument(Context context, File file) {
        try {
            String baseFolder = getExtSdCardFolder(context, file);
            if (baseFolder == null) {
                return file.exists() ? DocumentFile.fromFile(file) : null;
            }

            String fullPath = file.getAbsolutePath();
            String relativePath = baseFolder.length() < fullPath.length() ? fullPath.substring(baseFolder.length() + 1) : "";

            String[] segments = relativePath.isEmpty()?new String[0]:relativePath.split("/");

            Uri rootUri = getDocumentUri(context, new File(baseFolder));
            DocumentFile f = DocumentFile.fromTreeUri(context, rootUri);

            for (int i = 0; i < segments.length; i++) {
                String segment = segments[i];
                DocumentFile child = f.findFile(segment);
                if (child != null) {
                    f = child;
                } else {
                    return null;
                }
            }

            return f;
        } catch (Exception e) {
            LOG.error("Error getting document: {} {}", file, e);
            return null;
        }
    }

    private static Uri getDocumentUri(Context context, File file) {
        String baseFolder = getExtSdCardFolder(context, file);
        if (baseFolder == null) {
            return null;
        }

        String volumeId = getVolumeId(context, baseFolder);
        if (volumeId == null) {
            return null;
        }

        String fullPath = file.getAbsolutePath();
        String relativePath = baseFolder.length() < fullPath.length() ? fullPath.substring(baseFolder.length() + 1) : "";

        relativePath = relativePath.replace("/", "%2F");
        relativePath = relativePath.replace(" ", "%20");
        String uri = "content://com.android.externalstorage.documents/tree/" + volumeId + "%3A" + relativePath;

        return Uri.parse(uri);
    }

    private static String getPath(Context context, Uri treeUri, boolean tree) {
        if (treeUri == null) {
            return null;
        }

        StorageManager mStorageManager = (StorageManager) context.getSystemService(Context.STORAGE_SERVICE);

        String volumePath = getVolumePath(mStorageManager, getVolumeIdFromTreeUri(treeUri));
        if (volumePath == null) {
            return File.separator;
        }
        if (volumePath.endsWith(File.separator)) {
            volumePath = volumePath.substring(0, volumePath.length() - 1);
        }

        String documentPath = getDocumentPathFromTreeUri(treeUri, tree);
        if (documentPath.endsWith(File.separator)) {
            documentPath = documentPath.substring(0, documentPath.length() - 1);
        }

        String path = volumePath;

        if (documentPath.length() > 0) {
            if (documentPath.startsWith(File.separator)) {
                path = volumePath + documentPath;
            } else {
                path = volumePath + File.separator + documentPath;
            }
        }

        return path;
    }

    private static String getExtSdCardFolder(Context context, File file) {
        if (file.getAbsolutePath().contains("/Android/data/")) {
            return null;
        }

        String[] extSdPaths = getExtSdCardPaths(context);
        try {
            for (int i = 0; i < extSdPaths.length; i++) {
                String extSdPath = extSdPaths[i];
                if (file.getCanonicalPath().startsWith(extSdPath)) {
                    return extSdPath;
                }
            }
        } catch (IOException e) {
            return null;
        }
        return null;
    }

    private static String[] getExtSdCardPaths(Context context) {
        List<String> paths = new ArrayList<>();
        File[] externals = ContextCompat.getExternalFilesDirs(context, "external");
        File external = context.getExternalFilesDir("external");
        for (int i = 0; i < externals.length; i++) {
            File file = externals[i];
            if (file != null && !file.equals(external)) {
                String absolutePath = file.getAbsolutePath();
                int index = absolutePath.lastIndexOf("/Android/data");
                if (index >= 0) {
                    String path = absolutePath.substring(0, index);
                    try {
                        path = new File(path).getCanonicalPath();
                    } catch (IOException e) {
                        // Keep non-canonical path.
                    }
                    paths.add(path);
                } else {
                    LOG.warn("ext sd card path wrong: {}", absolutePath);
                }
            }
        }
        // special hard coded paths for more security
        for (String path : FIXED_SDCARD_PATHS) {
            if (!paths.contains(path)) {
                paths.add(path);
            }
        }

        return paths.toArray(new String[0]);
    }

    private static String getVolumeId(Context context, final String volumePath) {
        try {
            StorageManager mStorageManager = (StorageManager) context.getSystemService(Context.STORAGE_SERVICE);

            Class<?> storageVolumeClazz = Class.forName("android.os.storage.StorageVolume");

            Method getVolumeList = mStorageManager.getClass().getMethod("getVolumeList");
            Method getUuid = storageVolumeClazz.getMethod("getUuid");
            Method getPath = storageVolumeClazz.getMethod("getPath");
            Object result = getVolumeList.invoke(mStorageManager);

            final int length = Array.getLength(result);
            for (int i = 0; i < length; i++) {
                Object storageVolumeElement = Array.get(result, i);
                String path = (String) getPath.invoke(storageVolumeElement);

                if (path != null) {
                    if (path.equals(volumePath)) {
                        return (String) getUuid.invoke(storageVolumeElement);
                    }
                }
            }

            // not found.
            return null;
        } catch (Exception ex) {
            return null;
        }
    }

    private static String getVolumePath(StorageManager mStorageManager, final String volumeId) {
        try {
            Class<?> storageVolumeClazz = Class.forName("android.os.storage.StorageVolume");

            Method getVolumeList = mStorageManager.getClass().getMethod("getVolumeList");
            Method getUuid = storageVolumeClazz.getMethod("getUuid");
            Method getPath = storageVolumeClazz.getMethod("getPath");
            Method isPrimary = storageVolumeClazz.getMethod("isPrimary");
            Object result = getVolumeList.invoke(mStorageManager);

            final int length = Array.getLength(result);
            for (int i = 0; i < length; i++) {
                Object storageVolumeElement = Array.get(result, i);
                String uuid = (String) getUuid.invoke(storageVolumeElement);
                Boolean primary = (Boolean) isPrimary.invoke(storageVolumeElement);

                // primary volume?
                if (primary && "primary".equals(volumeId)) {
                    return (String) getPath.invoke(storageVolumeElement);
                }

                // other volumes?
                if (uuid != null) {
                    if (uuid.equals(volumeId)) {
                        return (String) getPath.invoke(storageVolumeElement);
                    }
                }
            }

            // not found.
            return null;
        } catch (Exception ex) {
            return null;
        }
    }

    private static String getVolumeIdFromTreeUri(final Uri treeUri) {
        final String docId = getTreeDocumentId(treeUri);
        final String[] split = docId.split(":");

        if (split.length > 0) {
            return split[0];
        } else {
            return null;
        }
    }

    private static String getDocumentPathFromTreeUri(final Uri treeUri, boolean tree) {
        final String docId = tree ? getTreeDocumentId(treeUri) : getDocumentDocumentId(treeUri);
        final String[] split = docId.split(":");
        if ((split.length >= 2) && (split[1] != null)) {
            return split[1];
        } else {
            return File.separator;
        }
    }

    private static String getTreeDocumentId(Uri documentUri) {
        final List<String> paths = documentUri.getPathSegments();
        if (paths.size() >= 2 && "tree".equals(paths.get(0))) {
            return paths.get(1);
        }
        throw new IllegalArgumentException("Invalid URI: " + documentUri);
    }

    private static String getDocumentDocumentId(Uri documentUri) {
        final List<String> paths = documentUri.getPathSegments();
        if (paths.size() >= 4 && "document".equals(paths.get(2))) {
            return paths.get(3);
        }
        throw new IllegalArgumentException("Invalid URI: " + documentUri);
    }

    private static String combineRoot(String baseFolder) {
        String root = Platforms.appSettings().string(Constants.PREF_KEY_STORAGE_PATH);

        return root != null && root.startsWith(baseFolder) ? root : baseFolder;
    }

    //------------ more tools methods

    private static boolean copy(Context context, DocumentFile source, DocumentFile target) {
        InputStream inStream = null;
        OutputStream outStream = null;
        try {
            inStream = context.getContentResolver().openInputStream(source.getUri());
            outStream = openOutputStream(context, target);

            byte[] buffer = new byte[16384]; // MAGIC_NUMBER
            int bytesRead;
            while ((bytesRead = inStream.read(buffer)) != -1) {
                outStream.write(buffer, 0, bytesRead);
            }

        } catch (Exception e) {
            LOG.error("Error when copying file from {} to {} {}", source.getUri(), target.getUri(), e);
            return false;
        } finally {
            IOUtils.closeQuietly(inStream);
            IOUtils.closeQuietly(outStream);
        }

        return true;
    }

    private static boolean write(Context context, DocumentFile f, byte[] data) {
        InputStream inStream = null;
        OutputStream outStream = null;
        try {
            inStream = new ByteArrayInputStream(data);
            outStream = openOutputStream(context, f);

            byte[] buffer = new byte[16384]; // MAGIC_NUMBER
            int bytesRead;
            while ((bytesRead = inStream.read(buffer)) != -1) {
                outStream.write(buffer, 0, bytesRead);
            }

        } catch (Exception e) {
            LOG.error("Error when writing bytes to {} {}", f.getUri(), e);
            return false;
        } finally {
            IOUtils.closeQuietly(inStream);
            IOUtils.closeQuietly(outStream);
        }

        return true;
    }

    private static OutputStream openOutputStream(Context context, DocumentFile f) throws IOException {
        ContentResolver cr = context.getContentResolver();
        ParcelFileDescriptor pfd = cr.openFileDescriptor(f.getUri(), "rw");

        int fd = pfd.detachFd(); // this trick the internal system to trigger the media scanner on nothing
        pfd = ParcelFileDescriptor.adoptFd(fd);

        return new AutoSyncOutputStream(pfd);
    }

    private static List<String> buildFixedSdCardPaths() {
        LinkedList<String> l = new LinkedList<>();

        l.add("/storage/sdcard1"); // Motorola Xoom
        l.add("/storage/extsdcard"); // Samsung SGS3
        l.add("/storage/sdcard0/external_sdcard"); // user request
        l.add("/mnt/extsdcard");
        l.add("/mnt/sdcard/external_sd"); // Samsung galaxy family
        l.add("/mnt/external_sd");
        l.add("/mnt/media_rw/sdcard1"); // 4.4.2 on CyanogenMod S3
        l.add("/removable/microsd"); // Asus transformer prime
        l.add("/mnt/emmc");
        l.add("/storage/external_SD"); // LG
        l.add("/storage/ext_sd"); // HTC One Max
        l.add("/storage/removable/sdcard1"); // Sony Xperia Z1
        l.add("/data/sdext");
        l.add("/data/sdext2");
        l.add("/data/sdext3");
        l.add("/data/sdext4");
        l.add("/storage/16F7-2B0E"); // emulator

        return Collections.unmodifiableList(l);
    }

    private static final class AutoSyncOutputStream extends ParcelFileDescriptor.AutoCloseOutputStream {

        public AutoSyncOutputStream(ParcelFileDescriptor fd) throws IOException {
            super(fd);
        }

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

        private void sync() {
            try {
                getFD().sync();
            } catch (Exception e) {
                // ignore
            }
        }
    }
}
