package cbconvert

import (
	"bytes"
	"context"
	"crypto/md5"
	"fmt"
	"image"
	_ "image/gif"
	"image/png"
	"os"
	"path/filepath"
	"strconv"
	"strings"

	pngstructure "github.com/dsoprea/go-png-image-structure"
	"github.com/dustin/go-humanize"
	folio "github.com/gen2brain/folio/doc"
)

// Options type.
type Options struct {
	// Image format, valid values are jpeg, png, tiff, bmp, webp, avif, jxl
	Format string
	// Archive format, valid values are zip, tar
	Archive string
	// ZIP compression level: -1 default, 0 store (no compression), 1-9 deflate (1 fastest, 9 smallest)
	ZipLevel int
	// JPEG image quality
	Quality int
	// Encoder speed/effort, format-specific: webp method 0-6, avif speed 0-10, jxl effort 1-10; -1 uses the format default
	Effort int
	// Combine all inputs into a single archive
	Combine bool
	// Lossless enables lossless compression (webp, avif, jxl), ignores quality
	Lossless bool
	// Image width
	Width int
	// Image height
	Height int
	// Best fit for required width and height
	Fit bool
	// Do not upscale images already smaller than the requested width/height
	NoUpscale bool
	// Document rendering resolution in DPI (PDF, EPUB, etc.); 0 uses the page's native resolution
	DPI int
	// 0=NearestNeighbor, 1=Box, 2=Linear, 3=MitchellNetravali, 4=CatmullRom, 5=Gaussian, 6=Lanczos
	Filter int
	// Do not convert the cover image
	NoCover bool
	// Do not convert images that have RGB colorspace
	NoRGB bool
	// Remove non-image files from the archive
	NoNonImage bool
	// Do not transform or convert images
	NoConvert bool
	// Add suffix to file baseNoExt
	Suffix string
	// Extract cover
	Cover bool
	// Extract cover thumbnail (freedesktop spec.)
	Thumbnail bool
	// CBZ metadata
	Meta bool
	// Version
	Version bool
	// ZIP comment
	Comment bool
	// ZIP comment body
	CommentBody string
	// Add file
	FileAdd string
	// Remove file
	FileRemove string
	// Output file
	OutFile string
	// Output directory
	OutDir string
	// Convert images to grayscale (monochromatic)
	Grayscale bool
	// Rotate images, valid values are 0, 90, 180, 270
	Rotate int
	// Adjust the brightness of the images, must be in the range (-100, 100)
	Brightness int
	// Adjust the contrast of the images, must be in the range (-100, 100)
	Contrast int
	// Process subdirectories recursively
	Recursive bool
	// Process only files larger than size (in MB)
	Size int
	// Hide console output
	Quiet bool
}

// Converter type.
type Converter struct {
	// Options struct
	Opts Options
	// Current working directory
	Workdir string
	// Page name prefix, set per input when combining
	prefix string
	// Input root for the current file, used to build recursive output paths
	root string
	// Target size for fast previews; when set, JPEG covers are IDCT-downscaled while decoding
	previewWidth, previewHeight int
	// Number of files
	Nfiles int
	// Index of the current file
	CurrFile int
	// Number of contents in archive/document
	Ncontents int
	// Index of current content
	CurrContent int32
	// Start function
	OnStart func()
	// Progress function
	OnProgress func()
	// Compress function
	OnCompress func()
	// Cancel function
	OnCancel func()
}

// File type.
type File struct {
	Name      string
	Path      string
	Root      string
	Stat      os.FileInfo
	SizeHuman string
}

// Image type.
type Image struct {
	Image     image.Image
	Width     int
	Height    int
	SizeHuman string
}

// NewOptions returns default options.
func NewOptions() Options {
	o := Options{}
	o.Format = "jpeg"
	o.Archive = "zip"
	o.Quality = 75
	o.Effort = -1
	o.ZipLevel = -1
	o.Filter = 2

	return o
}

// New returns new converter.
func New(o Options) *Converter {
	c := &Converter{}
	c.Opts = o

	return c
}

