/*
 * Copyright (c) 2020 Microsoft Corporation. All rights reserved.
 * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
 *
 * This code is free software; you can redistribute it and/or modify it
 * under the terms of the GNU General Public License version 2 only, as
 * published by the Free Software Foundation.
 *
 * This code 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
 * version 2 for more details (a copy is included in the LICENSE file that
 * accompanied this code).
 *
 * You should have received a copy of the GNU General Public License version
 * 2 along with this work; if not, write to the Free Software Foundation,
 * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
 *
 * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
 * or visit www.oracle.com if you need additional information or have any
 * questions.
 *
 */

import java.util.Map;
import java.util.Optional;
import java.util.stream.Stream;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.io.ByteArrayOutputStream;
import java.io.PrintStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;

import static org.testng.Assert.*;
import org.testng.annotations.BeforeClass;
import org.testng.annotations.AfterClass;
import org.testng.annotations.AfterMethod;
import org.testng.annotations.Test;
import org.testng.SkipException;

import jdk.test.lib.process.ProcessTools;
import jdk.test.lib.process.OutputAnalyzer;

/* @test
 * @summary Test Files' public APIs with drives created using the subst command on Windows.
 * @requires (os.family == "windows")
 * @library /test/lib ..
 * @build SubstDrive
 * @run testng SubstDrive
 */
public class SubstDrive {

    private static Path SUBST_DRIVE;
    private static Path TEST_TEMP_DIRECTORY;

    /**
     * Setup for the test:
     *      + Create a temporary directory where all subsequently created temp
     *        directories will be in. This directory and all of its contents will be
     *        deleted when the test finishes.
     *      + Find a drive that is available for use with subst.
     */
    @BeforeClass
    public void setup() throws IOException {
        TEST_TEMP_DIRECTORY = Files.createTempDirectory("tmp");
        System.out.printf("Test directory is at %s\n", TEST_TEMP_DIRECTORY);

        Optional<Path> substDrive = findAvailableDrive(TEST_TEMP_DIRECTORY);
        if (!substDrive.isPresent()) {
            throw new SkipException(
                "Could not find any available drive to use with subst, skipping the tests");
        }
        SUBST_DRIVE = substDrive.get();
        System.out.printf("Using drive %s\n with subst", SUBST_DRIVE);
    }

    /**
     * Delete the root temporary directory together with all of its contents
     * when all tests finish.
     */
    @AfterClass
    public void removeRootTempDirectory() throws IOException {
        TestUtil.removeAll(TEST_TEMP_DIRECTORY);
    }

    /**
     * Each test method maps drive `SUBST_DRIVE` to a temporary directory,
     * unmap the drive after every test so that subsequent ones can reuse
     * the drive.
     */
    @AfterMethod
    public void deleteSubstDrive() throws IOException {
        Stream<String> substitutedDrives = substFindMappedDrives();
        // Only delete `SUBST_DRIVE` if it is currently being substituted
        if (substitutedDrives.anyMatch(e -> e.contains(SUBST_DRIVE.toString()))) {
            substDelete(SUBST_DRIVE);
        }
    }

    /**
     * Test whether files can be created in the substituted drive.
     */
    @Test
    public void testCreateAndDeleteFile() throws IOException {
        Path tempDirectory = Files.createTempDirectory(TEST_TEMP_DIRECTORY, "tmp");
        substCreate(SUBST_DRIVE, tempDirectory);

        String fileContents = "Hello world!";
        Path p = Path.of(SUBST_DRIVE.toString(), "testFile.txt");
        Files.createFile(p);

        assertTrue(Files.exists(p));

        Files.writeString(p, fileContents);
        assertEquals(Files.readString(p), fileContents);
    }

    /**
     * Test if we can delete the substituted drive (essentially just a directory).
     */
    @Test
    public void testDeleteSubstitutedDrive() throws IOException {
        Path tempDirectory = Files.createTempDirectory(TEST_TEMP_DIRECTORY, "tmp");
        substCreate(SUBST_DRIVE, tempDirectory);

        assertTrue(Files.exists(tempDirectory));
        Files.delete(SUBST_DRIVE);
        assertTrue(Files.notExists(tempDirectory));
    }

