/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you 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.apache.cassandra.service.snapshot;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Stream;

import javax.annotation.Nullable;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import org.apache.cassandra.config.CassandraRelevantProperties;
import org.apache.cassandra.db.ColumnFamilyStore;
import org.apache.cassandra.db.Directories;
import org.apache.cassandra.db.Keyspace;
import org.apache.cassandra.db.lifecycle.SSTableSet;
import org.apache.cassandra.io.FSReadError;
import org.apache.cassandra.io.sstable.Component;
import org.apache.cassandra.io.sstable.Descriptor;
import org.apache.cassandra.io.sstable.SSTable;
import org.apache.cassandra.io.sstable.SSTableId;
import org.apache.cassandra.io.sstable.format.SSTableFormat;
import org.apache.cassandra.io.sstable.format.SSTableReader;
import org.apache.cassandra.io.util.File;
import org.apache.cassandra.io.util.FileUtils;
import org.apache.cassandra.utils.Clock;
import org.apache.cassandra.utils.FBUtilities;
import org.apache.cassandra.utils.LocalizeString;
import org.apache.cassandra.utils.Throwables;
import org.apache.cassandra.utils.concurrent.Refs;

import oshi.PlatformEnum;

public class TableSnapshot
{
    private static final Logger logger = LoggerFactory.getLogger(TableSnapshot.class);
    private static final PlatformEnum PLATFORM = FBUtilities.getSystemInfo().platform();

    private final String keyspaceName;
    private final String tableName;
    private final String keyspaceTable;
    // tableId may be null under some rare circumstance namely pre-2.1 table
    // whose snapshot is loaded upon startup rather than created while the jvm is running
    @Nullable
    private final UUID tableId;
    private final String tag;
    private final boolean ephemeral;

    private final Instant createdAt;
    private final Instant expiresAt;

    private final Set<File> snapshotDirs;

    private volatile long sizeOnDisk = 0;

    private volatile long manifestsSize;
    private volatile long schemasSize;

    private volatile boolean inProgress = false;

    public TableSnapshot(String keyspaceName, String tableName, UUID tableId,
                         String tag, Instant createdAt, Instant expiresAt,
                         Set<File> snapshotDirs, boolean ephemeral)
    {
        this.keyspaceName = keyspaceName;
        this.tableName = tableName;
        this.keyspaceTable = keyspaceName + '.' + tableName;
        this.tableId = tableId;
        this.tag = tag;
        this.createdAt = createdAt;
        this.expiresAt = expiresAt;
        this.snapshotDirs = snapshotDirs;
        this.ephemeral = ephemeral;

        manifestsSize = getManifestsSize();
        schemasSize = getSchemasSize();
    }

    public boolean isCompleted()
    {
        return !inProgress;
    }

    public void incomplete()
    {
        inProgress = true;
    }

    public void complete()
    {
        inProgress = false;
    }

    /**
     * Unique identifier of a snapshot. Used
     * only to deduplicate snapshots internally,
     * not exposed externally.
     * table_id may be empty for tables created prior to 2.1
     * <p>
     * Format: "$ks:$table_name:$table_id:$tag"
     */
    public String getId()
    {
        return buildSnapshotId(keyspaceName, tableName, tableId, tag);
    }

    public String getKeyspaceName()
    {
        return keyspaceName;
    }

    public String getTableName()
    {
        return tableName;
    }

    public String getKeyspaceTable()
    {
        return keyspaceTable;
    }

    public String getTag()
    {
        return tag;
    }

    public Instant getCreatedAt()
    {
        if (createdAt == null)
        {
            long minCreation = 0;
            for (File snapshotDir : snapshotDirs)
            {
                if (!snapshotDir.exists())
                    continue;

                long lastModified = snapshotDir.lastModified();
                if (lastModified == 0)
                    continue;

                if (minCreation == 0 || minCreation > lastModified)
                    minCreation = lastModified;
            }

            if (minCreation != 0)
                return Instant.ofEpochMilli(minCreation);
        }
        return createdAt;
    }

    public Instant getExpiresAt()
    {
        return expiresAt;
    }

    public boolean isExpired(Instant now)
    {
        if (createdAt == null || expiresAt == null)
        {
            return false;
        }

        return expiresAt.compareTo(now) < 0;
    }

    public boolean exists()
    {
        for (File snapshotDir : snapshotDirs)
            if (snapshotDir.exists())
                return true;

        return false;
    }

