package cbz

import (
	"archive/zip"
	"bufio"
	"context"
	"fmt"
	"io"
	"io/fs"
	"os"
	"path/filepath"
	"strings"
	"time"

	"github.com/araddon/dateparse"
	"github.com/belphemur/CBZOptimizer/v2/internal/manga"
	"github.com/belphemur/CBZOptimizer/v2/internal/utils/errs"
	"github.com/mholt/archives"
	"github.com/rs/zerolog/log"
)

// supportedImageExtensions contains file extensions considered valid image pages.
var supportedImageExtensions = map[string]bool{
	".jpg":  true,
	".jpeg": true,
	".png":  true,
	".gif":  true,
	".webp": true,
	".bmp":  true,
	".tiff": true,
	".tif":  true,
}

// parseConvertedComment checks if a zip comment's first line is a parseable date,
// indicating the archive was already converted. Returns true and the parsed time if so.
func parseConvertedComment(comment string) bool {
	if comment == "" {
		return false
	}
	scanner := bufio.NewScanner(strings.NewReader(comment))
	if scanner.Scan() {
		_, err := dateparse.ParseAny(scanner.Text())
		return err == nil
	}
	return false
}

// parseConvertedCommentTime parses the converted timestamp from a zip comment.
// Returns the time and true if the comment indicates conversion, otherwise zero time and false.
func parseConvertedCommentTime(comment string) (time.Time, bool) {
	if comment == "" {
		return time.Time{}, false
	}
	scanner := bufio.NewScanner(strings.NewReader(comment))
	if scanner.Scan() {
		t, err := dateparse.ParseAny(scanner.Text())
		if err == nil {
			return t, true
		}
	}
	return time.Time{}, false
}

// IsAlreadyConverted performs a fast check to see if the archive is already
// converted without extracting any image data. It reads only the zip comment
// and metadata files (converted.txt) to determine conversion status.
func IsAlreadyConverted(ctx context.Context, filePath string) (converted bool, err error) {
	log.Debug().Str("file_path", filePath).Msg("Checking if already converted")

	pathLower := strings.ToLower(filepath.Ext(filePath))

	if pathLower == ".cbz" {
		r, err := zip.OpenReader(filePath)
		if err != nil {
			return false, fmt.Errorf("failed to open CBZ for conversion check: %w", err)
		}
		defer errs.Capture(&err, r.Close, "failed to close zip reader")

		// Check zip comment
		if parseConvertedComment(r.Comment) {
			log.Debug().Str("file_path", filePath).Msg("Already converted (zip comment)")
			return true, nil
		}

		// Check for converted.txt inside the archive
		for _, f := range r.File {
			if strings.ToLower(filepath.Base(f.Name)) == "converted.txt" {
				rc, err := f.Open()
				if err != nil {
					continue
				}
				scanner := bufio.NewScanner(rc)
				if scanner.Scan() {
					_, parseErr := dateparse.ParseAny(scanner.Text())
					_ = rc.Close()
					if parseErr == nil {
						log.Debug().Str("file_path", filePath).Msg("Already converted (converted.txt)")
						return true, nil
					}
				} else {
					_ = rc.Close()
				}
			}
		}
	}

	// For CBR files, we need to use the archives library to check
	if pathLower == ".cbr" {
		fsys, err := archives.FileSystem(ctx, filePath, nil)
		if err != nil {
			return false, fmt.Errorf("failed to open archive: %w", err)
		}

		var converted bool
		_ = fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error {
			if err != nil || d.IsDir() {
				return err
			}
			if strings.ToLower(filepath.Base(path)) == "converted.txt" {
				file, err := fsys.Open(path)
				if err != nil {
					return nil
				}
				defer func() { _ = file.Close() }()
				scanner := bufio.NewScanner(file)
				if scanner.Scan() {
					_, err := dateparse.ParseAny(scanner.Text())
					if err == nil {
						converted = true
						return fs.SkipAll
					}
				}
			}
			return nil
		})
		return converted, nil
	}

	return false, nil
}

