package main

import (
	"bytes"
	"path/filepath"
	"strings"
)

// ---------------------------------------------------------------------------
// Content identification
//
// Every file is identified by looking at its first bytes. The filename
// extension is consulted ONLY when the magic bytes are inconclusive, and the
// inventory always records WHICH method produced the answer, so a phone full of
// files named .jpg that are really HEIC cannot quietly lie to you.
// ---------------------------------------------------------------------------

// Detection methods, recorded per item as detected_by.
const (
	byMagic     = "magic"
	byMagicExt  = "magic+extension" // container found by magic, refined by extension
	byExtension = "extension"
	byNothing   = "unrecognised"
)

const (
	kindUnknown = "application/octet-stream"
	// sniffLen is how much of the head of a file we read to identify it. The
	// longest thing we look at is an ISO base media ftyp box header plus a few
	// compatible brands.
	sniffLen = 64
)

// categoryOf groups a media type into the buckets a migration actually cares
// about. Anything unlisted is "other".
func categoryOf(kind string) string {
	switch {
	case strings.HasPrefix(kind, "image/"):
		return "photo"
	case strings.HasPrefix(kind, "video/"):
		return "video"
	case strings.HasPrefix(kind, "audio/"):
		return "audio"
	case kind == "application/pdf",
		kind == "text/plain",
		kind == "text/vcard",
		kind == "text/calendar",
		kind == "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
		kind == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
		kind == "application/vnd.openxmlformats-officedocument.presentationml.presentation":
		return "document"
	case kind == "application/zip",
		kind == "application/epub+zip",
		kind == "application/vnd.android.package-archive":
		return "archive"
	}
	return "other"
}

// prefixSig is a fixed byte sequence at the very start of the file.
type prefixSig struct {
	magic []byte
	kind  string
}

var prefixSigs = []prefixSig{
	{[]byte{0xFF, 0xD8, 0xFF}, "image/jpeg"},
	{[]byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, "image/png"},
	{[]byte("GIF87a"), "image/gif"},
	{[]byte("GIF89a"), "image/gif"},
	{[]byte("%PDF-"), "application/pdf"},
	{[]byte{'P', 'K', 0x03, 0x04}, "application/zip"},
	{[]byte{'P', 'K', 0x05, 0x06}, "application/zip"}, // empty archive
	{[]byte{'P', 'K', 0x07, 0x08}, "application/zip"}, // spanned archive
}

// heicBrands are the ISO base media brands that mean "HEIF still image".
var heicBrands = map[string]bool{
	"heic": true, "heix": true, "hevc": true, "hevx": true,
	"heim": true, "heis": true, "hevm": true, "hevs": true,
	"mif1": true, "msf1": true,
}

// avifBrands are the AV1 still-image brands. They share the ftyp container with
// HEIC but are a different codec, so they are reported separately rather than
// folded into image/heic.
var avifBrands = map[string]bool{"avif": true, "avis": true}

// quickTimeAtoms are top-level atom types that identify a classic QuickTime
// .mov file that carries no ftyp box at all.
var quickTimeAtoms = map[string]bool{
	"moov": true, "mdat": true, "wide": true, "pnot": true, "skip": true, "free": true,
}

// zipRefine maps an extension onto the specific zip-based format, used when the
// magic bytes prove the file is a zip container but not which one.
var zipRefine = map[string]string{
	".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
	".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
	".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
	".epub": "application/epub+zip",
	".apk":  "application/vnd.android.package-archive",
}

// extKinds is the fallback table, consulted only when sniffing is inconclusive.
var extKinds = map[string]string{
	".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".jpe": "image/jpeg",
	".png":  "image/png",
	".gif":  "image/gif",
	".heic": "image/heic", ".heif": "image/heic",
	".avif": "image/avif",
	".webp": "image/webp",
	".tif":  "image/tiff", ".tiff": "image/tiff",
	".bmp": "image/bmp",
	".dng": "image/x-adobe-dng",
	".mp4": "video/mp4", ".m4v": "video/mp4",
	".mov": "video/quicktime",
	".3gp": "video/3gpp", ".3g2": "video/3gpp2",
	".avi":  "video/x-msvideo",
	".mkv":  "video/x-matroska",
	".webm": "video/webm",
	".mp3":  "audio/mpeg",
	".m4a":  "audio/mp4", ".aac": "audio/aac",
	".wav": "audio/wav", ".flac": "audio/flac",
	".opus": "audio/opus", ".ogg": "audio/ogg",
	".amr": "audio/amr",
	".pdf": "application/pdf",
	".zip": "application/zip",
	".txt": "text/plain", ".log": "text/plain", ".md": "text/plain",
	".vcf":  "text/vcard",
	".ics":  "text/calendar",
	".csv":  "text/csv",
	".json": "application/json",
	".xml":  "application/xml",
	".docx": zipRefine[".docx"],
	".xlsx": zipRefine[".xlsx"],
	".pptx": zipRefine[".pptx"],
	".epub": zipRefine[".epub"],
	".apk":  zipRefine[".apk"],
}

// sniff identifies a file from the head of its contents and, only where that is
// inconclusive, from its name. It returns the media type and the method used.
func sniff(head []byte, name string) (kind string, method string) {
	ext := strings.ToLower(filepath.Ext(name))

	if k, ok := sniffMagic(head); ok {
		if k == "application/zip" {
			if refined, ok := zipRefine[ext]; ok {
				return refined, byMagicExt
			}
		}
		return k, byMagic
	}
	if k, ok := extKinds[ext]; ok {
		return k, byExtension
	}
	return kindUnknown, byNothing
}

// sniffMagic reports the media type implied by the leading bytes alone, or
// ok=false when the bytes decide nothing.
func sniffMagic(head []byte) (string, bool) {
	for _, s := range prefixSigs {
		if len(head) >= len(s.magic) && bytes.Equal(head[:len(s.magic)], s.magic) {
			return s.kind, true
		}
	}
	// ISO base media file format: [4-byte box size]["ftyp"][4-byte major brand]
	if len(head) >= 12 && bytes.Equal(head[4:8], []byte("ftyp")) {
		major := string(head[8:12])
		if k, ok := isoBrandKind(major); ok {
			return k, true
		}
		// The major brand was unfamiliar; the compatible-brand list that
		// follows the 4-byte minor version may still name something we know.
		for off := 16; off+4 <= len(head); off += 4 {
			if k, ok := isoBrandKind(string(head[off : off+4])); ok {
				return k, true
			}
		}
		// It is definitely an ISO base media file. Everything in that family
		// that is not a still image is, for migration purposes, an MP4.
		return "video/mp4", true
	}
	// Classic QuickTime movies carry no ftyp box, just a top-level atom.
	if len(head) >= 8 && quickTimeAtoms[string(head[4:8])] {
		return "video/quicktime", true
	}
	return "", false
}

func isoBrandKind(brand string) (string, bool) {
	switch {
	case brand == "qt  ":
		return "video/quicktime", true
	case heicBrands[brand]:
		return "image/heic", true
	case avifBrands[brand]:
		return "image/avif", true
	case strings.HasPrefix(brand, "isom"), strings.HasPrefix(brand, "iso2"),
		strings.HasPrefix(brand, "mp41"), strings.HasPrefix(brand, "mp42"),
		strings.HasPrefix(brand, "avc1"), strings.HasPrefix(brand, "dash"),
		strings.HasPrefix(brand, "M4V "), strings.HasPrefix(brand, "M4A "),
		strings.HasPrefix(brand, "mmp4"), strings.HasPrefix(brand, "3gp"):
		return "video/mp4", true
	}
	return "", false
}