    public boolean isEphemeral()
    {
        return ephemeral;
    }

    public boolean isExpiring()
    {
        return expiresAt != null;
    }

    public long computeSizeOnDiskBytes()
    {
        return computeSizeOnDiskBytes(false);
    }

    /**
     *
     * @param refresh true if a caller wants to recompute otherwise cached size
     * @return on disk bytes
     */
    public long computeSizeOnDiskBytes(boolean refresh)
    {
        long sum = sizeOnDisk;
        if (sum == 0 || refresh)
        {
            sum = 0;
            for (File snapshotDir : snapshotDirs)
                sum += FileUtils.folderSize(snapshotDir);

            sizeOnDisk = sum;
        }

        return sum;
    }

    public long computeTrueSizeBytes()
    {
        ColumnFamilyStore cfs = ColumnFamilyStore.getIfExists(keyspaceName, tableName);
        if (cfs == null)
            return 0;

        return computeTrueSizeBytes(cfs.getFilesOfCfs());
    }

    public long computeTrueSizeBytes(Set<String> files)
    {
        long size = manifestsSize + schemasSize;

        for (File dataPath : getDirectories())
        {
            List<Path> snapshotFiles = listDir(dataPath.toPath());
            for (Path snapshotFile : snapshotFiles)
            {
                if (!snapshotFile.endsWith("manifest.json") && !snapshotFile.endsWith("schema.cql"))
                {
                    // files == null means that the underlying table was most probably dropped
                    // so in that case we indeed go to count snapshots file in for true size
                    if (files == null || (!files.contains(getLiveFileFromSnapshotFile(snapshotFile))))
                        size += getFileSize(snapshotFile);
                }
            }
        }

        return size;
    }

    private List<Path> listDir(Path dir)
    {
        List<Path> paths = new ArrayList<>();
        try (Stream<Path> stream = Files.list(dir))
        {
            stream.forEach(p -> {
                if (p.getFileName().toString().startsWith("."))
                {
                    paths.addAll(listDir(p));
                }
                else
                {
                    paths.add(p);
                }
            });
        }
        catch (Throwable t)
        {
            if (!(t instanceof NoSuchFileException))
                logger.error("Could not list directory content {}", dir, t);
        }

        return paths;
    }

    private long getFileSize(Path file)
    {
        try
        {
            return Files.size(file);
        }
        catch (Throwable t)
        {
            return 0;
        }
    }

    /**
     * Returns the corresponding live file for a given snapshot file.
     * <p>
     * Example:
     * - Base table:
     * - Snapshot file: ~/.ccm/test/node1/data0/test_ks/tbl-e03faca0813211eca100c705ea09b5ef/snapshots/1643481737850/me-1-big-Data.db
     * - Live file: ~/.ccm/test/node1/data0/test_ks/tbl-e03faca0813211eca100c705ea09b5ef/me-1-big-Data.db
     * - Secondary index:
     * - Snapshot file: ~/.ccm/test/node1/data0/test_ks/tbl-e03faca0813211eca100c705ea09b5ef/snapshots/1643481737850/.tbl_val_idx/me-1-big-Summary.db
     * - Live file: ~/.ccm/test/node1/data0/test_ks/tbl-e03faca0813211eca100c705ea09b5ef/.tbl_val_idx/me-1-big-Summary.db
     */
    static String getLiveFileFromSnapshotFile(Path snapshotFilePath)
    {
        // Snapshot directory structure format is {data_dir}/snapshots/{snapshot_name}/{snapshot_file}
        Path liveDir = snapshotFilePath.getParent().getParent().getParent();
        if (Directories.isSecondaryIndexFolder(snapshotFilePath.getParent()))
        {
            // Snapshot file structure format is {data_dir}/snapshots/{snapshot_name}/.{index}/{sstable-component}.db
            liveDir = File.getPath(liveDir.getParent().toString(), snapshotFilePath.getParent().getFileName().toString());
        }
        return liveDir.resolve(snapshotFilePath.getFileName().toString()).toAbsolutePath().toString();
    }

    public Collection<File> getDirectories()
    {
        return snapshotDirs;
    }

    /**
     * Returns all manifest files of a snapshot.
     * <p>
     * In practice, there might be multiple manifest files, as many as we have snapshot dirs.
     * Each snapshot dir will hold its view of a snapshot, containing only sstables located in such snapshot dir.
     *
     * @return all manifest files
     */
    public Set<File> getManifestFiles()
    {
        Set<File> manifestFiles = new HashSet<>();
        for (File snapshotDir : snapshotDirs)
        {
            File manifestFile = Directories.getSnapshotManifestFile(snapshotDir);
            if (manifestFile.exists())
                manifestFiles.add(manifestFile);
        }
        return manifestFiles;
    }