// ExtractChapter extracts an archive (CBZ/CBR) to a temp directory on disk.
// Pages are streamed directly to files — no image data is held in memory.
// Returns a Chapter with PageFile entries pointing to extracted files.
//
// When keepFilenames is true, each PageFile has its OriginalName set to the
// base filename of the entry inside the archive. Downstream code uses that
// name to preserve the original page identity in the output CBZ (with the
// extension swapped for format conversion). When false, OriginalName stays
// empty and the sequential %04d naming convention is used instead.
func ExtractChapter(ctx context.Context, filePath string, keepFilenames bool) (*manga.Chapter, error) {
	log.Debug().Str("file_path", filePath).Msg("Extracting chapter to disk")

	// Create temp directory for extraction
	tempDir, err := os.MkdirTemp("", "cbzoptimizer-*")
	if err != nil {
		return nil, fmt.Errorf("failed to create temp directory: %w", err)
	}

	inputDir := filepath.Join(tempDir, "input")
	if err := os.MkdirAll(inputDir, 0755); err != nil {
		_ = os.RemoveAll(tempDir)
		return nil, fmt.Errorf("failed to create input directory: %w", err)
	}

	chapter := &manga.Chapter{
		FilePath: filePath,
		TempDir:  tempDir,
	}

	// usedOriginalStems tracks the STEM (filename without extension) of every
	// OriginalName handed out in this chapter so stems stay unique across
	// pages. Tracking stems (not full names) prevents a downstream race in
	// pkg/converter/webp: the converter strips the OriginalName extension
	// and appends a target-format suffix (e.g. ".webp"), so two pages that
	// share a stem but differ in extension — e.g. a/page.png and b/page.jpg
	// (legal in zip) — would otherwise race on a single shared intermediate
	// output path. The map covers both same-stem-same-ext collisions (e.g.
	// a/page.png + b/page.png) and same-stem-different-ext collisions (e.g.
	// a/page.png + b/page.jpg). Allocated lazily so the keepFilenames=false
	// path stays allocation-free.
	var usedOriginalStems map[string]struct{}
	if keepFilenames {
		usedOriginalStems = make(map[string]struct{})
	}

	// For CBZ files, read metadata from zip comment
	pathLower := strings.ToLower(filepath.Ext(filePath))
	if pathLower == ".cbz" {
		r, err := zip.OpenReader(filePath)
		if err == nil {
			if t, ok := parseConvertedCommentTime(r.Comment); ok {
				chapter.IsConverted = true
				chapter.ConvertedTime = t
			}
			_ = r.Close()
		}
	}

	// Extract files using the archives library (supports both CBZ and CBR)
	fsys, err := archives.FileSystem(ctx, filePath, nil)
	if err != nil {
		_ = os.RemoveAll(tempDir)
		return nil, fmt.Errorf("failed to open archive: %w", err)
	}

	err = fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, walkErr error) error {
		if walkErr != nil {
			return walkErr
		}
		if d.IsDir() {
			return nil
		}

		// Check for context cancellation during extraction
		select {
		case <-ctx.Done():
			return ctx.Err()
		default:
		}

		ext := strings.ToLower(filepath.Ext(path))
		fileName := strings.ToLower(filepath.Base(path))

		// Skip OS-specific metadata files and junk
		if isJunkFile(path) {
			log.Debug().Str("file_path", filePath).Str("skipped", path).Msg("Skipping junk file")
			return nil
		}

		// Handle ComicInfo.xml
		if ext == ".xml" && fileName == "comicinfo.xml" {
			file, err := fsys.Open(path)
			if err != nil {
				return fmt.Errorf("failed to open ComicInfo.xml: %w", err)
			}
			defer func() { _ = file.Close() }()
			xmlContent, err := io.ReadAll(file)
			if err != nil {
				return fmt.Errorf("failed to read ComicInfo.xml: %w", err)
			}
			chapter.ComicInfoXml = string(xmlContent)
			log.Debug().Str("file_path", filePath).Int("xml_size", len(xmlContent)).Msg("ComicInfo.xml loaded")
			return nil
		}

		// Handle converted.txt (check conversion status)
		if !chapter.IsConverted && ext == ".txt" && fileName == "converted.txt" {
			file, err := fsys.Open(path)
			if err != nil {
				return fmt.Errorf("failed to open converted.txt: %w", err)
			}
			defer func() { _ = file.Close() }()
			scanner := bufio.NewScanner(file)
			if scanner.Scan() {
				t, err := dateparse.ParseAny(scanner.Text())
				if err == nil {
					chapter.IsConverted = true
					chapter.ConvertedTime = t
				}
			}
			return nil
		}

		// Only extract supported image files
		if !supportedImageExtensions[ext] {
			log.Debug().Str("file_path", filePath).Str("skipped", path).Str("ext", ext).Msg("Skipping non-image file")
			return nil
		}

		// Extract image file to disk
		file, err := fsys.Open(path)
		if err != nil {
			return fmt.Errorf("failed to open file %s: %w", path, err)
		}
		defer func() { _ = file.Close() }()

		// Create output file with sequential naming
		pageIndex := uint16(len(chapter.Pages))
		outputName := fmt.Sprintf("%04d%s", pageIndex, ext)
		outputPath := filepath.Join(inputDir, outputName)

		outFile, err := os.Create(outputPath)
		if err != nil {
			return fmt.Errorf("failed to create output file %s: %w", outputPath, err)
		}

		_, err = io.Copy(outFile, file)
		closeErr := outFile.Close()
		if err != nil {
			_ = os.Remove(outputPath)
			return fmt.Errorf("failed to write file %s: %w", outputPath, err)
		}
		if closeErr != nil {
			_ = os.Remove(outputPath)
			return fmt.Errorf("failed to close file %s: %w", outputPath, closeErr)
		}

		page := &manga.PageFile{
			Index:     pageIndex,
			Extension: ext,
			FilePath:  outputPath,
		}
		if keepFilenames {
			page.OriginalName = allocateUniqueBaseName(archiveBaseName(path), pageIndex, usedOriginalStems)
		}
		chapter.Pages = append(chapter.Pages, page)

		log.Debug().
			Str("file_path", filePath).
			Str("archive_file", path).
			Uint16("page_index", pageIndex).
			Msg("Page extracted to disk")

		return nil
	})

	if err != nil {
		_ = os.RemoveAll(tempDir)
		return nil, fmt.Errorf("failed to extract archive: %w", err)
	}

	log.Debug().
		Str("file_path", filePath).
		Int("pages_extracted", len(chapter.Pages)).
		Bool("is_converted", chapter.IsConverted).
		Msg("Chapter extraction completed")

	return chapter, nil
}

