/*_############################################################################
  _##
  _##  SNMP4J-UNIX-TRANSPORT - UnixDomainAddressTest.java
  _##
  _##  Copyright (C) 2021-2026  Frank Fock (SNMP4J.org)
  _##
  _##  Licensed under the Apache License, Version 2.0 (the "License");
  _##  you may not use this file except in compliance with the License.
  _##  You may obtain a copy of the License at
  _##
  _##      http://www.apache.org/licenses/LICENSE-2.0
  _##
  _##  Unless required by applicable law or agreed to in writing, software
  _##  distributed under the License is distributed on an "AS IS" BASIS,
  _##  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  _##  See the License for the specific language governing permissions and
  _##  limitations under the License.
  _##
  _##########################################################################*/
package org.snmp4j.transport.unix;

import org.junit.After;
import org.junit.Assume;
import org.junit.Before;
import org.junit.Test;

import java.io.IOException;
import java.net.StandardProtocolFamily;
import java.net.UnixDomainSocketAddress;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.PosixFilePermissions;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;

public class UnixDomainAddressTest {

    private static final String READ_ONLY_DIRECTORY = "r-xr-xr-x";
    private static final String READ_WRITE_DIRECTORY = "rwxr-xr-x";

    /**
     * The test sockets are created below {@code /tmp} rather than below {@code java.io.tmpdir}, because the
     * latter is a long path on some operating systems (for example below {@code /var/folders} on macOS) which
     * would exceed the Unix domain socket path length limit, see
     * {@link UnixDomainAddress#MAX_SOCKET_PATH_LENGTH}.
     */
    private static final Path TEMP_DIRECTORY = Path.of("/tmp");

    private Path testDirectory;
    private ServerSocketChannel serverSocketChannel;

    @Before
    public void setUp() throws IOException {
        testDirectory = Files.createTempDirectory(TEMP_DIRECTORY, "uds");
    }

    @After
    public void tearDown() throws IOException {
        if (serverSocketChannel != null) {
            serverSocketChannel.close();
        }
        if (testDirectory != null) {
            if (isPosixFileSystem()) {
                // Make the directory writable again, otherwise its content cannot be removed.
                Files.setPosixFilePermissions(testDirectory, PosixFilePermissions.fromString(READ_WRITE_DIRECTORY));
            }
            try (java.util.stream.Stream<Path> content = Files.list(testDirectory)) {
                for (Path path : content.toList()) {
                    Files.deleteIfExists(path);
                }
            }
            Files.deleteIfExists(testDirectory);
        }
    }

    private static boolean isPosixFileSystem() {
        return FileSystems.getDefault().supportedFileAttributeViews().contains("posix");
    }

    /**
     * The permission based checks cannot be tested on a file system without POSIX permissions and they are
     * meaningless for the super user, because the super user may access a file regardless of its mode bits.
     */
    private static void assumePosixPermissionsAreEffective() {
        Assume.assumeTrue("POSIX file permissions are not supported", isPosixFileSystem());
        Assume.assumeFalse("POSIX file permissions are not enforced for the super user",
                "root".equals(System.getProperty("user.name")));
    }

    private ServerSocketChannel bindSocket(Path socketPath) throws IOException {
        serverSocketChannel = ServerSocketChannel.open(StandardProtocolFamily.UNIX);
        serverSocketChannel.bind(UnixDomainSocketAddress.of(socketPath));
        return serverSocketChannel;
    }

    @Test
    public void isValidReturnsFalseWithoutSocketPath() {
        // must not throw a NullPointerException
        assertFalse(new UnixDomainAddress().isValid());
        assertFalse(new UnixDomainAddress().isBindable());
        assertFalse(new UnixDomainAddress().isConnectable());
    }

    @Test
    public void parseAddressAcceptsSocketNameWithoutParentDirectory() {
        UnixDomainAddress address = new UnixDomainAddress();
        // a socket file in the working directory has no parent path element - this must not throw a
        // NullPointerException
        assertTrue(address.parseAddress("snmp4j-agent.sock"));
        assertTrue(address.isValid());
        assertEquals(Path.of(System.getProperty("user.dir")), address.getParentDirectory());
    }