    /**
     * Test if the attributes returned by the Files' APIs are consistent when
     * using the actual path and the substituted path.
     */
    @Test
    public void testAttributes() throws IOException {
        Path tempDirectory = Files.createTempDirectory(TEST_TEMP_DIRECTORY, "tmp");
        substCreate(SUBST_DRIVE, tempDirectory);

        assertTrue(Files.isSameFile(tempDirectory, SUBST_DRIVE));

        assertEquals(
            Files.isExecutable(tempDirectory),
            Files.isExecutable(SUBST_DRIVE));

        assertEquals(
            Files.isReadable(tempDirectory),
            Files.isReadable(SUBST_DRIVE));

        assertEquals(
            Files.isDirectory(tempDirectory),
            Files.isDirectory(SUBST_DRIVE));

        assertEquals(
            Files.isHidden(tempDirectory),
            Files.isHidden(SUBST_DRIVE));

        assertEquals(
            Files.isRegularFile(tempDirectory),
            Files.isRegularFile(SUBST_DRIVE));

        assertEquals(
            Files.isSymbolicLink(tempDirectory),
            Files.isSymbolicLink(SUBST_DRIVE));

        assertEquals(
            Files.getOwner(tempDirectory),
            Files.getOwner(SUBST_DRIVE));

        assertEquals(
            Files.isWritable(tempDirectory),
            Files.isWritable(SUBST_DRIVE));
    }

    /**
     * Test if setting attributes for a substituted path works the same way
     * as it would for a real path.
     */
    @Test
    public void testGetSetAttributes() throws IOException {
        Path tempDirectory = Files.createTempDirectory(TEST_TEMP_DIRECTORY, "tmp");
        substCreate(SUBST_DRIVE, tempDirectory);

        Files.setAttribute(SUBST_DRIVE, "dos:hidden", true);
        assertTrue(Files.isHidden(SUBST_DRIVE));
        assertTrue(Files.isHidden(tempDirectory));

        Files.setAttribute(tempDirectory, "dos:hidden", false);
        assertFalse(Files.isHidden(SUBST_DRIVE));
        assertFalse(Files.isHidden(tempDirectory));

        Map<String, Object> attr1 = Files.readAttributes(SUBST_DRIVE, "*");
        Map<String, Object> attr2 = Files.readAttributes(tempDirectory, "*");
        assertEquals(attr1, attr2);
    }

    /**
     * Test if the FileStores returned from using substituted path and real path
     * are the same.
     */
    @Test
    public void testFileStore() throws IOException {
        Path tempDirectory = Files.createTempDirectory(TEST_TEMP_DIRECTORY, "tmp");
        substCreate(SUBST_DRIVE, tempDirectory);

        FileStore fileStore1 = Files.getFileStore(tempDirectory);
        FileStore fileStore2 = Files.getFileStore(SUBST_DRIVE);

        assertEquals(
            fileStore1.getTotalSpace(),
            fileStore2.getTotalSpace());

        assertEquals(
            fileStore1.getBlockSize(),
            fileStore2.getBlockSize());

        assertEquals(
            fileStore1.name(),
            fileStore2.name());

        assertEquals(
            fileStore1.type(),
            fileStore2.type());

        assertEquals(
            SUBST_DRIVE.getFileSystem().getRootDirectories(),
            tempDirectory.getFileSystem().getRootDirectories());
    }