// Args returns the non-default options as cbconvert convert command-line flags.
func (o Options) Args() []string {
	def := NewOptions()

	var args []string

	str := func(name, val, dflt string) {
		if val != dflt {
			args = append(args, "--"+name, val)
		}
	}
	num := func(name string, val, dflt int) {
		if val != dflt {
			args = append(args, "--"+name, strconv.Itoa(val))
		}
	}
	flag := func(name string, val bool) {
		if val {
			args = append(args, "--"+name)
		}
	}

	num("width", o.Width, def.Width)
	num("height", o.Height, def.Height)
	flag("fit", o.Fit)
	flag("no-upscale", o.NoUpscale)
	num("dpi", o.DPI, def.DPI)
	str("format", o.Format, def.Format)
	str("archive", o.Archive, def.Archive)
	num("zip-level", o.ZipLevel, def.ZipLevel)
	num("quality", o.Quality, def.Quality)
	num("effort", o.Effort, def.Effort)
	flag("lossless", o.Lossless)
	flag("combine", o.Combine)
	str("outfile", o.OutFile, def.OutFile)
	num("filter", o.Filter, def.Filter)
	flag("no-cover", o.NoCover)
	flag("no-rgb", o.NoRGB)
	flag("no-nonimage", o.NoNonImage)
	flag("no-convert", o.NoConvert)
	str("suffix", o.Suffix, def.Suffix)
	flag("grayscale", o.Grayscale)
	num("rotate", o.Rotate, def.Rotate)
	num("brightness", o.Brightness, def.Brightness)
	num("contrast", o.Contrast, def.Contrast)
	flag("recursive", o.Recursive)
	str("outdir", o.OutDir, def.OutDir)
	num("size", o.Size, def.Size)

	return args
}

// renderPage renders document page n at the configured DPI, or at the page's
// native resolution when DPI is unset.
func (c *Converter) renderPage(doc folio.Document, n int) (*image.RGBA, error) {
	page, err := doc.Page(n)
	if err != nil {
		return nil, err
	}

	if c.Opts.DPI > 0 {
		return page.ImageDPI(float64(c.Opts.DPI))
	}

	return page.Image()
}

// Cancel cancels the operation.
func (c *Converter) Cancel() {
	if c.OnCancel != nil {
		c.OnCancel()
	}
}

// Files returns list of found comic files.
func (c *Converter) Files(args []string) ([]File, error) {
	var files []File
	var root string

	toFile := func(fp string, f os.FileInfo) File {
		var file File
		file.Name = filepath.Base(fp)
		file.Path = fp
		file.Root = root
		file.Stat = f
		file.SizeHuman = humanize.IBytes(uint64(f.Size()))
		return file
	}

	walkFiles := func(fp string, f os.FileInfo, err error) error {
		if f.IsDir() {
			return nil
		}
		if isArchive(fp) || isDocument(fp) {
			if isSize(int64(c.Opts.Size), f.Size()) {
				files = append(files, toFile(fp, f))
			}
		}

		return nil
	}

	walkDirs := func(fp string, f os.FileInfo, err error) error {
		if f.IsDir() {
			fs, err := os.ReadDir(filepath.Join(filepath.Dir(fp), f.Name()))
			if err != nil {
				return err
			}

			count := 0
			for _, fn := range fs {
				if !fn.IsDir() && isImage(fn.Name()) {
					count++
				}
			}

			if count > 1 {
				files = append(files, toFile(fp, f))
			}
		}

		return nil
	}

	for _, arg := range args {
		path, err := filepath.Abs(arg)
		if err != nil {
			return files, fmt.Errorf("%s: %w", arg, err)
		}

		stat, err := os.Stat(path)
		if err != nil {
			return files, fmt.Errorf("%s: %w", arg, err)
		}

		if !stat.IsDir() {
			root = filepath.Dir(path)
			if isArchive(path) || isDocument(path) {
				if isSize(int64(c.Opts.Size), stat.Size()) {
					files = append(files, toFile(path, stat))
				}
			}
		} else {
			root = path
			if c.Opts.Recursive {
				if err := filepath.Walk(path, walkFiles); err != nil {
					return files, fmt.Errorf("%s: %w", arg, err)
				}
			} else {
				fs, err := os.ReadDir(path)
				if err != nil {
					return files, fmt.Errorf("%s: %w", arg, err)
				}

				for _, f := range fs {
					if isArchive(f.Name()) || isDocument(f.Name()) {
						info, err := f.Info()
						if err != nil {
							return files, fmt.Errorf("%s: %w", arg, err)
						}
						if isSize(int64(c.Opts.Size), info.Size()) {
							files = append(files, toFile(filepath.Join(path, f.Name()), info))
						}
					}
				}
			}

			if len(files) == 0 {
				// append plain directory with images
				if c.Opts.Recursive {
					if err := filepath.Walk(path, walkDirs); err != nil {
						return files, fmt.Errorf("%s: %w", arg, err)
					}
				} else {
					files = append(files, toFile(path, stat))
				}
			}
		}
	}

	c.Nfiles = len(files)

	return files, nil
}

