/*
 This file is part of Airsonic.

 Airsonic 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.

 Airsonic 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 Airsonic.  If not, see <http://www.gnu.org/licenses/>.

 Copyright 2016 (C) Airsonic Authors
 Based upon Subsonic, Copyright 2009 (C) Sindre Mehus
 */
package org.airsonic.player.controller;

import org.airsonic.player.dao.DaoHelper;
import org.airsonic.player.dao.MediaFileDao;
import org.airsonic.player.dao.MusicFolderDao;
import org.airsonic.player.domain.MediaLibraryStatistics;
import org.airsonic.player.domain.MusicFolder;
import org.airsonic.player.service.SecurityService;
import org.airsonic.player.service.SettingsService;
import org.airsonic.player.service.TranscodingService;
import org.airsonic.player.service.VersionService;
import org.airsonic.player.service.search.AnalyzerFactory;
import org.airsonic.player.service.search.IndexManager;
import org.airsonic.player.service.search.IndexType;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.input.ReversedLinesFileReader;
import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.index.IndexReader;
import org.apache.lucene.search.IndexSearcher;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.ModelAndView;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import java.io.File;
import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.*;

/**
 * Controller for the help page.
 *
 * @author Sindre Mehus
 */
@Controller
@RequestMapping("/internalhelp")
public class InternalHelpController {

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

    private static final int LOG_LINES_TO_SHOW = 50;

    public class IndexStatistics {
        private String name;
        private int count;
        private int deletedCount;

        public String getName() {
            return name;
        }

        public void setName(String name) {
            this.name = name;
        }

        public int getCount() {
            return count;
        }

        public void setCount(int count) {
            this.count = count;
        }

        public int getDeletedCount() {
            return deletedCount;
        }

        public void setDeletedCount(int deletedCount) {
            this.deletedCount = deletedCount;
        }
    }

    public class FileStatistics {
        private String name;
        private String path;
        private String freeFilesystemSizeBytes;
        private String totalFilesystemSizeBytes;
        private boolean readable;
        private boolean writable;
        private boolean executable;

        public FileStatistics() {}

        public FileStatistics(File path) {
        }

        public String getName() {
            return name;
        }

        public String getFreeFilesystemSizeBytes() {
            return freeFilesystemSizeBytes;
        }

        public boolean isReadable() {
            return readable;
        }

        public boolean isWritable() {
            return writable;
        }

        public boolean isExecutable() {
            return executable;
        }

        public String getTotalFilesystemSizeBytes() {
            return totalFilesystemSizeBytes;
        }

        public String getPath() {
            return path;
        }

        public void setName(String name) {
            this.name = name;
        }

        public void setFreeFilesystemSizeBytes(String freeFilesystemSizeBytes) {
            this.freeFilesystemSizeBytes = freeFilesystemSizeBytes;
        }

        public void setReadable(boolean readable) {
            this.readable = readable;
        }

        public void setWritable(boolean writable) {
            this.writable = writable;
        }

        public void setExecutable(boolean executable) {
            this.executable = executable;
        }

        public void setTotalFilesystemSizeBytes(String totalFilesystemSizeBytes) {
            this.totalFilesystemSizeBytes = totalFilesystemSizeBytes;
        }

        public void setPath(String path) {
            this.path = path;
        }

        public void setFromFile(File file) {
            this.setName(file.getName());
            this.setPath(file.getAbsolutePath());
            this.setFreeFilesystemSizeBytes(FileUtils.byteCountToDisplaySize(file.getUsableSpace()));
            this.setTotalFilesystemSizeBytes(FileUtils.byteCountToDisplaySize(file.getTotalSpace()));
            this.setReadable(Files.isReadable(file.toPath()));
            this.setWritable(Files.isWritable(file.toPath()));
            this.setExecutable(Files.isExecutable(file.toPath()));
        }
    }

    @Autowired
    private VersionService versionService;
    @Autowired
    private SettingsService settingsService;
    @Autowired
    private SecurityService securityService;
    @Autowired
    private IndexManager indexManager;
    @Autowired
    private DaoHelper daoHelper;
    @Autowired
    private AnalyzerFactory analyzerFactory;
    @Autowired
    private MusicFolderDao musicFolderDao;
    @Autowired
    private MediaFileDao mediaFileDao;
    @Autowired
    private TranscodingService transcodingService;
    @Autowired
    private Environment environment;