    /**
     * Test if Files.copy works correctly on a substituted drive, and that
     * all of the attributes are the same.
     */
    @Test
    public void testMoveAndCopySubstDrive() throws IOException {
        Path tempDirectory = Files.createTempDirectory(TEST_TEMP_DIRECTORY, "tmp");
        Path tempDirectoryCopy = Path.of(tempDirectory.toString() + "_copy");

        substCreate(SUBST_DRIVE, tempDirectory);

        Files.copy(SUBST_DRIVE, tempDirectoryCopy);

        assertEquals(
            Files.isExecutable(SUBST_DRIVE),
            Files.isExecutable(tempDirectoryCopy));

        assertEquals(
            Files.isReadable(SUBST_DRIVE),
            Files.isReadable(tempDirectoryCopy));

        assertEquals(
            Files.isDirectory(SUBST_DRIVE),
            Files.isDirectory(tempDirectoryCopy));

        assertEquals(
            Files.isHidden(SUBST_DRIVE),
            Files.isHidden(tempDirectoryCopy));

        assertEquals(
            Files.isRegularFile(SUBST_DRIVE),
            Files.isRegularFile(tempDirectoryCopy));

        assertEquals(
            Files.isWritable(SUBST_DRIVE),
            Files.isWritable(tempDirectoryCopy));

        assertEquals(
            Files.getOwner(SUBST_DRIVE),
            Files.getOwner(tempDirectoryCopy));
    }

    /**
     * Test if the attributes of a resolved symlink are the same as its target's
     * Note: requires administrator privileges.
     */
    @Test
    public void testGetResolvedSymlinkAttribute() throws IOException {
        if (!TestUtil.supportsLinks(TEST_TEMP_DIRECTORY)) {
            return;
        }

        Path tempDirectory = Files.createTempDirectory(TEST_TEMP_DIRECTORY, "tmp");
        substCreate(SUBST_DRIVE, tempDirectory);

        Path tempFile = Path.of(SUBST_DRIVE.toString(), "test.txt");
        String contents = "Hello world!";
        Files.writeString(tempFile, contents);
        assertEquals(Files.readString(tempFile), contents);

        Path link = Path.of(SUBST_DRIVE.toString(), "link");
        Files.createSymbolicLink(link, tempFile);

        assertEquals(Files.readString(link), contents);
        assertEquals(Files.isExecutable(link), Files.isExecutable(tempFile));
        assertEquals(Files.isReadable(link), Files.isReadable(tempFile));
        assertEquals(Files.isDirectory(link), Files.isDirectory(tempFile));
        assertEquals(Files.isHidden(link), Files.isHidden(tempFile));
        assertEquals(Files.isRegularFile(link), Files.isRegularFile(tempFile));
        assertEquals(Files.isWritable(link), Files.isWritable(tempFile));
        assertEquals(Files.getOwner(link), Files.getOwner(tempFile));
    }

    /**
     * Test if files and directories can be created, moved, and cut when the
     * substituted drive is a symlink.
     * Note: requires administrator privileges.
     */
    @Test
    public void testSubstWithSymlinkedDirectory() throws IOException {
        if (!TestUtil.supportsLinks(TEST_TEMP_DIRECTORY)) {
            return;
        }

        Path tempDirectory = Files.createTempDirectory(TEST_TEMP_DIRECTORY, "tmp");
        Path tempLink = Path.of(tempDirectory.toString() + "_link");
        Files.createSymbolicLink(tempLink, tempDirectory);

        substCreate(SUBST_DRIVE, tempLink);

        assertEquals(
            Files.readAttributes(SUBST_DRIVE, "*"),
            Files.readAttributes(tempDirectory, "*"));

        assertTrue(Files.isWritable(SUBST_DRIVE));

        Path tempFile = Files.createTempFile(SUBST_DRIVE, "prefix", "suffix");
        String contents = "Hello world!";
        Files.writeString(tempFile, contents);
        assertEquals(Files.readString(tempFile), contents);

        Path tempDirectory2 = Files.createTempDirectory(TEST_TEMP_DIRECTORY, "tmp");
        Path copy = Path.of(tempDirectory2.toString(), "copied");
        Files.copy(tempFile, copy);

        assertTrue(Files.exists(copy));
        assertEquals(Files.readString(copy), contents);

        Path cut = Path.of(tempDirectory2.toString(), "cut");
        Files.move(tempFile, cut);
        assertTrue(Files.notExists(tempFile));
        assertTrue(Files.exists(cut));
        assertEquals(Files.readString(cut), contents);
    }