// recursiveDir mirrors the source path under OutDir, relative to the input root.
func (c *Converter) recursiveDir(fileName string) string {
	dir := filepath.Dir(fileName)

	if c.root != "" {
		if rel, err := filepath.Rel(c.root, dir); err == nil {
			return filepath.Join(c.Opts.OutDir, rel)
		}
	}

	dir = strings.TrimPrefix(dir[len(filepath.VolumeName(dir)):], string(os.PathSeparator))

	return filepath.Join(c.Opts.OutDir, dir)
}

// Cover extracts cover.
func (c *Converter) Cover(file File) error {
	c.CurrFile++
	c.root = file.Root
	fileName, fileInfo := file.Path, file.Stat

	cover, err := c.coverImage(fileName, fileInfo)
	if err != nil {
		return fmt.Errorf("%s: %w", fileName, err)
	}

	if c.Opts.Width > 0 || c.Opts.Height > 0 {
		cover = c.resizeFit(cover)
	}

	ext := c.Opts.Format
	if ext == "jpeg" {
		ext = "jpg"
	}

	var fName string
	if c.Opts.Recursive {
		outDir := c.recursiveDir(fileName)
		if err := os.MkdirAll(outDir, 0755); err != nil {
			return fmt.Errorf("%s: %w", fileName, err)
		}

		fName = filepath.Join(outDir, fmt.Sprintf("%s.%s", baseNoExt(fileName), ext))
	} else {
		fName = filepath.Join(c.Opts.OutDir, fmt.Sprintf("%s.%s", baseNoExt(fileName), ext))
	}

	w, err := os.Create(fName)
	if err != nil {
		return fmt.Errorf("imageConvert: %w", err)
	}
	defer w.Close()

	if err := c.imageEncode(cover, w); err != nil {
		return fmt.Errorf("%s: %w", fileName, err)
	}

	return nil
}

