package astikit

import (
	"archive/zip"
	"context"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"strings"
)

// internal shouldn't lead with a "/"
func zipInternalPath(p string) (external, internal string) {
	if items := strings.Split(p, ".zip"); len(items) > 1 {
		external = items[0] + ".zip"
		internal = strings.TrimPrefix(strings.Join(items[1:], ".zip"), string(os.PathSeparator))
		return
	}
	external = p
	return
}

// Zip zips a src into a dst
// Possible dst formats are:
//   - /path/to/zip.zip
//   - /path/to/zip.zip/root/path
func Zip(ctx context.Context, dst, src string) (err error) {
	// Get external/internal path
	externalPath, internalPath := zipInternalPath(dst)

	// Make sure the directory exists
	if err = os.MkdirAll(filepath.Dir(externalPath), DefaultDirMode); err != nil {
		return fmt.Errorf("astikit: mkdirall %s failed: %w", filepath.Dir(externalPath), err)
	}

	// Create destination file
	var dstFile *os.File
	if dstFile, err = os.Create(externalPath); err != nil {
		return fmt.Errorf("astikit: creating %s failed: %w", externalPath, err)
	}
	defer dstFile.Close()

	// Create zip writer
	var zw = zip.NewWriter(dstFile)
	defer zw.Close()

	// Make sure to clean dir path so that we get consistent path separator with filepath.Walk
	src = filepath.Clean(src)

	// Walk
	if err = filepath.Walk(src, func(path string, info os.FileInfo, e error) (err error) {
		// Process error
		if e != nil {
			err = e
			return
		}

		// Init header
		var h *zip.FileHeader
		if h, err = zip.FileInfoHeader(info); err != nil {
			return fmt.Errorf("astikit: initializing zip header failed: %w", err)
		}

		// Set header info
		h.Name = filepath.Join(internalPath, strings.TrimPrefix(path, src))
		if info.IsDir() {
			h.Name += string(os.PathSeparator)
		} else {
			h.Method = zip.Deflate
		}

		// Create writer
		var w io.Writer
		if w, err = zw.CreateHeader(h); err != nil {
			return fmt.Errorf("astikit: creating zip header failed: %w", err)
		}

		// If path is dir, stop here
		if info.IsDir() {
			return
		}

		// Open path
		var walkFile *os.File
		if walkFile, err = os.Open(path); err != nil {
			return fmt.Errorf("astikit: opening %s failed: %w", path, err)
		}
		defer walkFile.Close()

		// Copy
		if _, err = Copy(ctx, w, walkFile); err != nil {
			return fmt.Errorf("astikit: copying failed: %w", err)
		}
		return
	}); err != nil {
		return fmt.Errorf("astikit: walking failed: %w", err)
	}
	return
}

// Unzip unzips a src into a dst
// Possible src formats are:
//   - /path/to/zip.zip
//   - /path/to/zip.zip/root/path
func Unzip(ctx context.Context, dst, src string) (err error) {
	// Get external/internal path
	externalPath, internalPath := zipInternalPath(src)

	// Make sure the destination exists
	if err = os.MkdirAll(dst, DefaultDirMode); err != nil {
		return fmt.Errorf("astikit: mkdirall %s failed: %w", dst, err)
	}

	// Open overall reader
	var r *zip.ReadCloser
	if r, err = zip.OpenReader(externalPath); err != nil {
		return fmt.Errorf("astikit: opening overall zip reader on %s failed: %w", externalPath, err)
	}
	defer r.Close()

	// Loop through files to determine their type
	var dirs, files, symlinks = make(map[string]*zip.File), make(map[string]*zip.File), make(map[string]*zip.File)
	for _, f := range r.File {
		// Validate internal path
		if internalPath != "" && !strings.HasPrefix(f.Name, internalPath) {
			continue
		}
		var p = filepath.Join(dst, strings.TrimPrefix(f.Name, internalPath))

		// Check file type
		if f.FileInfo().Mode()&os.ModeSymlink != 0 {
			symlinks[p] = f
		} else if f.FileInfo().IsDir() {
			dirs[p] = f
		} else {
			files[p] = f
		}
	}

	// Invalid internal path
	if internalPath != "" && len(dirs) == 0 && len(files) == 0 && len(symlinks) == 0 {
		return fmt.Errorf("astikit: content in archive does not match specified internal path %s", internalPath)
	}

	// Create dirs
	for p, f := range dirs {
		if err = os.MkdirAll(p, f.FileInfo().Mode().Perm()); err != nil {
			return fmt.Errorf("astikit: mkdirall %s failed: %w", p, err)
		}
	}

	// Create files
	for p, f := range files {
		if err = createZipFile(ctx, f, p); err != nil {
			return fmt.Errorf("astikit: creating zip file into %s failed: %w", p, err)
		}
	}

	// Create symlinks
	for p, f := range symlinks {
		if err = createZipSymlink(f, p); err != nil {
			return fmt.Errorf("astikit: creating zip symlink into %s failed: %w", p, err)
		}
	}
	return
}

func createZipFile(ctx context.Context, f *zip.File, p string) (err error) {
	// Open file reader
	var fr io.ReadCloser
	if fr, err = f.Open(); err != nil {
		return fmt.Errorf("astikit: opening zip reader on file %s failed: %w", f.Name, err)
	}
	defer fr.Close()

	// Since dirs don't always come up we make sure the directory of the file exists with default
	// file mode
	if err = os.MkdirAll(filepath.Dir(p), DefaultDirMode); err != nil {
		return fmt.Errorf("astikit: mkdirall %s failed: %w", filepath.Dir(p), err)
	}

	// Open the file
	var fl *os.File
	if fl, err = os.OpenFile(p, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.FileInfo().Mode().Perm()); err != nil {
		return fmt.Errorf("astikit: opening file %s failed: %w", p, err)
	}
	defer fl.Close()

	// Copy
	if _, err = Copy(ctx, fl, fr); err != nil {
		return fmt.Errorf("astikit: copying %s into %s failed: %w", f.Name, p, err)
	}
	return
}

func createZipSymlink(f *zip.File, p string) (err error) {
	// Open file reader
	var fr io.ReadCloser
	if fr, err = f.Open(); err != nil {
		return fmt.Errorf("astikit: opening zip reader on file %s failed: %w", f.Name, err)
	}
	defer fr.Close()

	// If file is a symlink we retrieve the target path that is in the content of the file
	var b []byte
	if b, err = io.ReadAll(fr); err != nil {
		return fmt.Errorf("astikit: reading all %s failed: %w", f.Name, err)
	}

	// Create the symlink
	if err = os.Symlink(string(b), p); err != nil {
		return fmt.Errorf("astikit: creating symlink from %s to %s failed: %w", string(b), p, err)
	}
	return
}