    @Test
    public void parseAddressRejectsUnusableSocketPaths() {
        // an empty path denotes an unnamed, automatically bound socket
        assertFalse(new UnixDomainAddress().parseAddress(""));
        // a path that does not fit into sockaddr_un.sun_path
        assertFalse(new UnixDomainAddress().parseAddress("/" + "x".repeat(UnixDomainAddress.MAX_SOCKET_PATH_LENGTH)));
        // a path that contains a NUL character must be rejected instead of throwing an InvalidPathException
        assertFalse(new UnixDomainAddress().parseAddress("/tmp/snmp4j\0.sock"));
        assertFalse(new UnixDomainAddress().parseAddress(null));
    }

    @Test
    public void parseAddressAcceptsLongestPossibleSocketPath() {
        UnixDomainAddress address = new UnixDomainAddress();
        String socketPath = "/" + "x".repeat(UnixDomainAddress.MAX_SOCKET_PATH_LENGTH - 1);
        assertEquals(UnixDomainAddress.MAX_SOCKET_PATH_LENGTH,
                socketPath.getBytes(StandardCharsets.UTF_8).length);
        assertTrue(address.parseAddress(socketPath));
        assertTrue(address.isValid());
    }

    @Test
    public void isValidDoesNotDependOnFileSystemState() {
        UnixDomainAddress address = new UnixDomainAddress();
        // the address is well formed although its directory does not exist
        assertTrue(address.parseAddress(testDirectory.resolve("missing/agent.sock").toString()));
        assertTrue(address.isValid());
        assertFalse(address.isBindable());
        assertFalse(address.isConnectable());
    }

    @Test
    public void isBindableRequiresWritableDirectory() throws IOException {
        assumePosixPermissionsAreEffective();
        UnixDomainAddress address = new UnixDomainAddress(testDirectory.resolve("agent.sock"));
        assertTrue(address.isBindable());
        // no socket file can be created in a directory that must not be modified
        Files.setPosixFilePermissions(testDirectory, PosixFilePermissions.fromString(READ_ONLY_DIRECTORY));
        assertFalse(address.isBindable());
        // the address itself is still valid
        assertTrue(address.isValid());
    }

    @Test
    public void isConnectableAcceptsSocketInReadOnlyDirectory() throws IOException {
        assumePosixPermissionsAreEffective();
        Path socketPath = testDirectory.resolve("agent.sock");
        bindSocket(socketPath);
        Files.setPosixFilePermissions(testDirectory, PosixFilePermissions.fromString(READ_ONLY_DIRECTORY));
        UnixDomainAddress address = new UnixDomainAddress(socketPath);
        // connecting to an existing socket does not need write permission on its directory
        assertTrue(address.isConnectable());
        // but binding does
        assertFalse(address.isBindable());
        // prove that the socket can indeed be connected
        try (SocketChannel socketChannel = SocketChannel.open(StandardProtocolFamily.UNIX)) {
            assertTrue(socketChannel.connect(UnixDomainSocketAddress.of(socketPath)));
        }
    }

    @Test
    public void isConnectableRequiresWritableSocketFile() throws IOException {
        assumePosixPermissionsAreEffective();
        Path socketPath = testDirectory.resolve("agent.sock");
        bindSocket(socketPath);
        UnixDomainAddress address = new UnixDomainAddress(socketPath);
        assertTrue(address.isConnectable());
        // a socket that must not be written to cannot be connected, although its directory is writable
        Files.setPosixFilePermissions(socketPath, PosixFilePermissions.fromString("r--r--r--"));
        assertFalse(address.isConnectable());
        try (SocketChannel socketChannel = SocketChannel.open(StandardProtocolFamily.UNIX)) {
            socketChannel.connect(UnixDomainSocketAddress.of(socketPath));
            fail("Connecting to a socket without write permission should have failed");
        } catch (IOException expected) {
            // as reported by the operating system, for example "Permission denied"
        }
    }

    @Test
    public void isConnectableRejectsDirectoryAndMissingSocketFile() {
        UnixDomainAddress address = new UnixDomainAddress(testDirectory.resolve("agent.sock"));
        // the socket file does not exist yet
        assertFalse(address.isConnectable());
        assertFalse(new UnixDomainAddress(testDirectory).isConnectable());
    }
}