// Thumbnail extracts thumbnail.
func (c *Converter) Thumbnail(file File) error {
	c.CurrFile++
	c.root = file.Root
	fileName, fileInfo := file.Path, file.Stat

	cover, err := c.coverImage(fileName, fileInfo)
	if err != nil {
		return fmt.Errorf("%s: %w", fileName, err)
	}

	if c.Opts.Width > 0 || c.Opts.Height > 0 {
		cover = c.resizeFit(cover)
	} else {
		cover = resize(cover, 256, 0, resampleFilter(c.Opts.Filter))
	}

	var buf bytes.Buffer
	err = png.Encode(&buf, cover)
	if err != nil {
		return fmt.Errorf("%s: %w", fileName, err)
	}

	pmp := pngstructure.NewPngMediaParser()
	csTmp, err := pmp.ParseBytes(buf.Bytes())
	if err != nil {
		return fmt.Errorf("%s: %w", fileName, err)
	}

	cs, ok := csTmp.(*pngstructure.ChunkSlice)
	if !ok {
		return fmt.Errorf("%s: type is not ChunkSlice", fileName)
	}

	var fName string
	var fURI string

	if c.Opts.OutFile == "" {
		fURI = "file://" + fileName

		if c.Opts.Recursive {
			outDir := c.recursiveDir(fileName)
			if err := os.MkdirAll(outDir, 0755); err != nil {
				return fmt.Errorf("%s: %w", fileName, err)
			}

			fName = filepath.Join(outDir, fmt.Sprintf("%x.png", md5.Sum([]byte(fURI))))
		} else {
			fName = filepath.Join(c.Opts.OutDir, fmt.Sprintf("%x.png", md5.Sum([]byte(fURI))))
		}
	} else {
		abs, _ := filepath.Abs(c.Opts.OutFile)
		fURI = "file://" + abs
		fName = abs
	}

	chunks := cs.Chunks()
	textChunks := []*pngstructure.Chunk{
		{Type: `tEXt`, Data: []uint8("Software\x00" + "CBconvert")},
		{Type: `tEXt`, Data: []uint8("Description\x00" + "Thumbnail of " + fileName)},
		{Type: `tEXt`, Data: []uint8("Thumb::URI\x00" + fURI)},
		{Type: `tEXt`, Data: []uint8("Thumb::MTime\x00" + strconv.FormatInt(fileInfo.ModTime().Unix(), 10))},
		{Type: `tEXt`, Data: []uint8("Thumb::Size\x00" + strconv.FormatInt(fileInfo.Size(), 10))},
	}

	for _, textChunk := range textChunks {
		textChunk.Length = uint32(len(textChunk.Data))
		textChunk.UpdateCrc32()
	}

	chunks = append(
		chunks[:1],
		append(
			textChunks,
			chunks[1:]...,
		)...,
	)

	cs = pngstructure.NewChunkSlice(chunks)
	err = cs.WriteTo(&buf)
	if err != nil {
		return fmt.Errorf("%s: %w", fileName, err)
	}

	f, err := os.Create(fName)
	if err != nil {
		return fmt.Errorf("%s: %w", fileName, err)
	}

	defer f.Close()

	_, err = buf.WriteTo(f)
	if err != nil {
		return fmt.Errorf("%s: %w", fileName, err)
	}

	return nil
}

// Meta manipulates with CBZ metadata.
func (c *Converter) Meta(fileName string) (any, error) {
	c.CurrFile++

	switch {
	case c.Opts.Cover:
		var images []string

		contents, err := c.archiveList(fileName)
		if err != nil {
			return nil, fmt.Errorf("%s: %w", fileName, err)
		}

		for _, ct := range contents {
			if isImage(ct) {
				images = append(images, ct)
			}
		}

		return c.coverName(images), nil
	case c.Opts.Comment:
		comment, err := c.archiveComment(fileName)
		if err != nil {
			return nil, fmt.Errorf("%s: %w", fileName, err)
		}

		return comment, nil
	case c.Opts.CommentBody != "":
		err := c.archiveSetComment(fileName, c.Opts.CommentBody)
		if err != nil {
			return nil, fmt.Errorf("%s: %w", fileName, err)
		}
	case c.Opts.FileAdd != "":
		err := c.archiveFileAdd(fileName, c.Opts.FileAdd)
		if err != nil {
			return nil, fmt.Errorf("%s: %w", fileName, err)
		}
	case c.Opts.FileRemove != "":
		err := c.archiveFileRemove(fileName, c.Opts.FileRemove)
		if err != nil {
			return nil, fmt.Errorf("%s: %w", fileName, err)
		}
	}

	return "", nil
}

// Preview returns the cover as an image preview.
func (c *Converter) Preview(fileName string, fileInfo os.FileInfo, width, height int) (Image, error) {
	i, err := c.coverImage(fileName, fileInfo)
	if err != nil {
		return Image{}, fmt.Errorf("%s: %w", fileName, err)
	}

	return c.previewImage(fileName, i, width, height)
}