// isJunkFile returns true for known OS/tool metadata files that should not be
// treated as pages (e.g., __MACOSX/, Thumbs.db, .DS_Store).
func isJunkFile(path string) bool {
	// __MACOSX resource fork directories
	if strings.Contains(path, "__MACOSX") {
		return true
	}
	baseLower := strings.ToLower(filepath.Base(path))
	switch baseLower {
	case "thumbs.db", ".ds_store", "desktop.ini":
		return true
	}
	return false
}

// archiveBaseName returns the bare base name of an archive entry, with
// Windows-style backslash separators normalized to forward slashes before
// the last-segment split. The archives library surfaces zip entry names
// verbatim, so a name like "..\evil.png" (common from Windows-created
// archives) would otherwise pass through filepath.Base unchanged on non-
// Windows hosts and be written into the output CBZ as a path-traversal
// shape. Normalizing the separators first and then taking the segment
// after the final one guarantees the returned name is a bare base name
// with no directory components, no backslashes, and no forward slashes —
// which is what the downstream writer expects as a safe ZIP entry name.
//
// This intentionally does NOT touch filepath.Base calls in isJunkFile or
// the ComicInfo.xml / converted.txt detection: those comparisons just
// fail to match, and the entry then falls through to the non-image
// extension filter, so no traversal-shaped data escapes the loader.
func archiveBaseName(path string) string {
	normalized := strings.ReplaceAll(path, "\\", "/")
	if idx := strings.LastIndex(normalized, "/"); idx >= 0 {
		return normalized[idx+1:]
	}
	return normalized
}

// allocateUniqueBaseName returns baseName when its stem is not already taken
// by an earlier page in this chapter, or a collision-resolved variant
// otherwise.
//
// Stem-uniqueness contract: the function guarantees that the STEM
// (filename without extension) of the returned OriginalName is unique
// among all names previously handed out from this chapter. That contract
// matches what downstream code relies on —
// pkg/converter/webp.intermediatePageName strips the OriginalName's
// extension and appends a target-format suffix (e.g. ".webp"), so two
// OriginalNames that share a stem would otherwise race on a single
// intermediate output path. The stem is computed the same way downstream
// does it (strings.TrimSuffix(name, filepath.Ext(name))) to keep both
// definitions in lock-step.
//
// On collision the resolved name matches the existing fallback style used
// by cbz_creator's resolvePageName: stem + "_%04d" + extension. The loop
// checks the CANDIDATE's stem (not the full generated name) so two files
// that share a stem but differ in extension — e.g. "page.png" and
// "page.jpg" — resolve to, e.g., "page.png" and "page_0001.jpg", each
// preserving its original extension. The starting suffix is pageIndex so
// the resolved name stays in the same neighborhood as the page's archive
// position. If the candidate's stem is itself already taken
// (astronomically rare: the source archive would need both the original
// name and a matching indexed name), the suffix is incremented until a
// free stem is found. The chosen name's stem is recorded in usedStems so
// subsequent calls cannot pick it again.
func allocateUniqueBaseName(baseName string, pageIndex uint16, usedStems map[string]struct{}) string {
	ext := filepath.Ext(baseName)
	stem := strings.TrimSuffix(baseName, ext)
	if _, taken := usedStems[stem]; !taken {
		usedStems[stem] = struct{}{}
		return baseName
	}
	for suffix := int(pageIndex); ; suffix++ {
		candidateStem := fmt.Sprintf("%s_%04d", stem, suffix)
		if _, taken := usedStems[candidateStem]; !taken {
			usedStems[candidateStem] = struct{}{}
			return candidateStem + ext
		}
	}
}

// LoadChapter extracts the chapter from a CBZ/CBR file to disk.
// It delegates to ExtractChapter with keepFilenames=false and always
// extracts all pages. Use IsAlreadyConverted for a fast conversion status
// check without extraction.
func LoadChapter(filePath string) (*manga.Chapter, error) {
	return ExtractChapter(context.Background(), filePath, false)
}