    public boolean hasManifest()
    {
        for (File snapshotDir : snapshotDirs)
        {
            if (Directories.getSnapshotManifestFile(snapshotDir).exists())
                return true;
        }
        return false;
    }

    /**
     * Returns all schemas files of a snapshot.
     *
     * @return all schema files
     */
    public Set<File> getSchemaFiles()
    {
        Set<File> schemaFiles = new HashSet<>();
        for (File snapshotDir : snapshotDirs)
        {
            File schemaFile = Directories.getSnapshotSchemaFile(snapshotDir);
            if (schemaFile.exists())
                schemaFiles.add(schemaFile);
        }
        return schemaFiles;
    }

    public long getManifestsSize()
    {
        long size = 0;
        for (File manifestFile : getManifestFiles())
            size += manifestFile.length();

        return size;
    }

    public long getSchemasSize()
    {
        long size = 0;
        for (File schemaFile : getSchemaFiles())
            size += schemaFile.length();

        return size;
    }


    @Override
    public boolean equals(Object o)
    {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        TableSnapshot snapshot = (TableSnapshot) o;
        return Objects.equals(keyspaceName, snapshot.keyspaceName) &&
               Objects.equals(tableName, snapshot.tableName) &&
               Objects.equals(tableId, snapshot.tableId) &&
               tagsEqual(tag, snapshot.tag);
    }

    private boolean tagsEqual(String tag1, String tag2)
    {
        if (tag1 == null && tag2 == null)
            return true;

        if (tag1 == null || tag2 == null)
            return false;

        // When hardlinks are created for a snapshot with the name "snapshot"
        // and then we take a snapshot with the name "Snapshot", macOS platform thinks
        // that this was already hardlinked because its hardlinking implementation
        // does not seem to be case-sensitive. The fix consists of checking,
        // in a case-insensitive manner, if there is already such snapshot,
        // but only on macOS platform.
        if (PLATFORM == PlatformEnum.MACOS)
            return LocalizeString.toLowerCaseLocalized(tag1).equals(LocalizeString.toLowerCaseLocalized(tag2));

        return Objects.equals(tag1, tag2);
    }

    @Override
    public int hashCode()
    {
        return Objects.hash(keyspaceName, tableName, tableId, tag);
    }

    @Override
    public String toString()
    {
        return "TableSnapshot{" +
               "keyspaceName='" + keyspaceName + '\'' +
               ", tableName='" + tableName + '\'' +
               ", tableId=" + tableId +
               ", tag='" + tag + '\'' +
               ", createdAt=" + createdAt +
               ", expiresAt=" + expiresAt +
               ", snapshotDirs=" + snapshotDirs +
               ", ephemeral=" + ephemeral +
               '}';
    }

    public void updateMetadataSize()
    {
        if (manifestsSize == 0)
            manifestsSize = getManifestsSize();
        if (schemasSize == 0)
            schemasSize = getSchemasSize();
    }

    static class Builder
    {
        private final String keyspaceName;
        private final String tableName;
        private final UUID tableId;
        private final String tag;

        private Instant createdAt = null;
        private Instant expiresAt = null;
        private boolean ephemeral;

        private final Set<File> snapshotDirs = new HashSet<>();

        Builder(String keyspaceName, String tableName, UUID tableId, String tag)
        {
            this.keyspaceName = keyspaceName;
            this.tableName = tableName;
            this.tag = tag;
            this.tableId = tableId;
        }

        void addSnapshotDir(File snapshotDir)
        {
            snapshotDirs.add(snapshotDir);
            File manifestFile = new File(snapshotDir, "manifest.json");
            if (manifestFile.exists() && createdAt == null && expiresAt == null)
                loadMetadataFromManifest(manifestFile);

            // check if an ephemeral marker file exists only in case it is not already ephemeral
            // by reading it from manifest
            // TODO remove this on Cassandra 4.3 release, see CASSANDRA-16911
            if (!ephemeral && new File(snapshotDir, "ephemeral.snapshot").exists())
                ephemeral = true;
        }