// CoverPreview returns the cover fitted into width x height, skipping the output-codec round-trip.
func (c *Converter) CoverPreview(fileName string, fileInfo os.FileInfo, width, height int) (Image, error) {
	c.previewWidth, c.previewHeight = width, height

	i, err := c.coverImage(fileName, fileInfo)
	if err != nil {
		return Image{}, fmt.Errorf("%s: %w", fileName, err)
	}

	if width != 0 && height != 0 {
		i = fit(i, width, height, resampleFilter(c.Opts.Filter))
	}

	var img Image
	img.Image = i
	img.Width = i.Bounds().Dx()
	img.Height = i.Bounds().Dy()

	return img, nil
}

// PreviewPage returns the page-th image (0-based) as an image preview.
func (c *Converter) PreviewPage(fileName string, fileInfo os.FileInfo, page, width, height int) (Image, error) {
	i, err := c.pageImage(fileName, fileInfo, page)
	if err != nil {
		return Image{}, fmt.Errorf("%s: %w", fileName, err)
	}

	return c.previewImage(fileName, i, width, height)
}

// previewImage applies the configured transforms and fits the result into width x height.
func (c *Converter) previewImage(fileName string, i image.Image, width, height int) (Image, error) {
	var img Image

	i = c.imageTransform(i)

	var w bytes.Buffer

	if err := c.imageEncode(i, &w); err != nil {
		return img, fmt.Errorf("%s: %w", fileName, err)
	}

	img.Width = i.Bounds().Dx()
	img.Height = i.Bounds().Dy()
	img.SizeHuman = humanize.IBytes(uint64(len(w.Bytes())))

	dec, err := c.imageDecode(bytes.NewReader(w.Bytes()))
	if err != nil {
		return img, fmt.Errorf("%s: %w", fileName, err)
	}

	if width != 0 && height != 0 {
		dec = fit(dec, width, height, resampleFilter(c.Opts.Filter))
	}

	img.Image = dec

	return img, nil
}

// Convert converts a comic book.
func (c *Converter) Convert(file File) error {
	c.CurrFile++
	c.root = file.Root
	fileName, fileInfo := file.Path, file.Stat

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	c.OnCancel = cancel
	c.prefix = ""

	var err error
	c.Workdir, err = os.MkdirTemp(os.TempDir(), "cbc")
	if err != nil {
		return fmt.Errorf("%s: %w", fileName, err)
	}

	switch {
	case fileInfo.IsDir():
		if err := c.convertDirectory(ctx, fileName); err != nil {
			return fmt.Errorf("%s: %w", fileName, err)
		}
	case isDocument(fileName):
		if err := c.convertDocument(ctx, fileName); err != nil {
			return fmt.Errorf("%s: %w", fileName, err)
		}
	case isArchive(fileName):
		if err := c.convertArchive(ctx, fileName); err != nil {
			return fmt.Errorf("%s: %w", fileName, err)
		}
	}

	if err := c.archiveSave(fileName); err != nil {
		return fmt.Errorf("%s: %w", fileName, err)
	}

	c.OnCancel = nil

	return nil
}

// Combine merges multiple comic books into a single archive.
func (c *Converter) Combine(files []File) error {
	if len(files) == 0 {
		return nil
	}

	c.root = ""

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	c.OnCancel = cancel

	var err error
	c.Workdir, err = os.MkdirTemp(os.TempDir(), "cbc")
	if err != nil {
		return fmt.Errorf("Combine: %w", err)
	}

	for i, file := range files {
		c.CurrFile++
		c.prefix = fmt.Sprintf("%04d_", i+1)

		switch {
		case file.Stat.IsDir():
			err = c.convertDirectory(ctx, file.Path)
		case isDocument(file.Path):
			err = c.convertDocument(ctx, file.Path)
		case isArchive(file.Path):
			err = c.convertArchive(ctx, file.Path)
		}

		if err != nil {
			return fmt.Errorf("%s: %w", file.Path, err)
		}
	}

	out := c.Opts.OutFile
	if out == "" {
		out = baseNoExt(files[0].Path) + "-combined"
	}

	if err := c.archiveSave(out); err != nil {
		return fmt.Errorf("Combine: %w", err)
	}

	c.OnCancel = nil

	return nil
}