    /**
     * When the substituted drive is a symlink, test if it has the same
     * attributes as its target.
     * Note: requires administrator privileges.
     */
    @Test
    public void testMoveAndCopyFilesToSymlinkedDrive() throws IOException {
        if (!TestUtil.supportsLinks(TEST_TEMP_DIRECTORY)) {
            return;
        }

        Path tempDirectory = Files.createTempDirectory(TEST_TEMP_DIRECTORY, "tmp");
        Path tempLink = Path.of(tempDirectory.toString() + "_link");
        Files.createSymbolicLink(tempLink, tempDirectory);

        substCreate(SUBST_DRIVE, tempLink);

        assertEquals(
            Files.readAttributes(SUBST_DRIVE, "*"),
            Files.readAttributes(tempDirectory, "*"));

        assertTrue(Files.isWritable(SUBST_DRIVE));
    }

    /**
     * Run a command and optionally prints stdout contents to
     * `customOutputStream`.
     */
    private void runCmd(ProcessBuilder pb, PrintStream customOutputStream) {
        try {
            PrintStream ps = customOutputStream != null ?
                                    customOutputStream :
                                    System.out;
            OutputAnalyzer outputAnalyzer = ProcessTools.executeCommand(pb)
                                            .outputTo(ps)
                                            .errorTo(System.err);

            int exitCode = outputAnalyzer.getExitValue();
            assertEquals(
                exitCode /* actual value */,
                0        /* expected value */,
                String.format(
                    "Command `%s` failed with exit code %d",
                    pb.command(),
                    exitCode
                )
            );

        } catch (Throwable t) {
            throw new RuntimeException(t);
        }
    }

    /**
     * Helper to map a path to a drive letter using subst.
     * For reference, see:
     * https://docs.microsoft.com/en-us/windows-server/administration/windows-commands/subst
     */
    private void substCreate(Path drive, Path path) {
        runCmd(
            new ProcessBuilder(
                "cmd", "/c", "subst", drive.toString(), path.toString()),
            null /* customOutputStream */);
    }

    /**
     * Delete a drive mapping using subst.
     * For reference, see:
     * https://docs.microsoft.com/en-us/windows-server/administration/windows-commands/subst
     */
    private void substDelete(Path drive) throws IOException {
        runCmd(
            new ProcessBuilder(
                "cmd", "/c", "subst", drive.toString(), "/D"),
            null /* customOutputStream */);
    }

    /**
     * Return a list of strings that represents all the currently mapped drives.
     * For instance, with the following output of subst:
     *      A:\: => path1
     *      B:\: => path2
     *      T:\: => path3
     *      X:\: => path4
     * The function returns: ["A:\", "B:\", "T:\", "X:\"]
     * For reference, see:
     * https://docs.microsoft.com/en-us/windows-server/administration/windows-commands/subst
     */
    private Stream<String> substFindMappedDrives() throws UnsupportedEncodingException {
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        String utf8 = StandardCharsets.UTF_8.name();
        try (PrintStream ps = new PrintStream(baos, true, utf8)) {
            // subst without any arguments returns a list of drives that
            // are being substituted
            runCmd(new ProcessBuilder("cmd", "/c", "subst"), ps);
            String stdout = baos.toString(utf8);
            return stdout
                    // split lines
                    .lines()
                    // only examine lines with "=>"
                    .filter(line -> line.contains("=>"))
                    // split each line into 2 components and take the first one
                    .map(line -> line.split("=>")[0].trim());
        }
    }

    /**
     * subst can fail if the drive to be mapped already exists. The method returns
     * a drive that is available.
     */
    private Optional<Path> findAvailableDrive(Path tempDirectory) {
        for (char letter = 'Z'; letter >= 'A'; letter--) {
            try {
                Path p = Path.of(letter + ":");
                substCreate(p, tempDirectory);
                substDelete(p);
                return Optional.of(p);
            } catch (Throwable t) {
                // fall through
            }
        }
        return Optional.empty();
    }
}