        private void loadMetadataFromManifest(File manifestFile)
        {
            try
            {
                logger.trace("Loading snapshot manifest from {}", manifestFile);
                SnapshotManifest manifest = SnapshotManifest.deserializeFromJsonFile(manifestFile);
                createdAt = manifest.createdAt;
                expiresAt = manifest.expiresAt;
                // a snapshot may be ephemeral when it has a marker file (old way) or flag in manifest (new way)
                if (!ephemeral)
                    ephemeral = manifest.ephemeral;
            }
            catch (IOException e)
            {
                logger.warn("Cannot read manifest file {} of snapshot {}.", manifestFile, tag, e);
            }
        }

        TableSnapshot build()
        {
            maybeCreateOrEnrichManifest();
            return new TableSnapshot(keyspaceName, tableName, tableId, tag, createdAt, expiresAt, snapshotDirs, ephemeral);
        }

        private List<File> getSnapshotDirsWithoutManifest()
        {
            List<File> snapshotDirNotHavingManifests = new ArrayList<>();
            for (File snapshotDir : snapshotDirs)
            {
                if (!new File(snapshotDir.toPath().resolve("manifest.json")).exists())
                    snapshotDirNotHavingManifests.add(snapshotDir);
            }

            return snapshotDirNotHavingManifests;
        }

        private void maybeCreateOrEnrichManifest()
        {
            if (!CassandraRelevantProperties.SNAPSHOT_MANIFEST_ENRICH_OR_CREATE_ENABLED.getBoolean())
                return;

            // this is caused by not reading any manifest or that snapshot had a basic manifest just with sstables
            // enumerated (pre CASSANDRA-16789), so we just go ahead and enrich it in each snapshot dir

            List<File> snapshotDirsWithoutManifests = getSnapshotDirsWithoutManifest();
            if (createdAt != null && snapshotDirsWithoutManifests.isEmpty())
                return;

            if (createdAt == null && snapshotDirsWithoutManifests.isEmpty())
                logger.info("Manifest in the old format for snapshot {} for {}.{} was detected, going to enrich it.",
                            tag, keyspaceName, tableName);

            if (!snapshotDirsWithoutManifests.isEmpty())
                logger.info("There is no manifest for snapshot {} for {}.{} at paths {}, going to create it.",
                            tag, keyspaceName, tableName, snapshotDirsWithoutManifests);

            long lastModified = createdAt == null ? -1 : createdAt.toEpochMilli();

            if (lastModified == -1)
            {
                for (File snapshotDir : snapshotDirs)
                {
                    // we will consider time of the creation the oldest last modified
                    // timestamp on any snapshot directory
                    long currentLastModified = snapshotDir.lastModified();
                    if ((currentLastModified < lastModified || lastModified == -1) && currentLastModified > 0)
                        lastModified = currentLastModified;
                }
            }

            List<String> allDataFiles = new ArrayList<>();
            for (File snapshotDir : snapshotDirs)
            {
                List<File> dataFiles = new ArrayList<>();
                try
                {
                    List<File> indicesDirs = new ArrayList<>();
                    File[] snapshotFiles = snapshotDir.list(file -> {
                        if (file.isDirectory() && file.name().startsWith("."))
                        {
                            indicesDirs.add(file);
                            return false;
                        }
                        else
                        {
                            return file.name().endsWith('-' + SSTableFormat.Components.DATA.type.repr);
                        }
                    });

                    Collections.addAll(dataFiles, snapshotFiles);

                    for (File indexDir : indicesDirs)
                        dataFiles.addAll(Arrays.asList(indexDir.list(file -> file.name().endsWith('-' + SSTableFormat.Components.DATA.type.repr))));
                }
                catch (IOException ex)
                {
                    logger.error("Unable to list a directory for data components: {}", snapshotDir);
                }

                for (File dataFile : dataFiles)
                {
                    Descriptor descriptor = SSTable.tryDescriptorFromFile(dataFile);
                    if (descriptor != null)
                    {
                        String relativeDataFileName = descriptor.relativeFilenameFor(SSTableFormat.Components.DATA);
                        allDataFiles.add(relativeDataFileName);
                    }
                }
            }

            // in any case of not being able to resolve it, set it to current time
            if (lastModified < 0)
                createdAt = Instant.ofEpochMilli(Clock.Global.currentTimeMillis());
            else
                createdAt = Instant.ofEpochMilli(lastModified);

            for (File snapshotDir : snapshotDirs)
            {
                SnapshotManifest snapshotManifest = new SnapshotManifest(allDataFiles, null, createdAt, ephemeral);
                File manifestFile = new File(snapshotDir, "manifest.json");
                if (snapshotDirsWithoutManifests.contains(snapshotDir))
                {
                    writeManifest(snapshotManifest, manifestFile);
                }
                else
                {
                    SnapshotManifest existingManifest = readManifest(manifestFile);
                    if (existingManifest != null && existingManifest.createdAt == null)
                        writeManifest(snapshotManifest, manifestFile);
                }
            }
        }

