/*
* ALR - Any Linux Repository
* Copyright (C) 2024 Евгений Храмов
*
* 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/>.
*/

// Пакет dl содержит абстракции для загрузки файлов и каталогов
// из различных источников.
package dl

import (
	"context"
	"crypto/md5"
	"crypto/sha1"
	"crypto/sha256"
	"crypto/sha512"
	"errors"
	"fmt"
	"hash"
	"io"
	"os"
	"path/filepath"
	"strings"

	"github.com/PuerkitoBio/purell"
	"github.com/vmihailenco/msgpack/v5"
	"golang.org/x/crypto/blake2b"
	"golang.org/x/crypto/blake2s"
	"golang.org/x/exp/slices"
	"plemya-x.ru/alr/internal/dlcache"
	"plemya-x.ru/alr/pkg/loggerctx"
)

// Константа для имени файла манифеста кэша
const manifestFileName = ".alr_cache_manifest"

// Объявление ошибок для несоответствия контрольной суммы и отсутствия алгоритма хеширования
var (
	ErrChecksumMismatch = errors.New("dl: checksums did not match")
	ErrNoSuchHashAlgo   = errors.New("dl: invalid hashing algorithm")
)

// Массив доступных загрузчиков в порядке их проверки
var Downloaders = []Downloader{
	GitDownloader{},
	TorrentDownloader{},
	FileDownloader{},
}

// Тип данных, представляющий тип загрузки (файл или каталог)
type Type uint8

// Объявление констант для типов загрузки
const (
	TypeFile Type = iota
	TypeDir
)

// Метод для получения строки, представляющей тип загрузки
func (t Type) String() string {
	switch t {
	case TypeFile:
		return "file"
	case TypeDir:
		return "dir"
	}
	return "<unknown>"
}

// Структура Options содержит параметры для загрузки файлов и каталогов
type Options struct {
	Hash             []byte
	HashAlgorithm    string
	Name             string
	URL              string
	Destination      string
	CacheDisabled    bool
	PostprocDisabled bool
	Progress         io.Writer
	LocalDir         string
}

// Метод для создания нового хеша на основе указанного алгоритма хеширования
func (opts Options) NewHash() (hash.Hash, error) {
	switch opts.HashAlgorithm {
	case "", "sha256":
		return sha256.New(), nil
	case "sha224":
		return sha256.New224(), nil
	case "sha512":
		return sha512.New(), nil
	case "sha384":
		return sha512.New384(), nil
	case "sha1":
		return sha1.New(), nil
	case "md5":
		return md5.New(), nil
	case "blake2s-128":
		return blake2s.New256(nil)
	case "blake2s-256":
		return blake2s.New256(nil)
	case "blake2b-256":
		return blake2b.New(32, nil)
	case "blake2b-512":
		return blake2b.New(64, nil)
	default:
		return nil, fmt.Errorf("%w: %s", ErrNoSuchHashAlgo, opts.HashAlgorithm)
	}
}

// Структура Manifest хранит информацию о типе и имени загруженного файла или каталога
type Manifest struct {
	Type Type
	Name string
}

// Интерфейс Downloader для реализации различных загрузчиков
type Downloader interface {
	Name() string
	MatchURL(string) bool
	Download(Options) (Type, string, error)
}

// Интерфейс UpdatingDownloader расширяет Downloader методом Update
type UpdatingDownloader interface {
	Downloader
	Update(Options) (bool, error)
}