    @GetMapping
    protected ModelAndView handleRequestInternal(HttpServletRequest request, HttpServletResponse response) {
        Map<String, Object> map = new HashMap<>();

        if (versionService.isNewFinalVersionAvailable()) {
            map.put("newVersionAvailable", true);
            map.put("latestVersion", versionService.getLatestFinalVersion());
        } else if (versionService.isNewBetaVersionAvailable()) {
            map.put("newVersionAvailable", true);
            map.put("latestVersion", versionService.getLatestBetaVersion());
        }

        long totalMemory = Runtime.getRuntime().totalMemory();
        long freeMemory = Runtime.getRuntime().freeMemory();

        String serverInfo = request.getSession().getServletContext().getServerInfo() +
                            ", java " + System.getProperty("java.version") +
                            ", " + System.getProperty("os.name");

        map.put("user", securityService.getCurrentUser(request));
        map.put("brand", settingsService.getBrand());
        map.put("localVersion", versionService.getLocalVersion());
        map.put("buildDate", versionService.getLocalBuildDate());
        map.put("buildNumber", versionService.getLocalBuildNumber());
        map.put("serverInfo", serverInfo);
        map.put("usedMemory", totalMemory - freeMemory);
        map.put("totalMemory", totalMemory);
        File logFile = SettingsService.getLogFile();
        List<String> latestLogEntries = getLatestLogEntries(logFile);
        map.put("logEntries", latestLogEntries);
        map.put("logFile", logFile);

        // Gather internal information
        gatherScanInfo(map);
        gatherIndexInfo(map);
        gatherDatabaseInfo(map);
        gatherFilesystemInfo(map);
        gatherTranscodingInfo(map);
        gatherLocaleInfo(map);

        return new ModelAndView("internalhelp","model",map);
    }

    private void gatherScanInfo(Map<String, Object> map) {
        // Airsonic scan statistics
        MediaLibraryStatistics stats = indexManager.getStatistics();
        if (stats != null) {
            map.put("statAlbumCount", stats.getAlbumCount());
            map.put("statArtistCount", stats.getArtistCount());
            map.put("statSongCount", stats.getSongCount());
            map.put("statLastScanDate", stats.getScanDate());
            map.put("statTotalDurationSeconds", stats.getTotalDurationInSeconds());
            map.put("statTotalLengthBytes", FileUtils.byteCountToDisplaySize(stats.getTotalLengthInBytes()));
        }
    }

    private void gatherIndexInfo(Map<String, Object> map) {
        SortedMap<String, IndexStatistics> indexStats = new TreeMap<>();
        for (IndexType indexType : IndexType.values()) {
            IndexStatistics stat = new IndexStatistics();
            IndexSearcher searcher = indexManager.getSearcher(indexType);
            stat.setName(indexType.name());
            indexStats.put(indexType.name(), stat);
            if (searcher != null) {
                IndexReader reader = searcher.getIndexReader();
                stat.setCount(reader.numDocs());
                stat.setDeletedCount(reader.numDeletedDocs());
                indexManager.release(indexType, searcher);
            } else {
                stat.setCount(0);
                stat.setDeletedCount(0);
            }
        }
        map.put("indexStatistics", indexStats);

        try (Analyzer analyzer = analyzerFactory.getAnalyzer()) {
            map.put("indexLuceneVersion", analyzer.getVersion().toString());
        } catch (IOException e) {
            LOG.debug("Unable to gather information", e);
        }
    }

    /**
     * Returns true if a locale string (e.g. en_US.UTF-8) appears to support UTF-8 correctly.
     *
     * Some systems use non-standard locales (e.g. en_US.utf8 instead of en_US.UTF-8)
     * to specify Unicode support, which are usually supported by the Glibc.
     *
     * See: https://superuser.com/questions/999133/differences-between-en-us-utf8-and-en-us-utf-8
     */
    private boolean doesLocaleSupportUtf8(String locale) {
        if (locale == null) {
            return false;
        } else {
            return locale.toLowerCase().replaceAll("\\W", "").contains("utf8");
        }
    }

    private void gatherLocaleInfo(Map<String, Object> map) {
        map.put("localeDefault", Locale.getDefault());
        map.put("localeUserLanguage", System.getProperty("user.language"));
        map.put("localeUserCountry", System.getProperty("user.country"));
        map.put("localeFileEncoding", System.getProperty("file.encoding"));
        map.put("localeSunJnuEncoding", System.getProperty("sun.jnu.encoding"));
        map.put("localeSunIoUnicodeEncoding", System.getProperty("sun.io.unicode.encoding"));
        map.put("localeLang", System.getenv("LANG"));
        map.put("localeLcAll", System.getenv("LC_ALL"));
        map.put("localeDefaultCharset", Charset.defaultCharset().toString());

        map.put("localeFileEncodingSupportsUtf8", doesLocaleSupportUtf8(System.getProperty("file.encoding")));
        map.put("localeLangSupportsUtf8", doesLocaleSupportUtf8(System.getenv("LANG")));
        map.put("localeLcAllSupportsUtf8", doesLocaleSupportUtf8(System.getenv("LC_ALL")));
        map.put("localeDefaultCharsetSupportsUtf8", doesLocaleSupportUtf8(Charset.defaultCharset().toString()));
    }