        private SnapshotManifest readManifest(File manifestFile)
        {
            try
            {
                return SnapshotManifest.deserializeFromJsonFile(manifestFile);
            }
            catch (IOException ex)
            {
                logger.error("Unable to read a manifest.json file in {}", manifestFile.absolutePath());
            }

            return null;
        }

        private void writeManifest(SnapshotManifest snapshotManifest, File manifestFile)
        {
            try
            {
                snapshotManifest.serializeToJsonFile(manifestFile);
            }
            catch (IOException ex)
            {
                logger.error("Unable to create a manifest.json file in {}", manifestFile.absolutePath());
            }
        }
    }

    protected static String buildSnapshotId(String keyspaceName, String tableName, @Nullable UUID tableId, String tag)
    {
        return String.format("%s:%s:%s:%s", keyspaceName, tableName, tableId == null ? "" : tableId, tag);
    }

    public static Set<Descriptor> getSnapshotDescriptors(String keyspace, String table, String tag)
    {
        try
        {
            Refs<SSTableReader> snapshotSSTableReaders = getSnapshotSSTableReaders(keyspace, table, tag);

            Set<Descriptor> descriptors = new HashSet<>();
            for (SSTableReader ssTableReader : snapshotSSTableReaders)
            {
                descriptors.add(ssTableReader.descriptor);
            }

            return descriptors;
        }
        catch (IOException e)
        {
            throw Throwables.unchecked(e);
        }
    }

    public static Refs<SSTableReader> getSnapshotSSTableReaders(String keyspace, String table, String tag) throws IOException
    {
        return getSnapshotSSTableReaders(Keyspace.open(keyspace).getColumnFamilyStore(table), tag);
    }

    public static Refs<SSTableReader> getSnapshotSSTableReaders(ColumnFamilyStore cfs, String tag)
    {
        Map<SSTableId, SSTableReader> active = new HashMap<>();
        for (SSTableReader sstable : cfs.getSSTables(SSTableSet.CANONICAL))
            active.put(sstable.descriptor.id, sstable);
        Map<Descriptor, Set<Component>> snapshots = cfs.getDirectories().sstableLister(Directories.OnTxnErr.IGNORE).snapshots(tag).list();
        Refs<SSTableReader> refs = new Refs<>();
        try
        {
            for (Map.Entry<Descriptor, Set<Component>> entries : snapshots.entrySet())
            {
                // Try to acquire reference to an active sstable instead of snapshot if it exists,
                // to avoid opening new sstables. If it fails, use the snapshot reference instead.
                SSTableReader sstable = active.get(entries.getKey().id);
                if (sstable == null || !refs.tryRef(sstable))
                {
                    if (logger.isTraceEnabled())
                        logger.trace("using snapshot sstable {}", entries.getKey());
                    // open offline so we don't modify components or track hotness.
                    sstable = SSTableReader.open(cfs, entries.getKey(), entries.getValue(), cfs.metadata, true, true);
                    refs.tryRef(sstable);
                    // release the self ref as we never add the snapshot sstable to DataTracker where it is otherwise released
                    sstable.selfRef().release();
                }
                else if (logger.isTraceEnabled())
                {
                    logger.trace("using active sstable {}", entries.getKey());
                }
            }
        }
        catch (FSReadError | RuntimeException e)
        {
            // In case one of the snapshot sstables fails to open,
            // we must release the references to the ones we opened so far
            refs.release();
            throw e;
        }
        return refs;
    }

    public static String getTimestampedSnapshotName(String clientSuppliedName, long timestamp)
    {
        String snapshotName = Long.toString(timestamp);
        if (clientSuppliedName != null && !clientSuppliedName.isEmpty())
            snapshotName = snapshotName + '-' + clientSuppliedName;

        return snapshotName;
    }

    public static String getTimestampedSnapshotNameWithPrefix(String clientSuppliedName, long timestamp, String prefix)
    {
        return prefix + '-' + getTimestampedSnapshotName(clientSuppliedName, timestamp);
    }
}