// Функция Download загружает файл или каталог с использованием указанных параметров
func Download(ctx context.Context, opts Options) (err error) {
	log := loggerctx.From(ctx)
	normalized, err := normalizeURL(opts.URL)
	if err != nil {
		return err
	}
	opts.URL = normalized

	d := getDownloader(opts.URL)

	if opts.CacheDisabled {
		_, _, err = d.Download(opts)
		return err
	}

	var t Type
	cacheDir, ok := dlcache.Get(ctx, opts.URL)
	if ok {
		var updated bool
		if d, ok := d.(UpdatingDownloader); ok {
			log.Info("Source can be updated, updating if required").Str("source", opts.Name).Str("downloader", d.Name()).Send()

			updated, err = d.Update(Options{
				Hash:          opts.Hash,
				HashAlgorithm: opts.HashAlgorithm,
				Name:          opts.Name,
				URL:           opts.URL,
				Destination:   cacheDir,
				Progress:      opts.Progress,
				LocalDir:      opts.LocalDir,
			})
			if err != nil {
				return err
			}
		}

		m, err := getManifest(cacheDir)
		if err == nil {
			t = m.Type

			dest := filepath.Join(opts.Destination, m.Name)
			ok, err := handleCache(cacheDir, dest, m.Name, t)
			if err != nil {
				return err
			}

			if ok && !updated {
				log.Info("Source found in cache and linked to destination").Str("source", opts.Name).Stringer("type", t).Send()
				return nil
			} else if ok {
				log.Info("Source updated and linked to destination").Str("source", opts.Name).Stringer("type", t).Send()
				return nil
			}
		} else {
			err = os.RemoveAll(cacheDir)
			if err != nil {
				return err
			}
		}
	}

	log.Info("Downloading source").Str("source", opts.Name).Str("downloader", d.Name()).Send()

	cacheDir, err = dlcache.New(ctx, opts.URL)
	if err != nil {
		return err
	}

	t, name, err := d.Download(Options{
		Hash:          opts.Hash,
		HashAlgorithm: opts.HashAlgorithm,
		Name:          opts.Name,
		URL:           opts.URL,
		Destination:   cacheDir,
		Progress:      opts.Progress,
		LocalDir:      opts.LocalDir,
	})
	if err != nil {
		return err
	}

	err = writeManifest(cacheDir, Manifest{t, name})
	if err != nil {
		return err
	}

	dest := filepath.Join(opts.Destination, name)
	_, err = handleCache(cacheDir, dest, name, t)
	return err
}

// Функция writeManifest записывает манифест в указанный каталог кэша
func writeManifest(cacheDir string, m Manifest) error {
	fl, err := os.Create(filepath.Join(cacheDir, manifestFileName))
	if err != nil {
		return err
	}
	defer fl.Close()
	return msgpack.NewEncoder(fl).Encode(m)
}

// Функция getManifest считывает манифест из указанного каталога кэша
func getManifest(cacheDir string) (m Manifest, err error) {
	fl, err := os.Open(filepath.Join(cacheDir, manifestFileName))
	if err != nil {
		return Manifest{}, err
	}
	defer fl.Close()

	err = msgpack.NewDecoder(fl).Decode(&m)
	return
}

// Функция handleCache создает жесткие ссылки для файлов из каталога кэша в каталог назначения
func handleCache(cacheDir, dest, name string, t Type) (bool, error) {
	switch t {
	case TypeFile:
		cd, err := os.Open(cacheDir)
		if err != nil {
			return false, err
		}

		names, err := cd.Readdirnames(0)
		if err == io.EOF {
			break
		} else if err != nil {
			return false, err
		}

		cd.Close()

		if slices.Contains(names, name) {
			err = os.Link(filepath.Join(cacheDir, name), dest)
			if err != nil {
				return false, err
			}
			return true, nil
		}
	case TypeDir:
		err := linkDir(cacheDir, dest)
		if err != nil {
			return false, err
		}
		return true, nil
	}
	return false, nil
}

// Функция linkDir рекурсивно создает жесткие ссылки для файлов из каталога src в каталог dest
func linkDir(src, dest string) error {
	return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return err
		}

		if info.Name() == manifestFileName {
			return nil
		}



		rel, err := filepath.Rel(src, path)
		if err != nil {
			return err
		}

		newPath := filepath.Join(dest, rel)
		if info.IsDir() {
			return os.MkdirAll(newPath, info.Mode())
		}

		return os.Link(path, newPath)
	})
}

// Функция getDownloader возвращает загрузчик, соответствующий URL
func getDownloader(u string) Downloader {
	for _, d := range Downloaders {
		if d.MatchURL(u) {
			return d
		}
	}
	return nil
}

// Функция normalizeURL нормализует строку URL, чтобы незначительные различия не изменяли хеш
func normalizeURL(u string) (string, error) {
	const normalizationFlags = purell.FlagRemoveTrailingSlash |
		purell.FlagRemoveDefaultPort |
		purell.FlagLowercaseHost |
		purell.FlagLowercaseScheme |
		purell.FlagRemoveDuplicateSlashes |
		purell.FlagRemoveFragment |
		purell.FlagRemoveUnnecessaryHostDots |
		purell.FlagSortQuery |
		purell.FlagDecodeHexHost |
		purell.FlagDecodeOctalHost |
		purell.FlagDecodeUnnecessaryEscapes |
		purell.FlagRemoveEmptyPortSeparator

	u, err := purell.NormalizeURLString(u, normalizationFlags)
	if err != nil {
		return "", err
	}

	// Исправление URL-адресов magnet после нормализации
	u = strings.Replace(u, "magnet://", "magnet:", 1)
	return u, nil
}