    private void gatherDatabaseInfo(Map<String, Object> map) {

        try (Connection conn = daoHelper.getDataSource().getConnection()) {

            // Driver name/version
            map.put("dbDriverName", conn.getMetaData().getDriverName());
            map.put("dbDriverVersion", conn.getMetaData().getDriverVersion());
            map.put("dbServerVersion", conn.getMetaData().getDatabaseProductVersion());

            // Gather information for existing database tables
            ResultSet resultSet = conn.getMetaData().getTables(null, null, "%", null);
            SortedMap<String, Long> dbTableCount = new TreeMap<>();
            while (resultSet.next()) {
                String tableSchema = resultSet.getString("TABLE_SCHEM");
                String tableName = resultSet.getString("TABLE_NAME");
                String tableType = resultSet.getString("TABLE_TYPE");
                LOG.debug("Got database table {}, schema {}, type {}", tableName, tableSchema, tableType);
                if (!"table".equalsIgnoreCase(tableType)) continue;   // Table type
                // MariaDB has "null" schemas, while other databases use "public".
                if (tableSchema != null && !"public".equalsIgnoreCase(tableSchema)) continue;  // Table schema
                try {
                    Long tableCount = daoHelper.getJdbcTemplate().queryForObject(String.format("SELECT count(*) FROM %s", tableName), Long.class);
                    dbTableCount.put(tableName, tableCount);
                } catch (Exception e) {
                    LOG.debug("Unable to gather information", e);
                }
            }
            map.put("dbTableCount", dbTableCount);

        } catch (SQLException e) {
            LOG.debug("Unable to gather information", e);
        }

        if (environment.acceptsProfiles("legacy")) {
            map.put("dbIsLegacy", true);
            File dbDirectory = new File(SettingsService.getAirsonicHome(), "db");
            map.put("dbDirectorySizeBytes", dbDirectory.exists() ? FileUtils.sizeOfDirectory(dbDirectory) : 0);
            map.put("dbDirectorySize", FileUtils.byteCountToDisplaySize((long) map.get("dbDirectorySizeBytes")));
            File dbLogFile = new File(dbDirectory, "airsonic.log");
            map.put("dbLogSizeBytes", dbLogFile.exists() ? dbLogFile.length() : 0);
            map.put("dbLogSize", FileUtils.byteCountToDisplaySize((long) map.get("dbLogSizeBytes")));
        } else {
            map.put("dbIsLegacy", false);
        }

        map.put("dbMediaFileMusicNonPresentCount", daoHelper.getJdbcTemplate().queryForObject("SELECT count(*) FROM media_file WHERE NOT present AND type = 'MUSIC'", Long.class));
        map.put("dbMediaFilePodcastNonPresentCount", daoHelper.getJdbcTemplate().queryForObject("SELECT count(*) FROM media_file WHERE NOT present AND type = 'PODCAST'", Long.class));
        map.put("dbMediaFileDirectoryNonPresentCount", daoHelper.getJdbcTemplate().queryForObject("SELECT count(*) FROM media_file WHERE NOT present AND type = 'DIRECTORY'", Long.class));
        map.put("dbMediaFileAlbumNonPresentCount", daoHelper.getJdbcTemplate().queryForObject("SELECT count(*) FROM media_file wheRE NOT present AND type = 'ALBUM'", Long.class));

        map.put("dbMediaFileMusicPresentCount", daoHelper.getJdbcTemplate().queryForObject("SELECT count(*) FROM media_file WHERE present AND type = 'MUSIC'", Long.class));
        map.put("dbMediaFilePodcastPresentCount", daoHelper.getJdbcTemplate().queryForObject("SELECT count(*) FROM media_file WHERE present AND type = 'PODCAST'", Long.class));
        map.put("dbMediaFileDirectoryPresentCount", daoHelper.getJdbcTemplate().queryForObject("SELECT count(*) FROM media_file WHERE present AND type = 'DIRECTORY'", Long.class));
        map.put("dbMediaFileAlbumPresentCount", daoHelper.getJdbcTemplate().queryForObject("SELECT count(*) FROM media_file WHERE present AND type = 'ALBUM'", Long.class));

        map.put("dbMediaFileDistinctAlbumCount", daoHelper.getJdbcTemplate().queryForObject("SELECT count(DISTINCT album) FROM media_file WHERE present", Long.class));
        map.put("dbMediaFileDistinctArtistCount", daoHelper.getJdbcTemplate().queryForObject("SELECT count(DISTINCT artist) FROM media_file WHERE present", Long.class));
        map.put("dbMediaFileDistinctAlbumArtistCount", daoHelper.getJdbcTemplate().queryForObject("SELECT count(DISTINCT album_artist) FROM media_file WHERE present", Long.class));

        map.put("dbMediaFilesInNonPresentMusicFoldersCount", mediaFileDao.getFilesInNonPresentMusicFoldersCount(Arrays.asList(settingsService.getPodcastFolder())));
        map.put("dbMediaFilesInNonPresentMusicFoldersSample", mediaFileDao.getFilesInNonPresentMusicFolders(10, Arrays.asList(settingsService.getPodcastFolder())));

        map.put("dbMediaFilesWithMusicFolderMismatchCount", mediaFileDao.getFilesWithMusicFolderMismatchCount());
        map.put("dbMediaFilesWithMusicFolderMismatchSample", mediaFileDao.getFilesWithMusicFolderMismatch(10));
    }

    private void gatherFilesystemInfo(Map<String, Object> map) {
        map.put("fsHomeDirectorySizeBytes", FileUtils.sizeOfDirectory(SettingsService.getAirsonicHome()));
        map.put("fsHomeDirectorySize", FileUtils.byteCountToDisplaySize((long)map.get("fsHomeDirectorySizeBytes")));
        map.put("fsHomeTotalSpaceBytes", SettingsService.getAirsonicHome().getTotalSpace());
        map.put("fsHomeTotalSpace", FileUtils.byteCountToDisplaySize((long)map.get("fsHomeTotalSpaceBytes")));
        map.put("fsHomeUsableSpaceBytes", SettingsService.getAirsonicHome().getUsableSpace());
        map.put("fsHomeUsableSpace", FileUtils.byteCountToDisplaySize((long)map.get("fsHomeUsableSpaceBytes")));
        SortedMap<String, FileStatistics> fsMusicFolderStatistics = new TreeMap<>();
        for (MusicFolder folder: musicFolderDao.getAllMusicFolders()) {
            FileStatistics stat = new FileStatistics();
            stat.setFromFile(folder.getPath());
            stat.setName(folder.getName());
            fsMusicFolderStatistics.put(folder.getName(), stat);
        }
        map.put("fsMusicFolderStatistics", fsMusicFolderStatistics);
    }

    private void gatherTranscodingInfo(Map<String, Object> map) {
        map.put("fsFfprobeInfo", gatherStatisticsForTranscodingExecutable("ffprobe"));
        map.put("fsFfmpegInfo", gatherStatisticsForTranscodingExecutable("ffmpeg"));
    }

    private static List<String> getLatestLogEntries(File logFile) {
        List<String> lines = new ArrayList<>(LOG_LINES_TO_SHOW);
        try (ReversedLinesFileReader reader = new ReversedLinesFileReader(logFile, Charset.defaultCharset())) {
            String current;
            while ((current = reader.readLine()) != null) {
                if (lines.size() >= LOG_LINES_TO_SHOW) {
                    break;
                }
                lines.add(0, current);
            }
            return lines;
        } catch (IOException e) {
            LOG.warn("Could not open log file " + logFile, e);
            return null;
        }
    }

    private File lookForExecutable(String executableName) {
        for (String path : System.getenv("PATH").split(File.pathSeparator)) {
            File file = new File(path, executableName);
            if (file.exists()) {
                LOG.debug("Found {} in {}", executableName, path);
                return file;
            } else {
                LOG.debug("Looking for {} in {} (not found)", executableName, path);
            }
        }
        return null;
    }

    private File lookForTranscodingExecutable(String executableName) {
        File executableLocation = null;
        for (String name: Arrays.asList(executableName, String.format("%s.exe", executableName))) {
            executableLocation = new File(transcodingService.getTranscodeDirectory(), name);
            if (executableLocation != null && executableLocation.exists()) return executableLocation;
            executableLocation = lookForExecutable(executableName);
            if (executableLocation != null && executableLocation.exists()) return executableLocation;
        }
        return null;
    }

    private FileStatistics gatherStatisticsForTranscodingExecutable(String executableName) {
        FileStatistics executableStatistics = null;
        File executableLocation = lookForTranscodingExecutable(executableName);
        if (executableLocation != null) {
            executableStatistics = new FileStatistics();
            executableStatistics.setFromFile(executableLocation);
        }
        return executableStatistics;
    }

}