// Command devicedock imports a phone's camera folder (DCIM) into a proper,
// dated photo library.
//
// It is the "Pro" member of the PhoneBridge / PocketSync / MovePhone family.
// Those siblings move bytes between a phone folder and a computer; DeviceDock
// is about what the bytes MEAN: it opens each JPEG, parses the EXIF APP1
// segment by hand, and files the photo by its REAL capture date
// (DateTimeOriginal) rather than by the file timestamp - which copying off a
// phone routinely destroys. It is also not PhotoSweep: PhotoSweep finds
// photos that LOOK alike (perceptual hashing); DeviceDock decides "have I
// already imported this?" by exact CONTENT HASH, so a renamed file on the
// phone is still recognised as already imported.
//
// Everything that touches user data is a dry run until --apply, files are
// COPIED and never moved, and nothing is ever deleted. The source folder is
// treated as strictly read-only: it is a phone.
//
// See README.txt for the full write-up, including what this deliberately does
// NOT do (it does not talk to phones over USB/MTP or wirelessly).
package main

import (
	"crypto/sha256"
	"encoding/binary"
	"encoding/hex"
	"encoding/json"
	"flag"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"time"
)

// ---------------------------------------------------------------------------
// CLI plumbing
// ---------------------------------------------------------------------------

func usage() {
	fmt.Fprint(os.Stderr, `DeviceDock - import a phone's camera folder into a dated photo library (EXIF-aware)

Usage:
  devicedock scan <phone-folder> [--json]
  devicedock import <phone-folder> --library <dir> [--layout year/month|year-month|flat] [--apply] [--json]
  devicedock verify --library <dir> [--json]
  devicedock help

scan
  Reports what is on the card: media file count, total size, the date range
  and per-month breakdown taken from each photo's EXIF capture date, and how
  many files have a usable EXIF date versus none at all. Every file is listed
  with the date that was read and where that date came from.

    --json          emit machine-readable JSON instead of text

import
  Copies (never moves) each photo from the phone folder into the library,
  under a path derived from the EXIF DateTimeOriginal tag. When a photo has
  no usable EXIF date, its file modification time is used instead and the
  file is counted as a fallback in the report.

  A photo already in the library is SKIPPED. Identity is decided by SHA-256
  CONTENT HASH, not by filename, so renaming a file on the phone does not
  cause a second copy.

    --library D     destination photo library (required)
    --layout L      year/month (default), year-month, or flat
                      year/month   <lib>/2023/2023-05/IMG_0042.jpg
                      year-month   <lib>/2023-05/IMG_0042.jpg
                      flat         <lib>/20230514-IMG_0042.jpg
    --apply         actually copy files (default: dry run, no changes made)
    --json          emit machine-readable JSON instead of text

  The source folder is never written to, and no file is ever deleted.

verify
  Audits an existing library: photos whose stored location does not match
  their own EXIF capture date (misfiled), and identical content stored under
  more than one name (duplicates).

    --library D     photo library to check (required)
    --json          emit machine-readable JSON instead of text

Examples:
  devicedock scan /Volumes/PHONE/DCIM
  devicedock import /Volumes/PHONE/DCIM --library ~/Photos
  devicedock import /Volumes/PHONE/DCIM --library ~/Photos --layout year-month --apply
  devicedock verify --library ~/Photos --json
`)
}

// reorderFlags works around a stdlib flag package quirk: flag.Parse stops
// scanning for flags at the first positional argument. Subcommands here take
// positional directory arguments that may appear before or between flags, so
// we reorder argv into "all flags, then all positionals" before handing it
// to flag.FlagSet.Parse.
func reorderFlags(args []string, valueFlags map[string]bool) []string {
	var flags, positional []string
	for i := 0; i < len(args); i++ {
		a := args[i]
		name := strings.TrimLeft(a, "-")
		if strings.HasPrefix(a, "-") && valueFlags[name] {
			flags = append(flags, a)
			if i+1 < len(args) {
				i++
				flags = append(flags, args[i])
			}
			continue
		}
		if strings.HasPrefix(a, "-") {
			flags = append(flags, a)
			continue
		}
		positional = append(positional, a)
	}
	return append(flags, positional...)
}

func humanBytes(n int64) string {
	const unit = 1024
	if n < unit {
		return fmt.Sprintf("%d B", n)
	}
	div, exp := int64(unit), 0
	for x := n / unit; x >= unit; x /= unit {
		div *= unit
		exp++
	}
	return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
}

// ---------------------------------------------------------------------------
// EXIF: hand-rolled JPEG APP1 / TIFF IFD parser
// ---------------------------------------------------------------------------
//
// No third-party libraries are used. The layout we walk is:
//
//   JPEG stream      FFD8 (SOI), then a chain of marker segments.
//                    Each non-standalone marker is FF <m> <len hi> <len lo>
//                    followed by len-2 payload bytes. We stop at SOS (FFDA)
//                    or EOI (FFD9) - EXIF always precedes the image data.
//
//   APP1 (FFE1)      payload begins with the ASCII signature "Exif\0\0",
//                    after which a complete little TIFF file begins.
//
//   TIFF header      "II" (Intel, little-endian) or "MM" (Motorola,
//                    big-endian), then the 16-bit magic number 42 in that
//                    byte order, then a 32-bit offset to IFD0. Every offset
//                    inside the TIFF block is relative to the START of this
//                    header, not to the file.
//
//   IFD              uint16 entry count, then that many 12-byte entries
//                    (tag uint16, type uint16, count uint32, value-or-offset
//                    uint32), then a uint32 offset to the next IFD (0 = end).
//                    A value of <= 4 bytes is stored inline in the last 4
//                    bytes of the entry; anything larger is stored at the
//                    offset those bytes hold.
//
//   Tags we want     0x8769 ExifIFDPointer  (IFD0) -> offset of the Exif sub-IFD
//                    0x9003 DateTimeOriginal (Exif sub-IFD) - the real capture time
//                    0x0132 DateTime         (IFD0) - file-change time, our fallback
//                    Both dates are ASCII "YYYY:MM:DD HH:MM:SS\0" (20 bytes).
//
// Robustness rules applied throughout, because this data comes off a phone
// and may be truncated or corrupt:
//   - every read is bounds-checked against the segment slice before it happens;
//   - the entry count is checked against the space actually available;
//   - the next-IFD chain is followed with a visited-offset set AND a hard
//     iteration cap, so a self-referential or cyclic chain cannot loop forever.

const (
	tagDateTime         = 0x0132
	tagExifIFDPointer   = 0x8769
	tagDateTimeOriginal = 0x9003

	maxHeaderScan = 4 << 20 // only the first 4 MiB of a file can hold EXIF
	maxIFDHops    = 16      // hard cap on the IFD chain
	maxIFDEntries = 4096    // sanity cap on entries in a single IFD
)

// exifInfo is what we learned about one file's embedded metadata.
type exifInfo struct {
	HasAPP1   bool      // an "Exif\0\0" APP1 segment was found
	ByteOrder string    // "II", "MM", or "" if no valid TIFF header
	Date      time.Time // zero if no date tag was found
	Tag       string    // "DateTimeOriginal", "DateTime", or ""
	Note      string    // human-readable reason when no date was recovered
}

// HasDate reports whether a real EXIF capture date was recovered.
func (e exifInfo) HasDate() bool { return !e.Date.IsZero() }

// tiff is a bounds-checked view over the TIFF block inside an APP1 segment.
type tiff struct {
	b   []byte
	big bool
}

func (t *tiff) u16(off int) (uint16, bool) {
	if off < 0 || off+2 > len(t.b) {
		return 0, false
	}
	if t.big {
		return binary.BigEndian.Uint16(t.b[off : off+2]), true
	}
	return binary.LittleEndian.Uint16(t.b[off : off+2]), true
}

func (t *tiff) u32(off int) (uint32, bool) {
	if off < 0 || off+4 > len(t.b) {
		return 0, false
	}
	if t.big {
		return binary.BigEndian.Uint32(t.b[off : off+4]), true
	}
	return binary.LittleEndian.Uint32(t.b[off : off+4]), true
}

type ifdEntry struct {
	tag      uint16
	typ      uint16
	count    uint32
	valueOff int // absolute offset within the TIFF block of the entry's 4 value bytes
}

// readIFD parses one IFD at off and returns its entries plus the offset of
// the next IFD (0 when the chain ends). Every access is bounds-checked; a
// malformed IFD yields whatever entries could be read safely, never a panic.
func (t *tiff) readIFD(off int) (entries []ifdEntry, next int, ok bool) {
	n, ok := t.u16(off)
	if !ok {
		return nil, 0, false
	}
	count := int(n)
	if count > maxIFDEntries {
		count = maxIFDEntries
	}
	// Clamp to what the buffer can actually hold, so a bogus count from a
	// corrupt segment cannot make us read past the end.
	if avail := (len(t.b) - off - 2) / 12; count > avail {
		count = avail
	}
	if count < 0 {
		count = 0
	}
	for i := 0; i < count; i++ {
		base := off + 2 + i*12
		tag, ok1 := t.u16(base)
		typ, ok2 := t.u16(base + 2)
		cnt, ok3 := t.u32(base + 4)
		if !ok1 || !ok2 || !ok3 {
			break
		}
		entries = append(entries, ifdEntry{tag: tag, typ: typ, count: cnt, valueOff: base + 8})
	}
	nextOff, ok := t.u32(off + 2 + count*12)
	if !ok {
		return entries, 0, true // truncated tail: entries are still usable
	}
	return entries, int(nextOff), true
}

// typeSize returns the byte size of one element of a TIFF field type.
func typeSize(typ uint16) int {
	switch typ {
	case 1, 2, 6, 7: // BYTE, ASCII, SBYTE, UNDEFINED
		return 1
	case 3, 8: // SHORT, SSHORT
		return 2
	case 4, 9, 11: // LONG, SLONG, FLOAT
		return 4
	case 5, 10, 12: // RATIONAL, SRATIONAL, DOUBLE
		return 8
	}
	return 0
}

// ascii reads an entry's ASCII value, following the inline/offset rule.
func (t *tiff) ascii(e ifdEntry) (string, bool) {
	if e.typ != 2 && e.typ != 7 { // ASCII, or UNDEFINED written by sloppy encoders
		return "", false
	}
	sz := typeSize(e.typ) * int(e.count)
	if sz <= 0 || sz > 4096 {
		return "", false
	}
	start := e.valueOff
	if sz > 4 {
		off, ok := t.u32(e.valueOff)
		if !ok {
			return "", false
		}
		start = int(off)
	}
	if start < 0 || start > len(t.b) || start+sz > len(t.b) {
		return "", false
	}
	s := string(t.b[start : start+sz])
	if i := strings.IndexByte(s, 0); i >= 0 {
		s = s[:i]
	}
	return strings.TrimSpace(s), true
}

// longValue reads an entry's first LONG/SHORT value (used for the sub-IFD pointer).
func (t *tiff) longValue(e ifdEntry) (uint32, bool) {
	switch e.typ {
	case 4, 9:
		return t.u32(e.valueOff)
	case 3, 8:
		v, ok := t.u16(e.valueOff)
		return uint32(v), ok
	}
	return 0, false
}

// parseExifDate parses the EXIF ASCII date format "YYYY:MM:DD HH:MM:SS".
// Some encoders leave blanks or zeroes in an unset field; those are rejected.
func parseExifDate(s string) (time.Time, bool) {
	s = strings.TrimSpace(s)
	if len(s) < 19 {
		return time.Time{}, false
	}
	t, err := time.ParseInLocation("2006:01:02 15:04:05", s[:19], time.Local)
	if err != nil {
		return time.Time{}, false
	}
	if t.Year() < 1900 {
		return time.Time{}, false
	}
	return t, true
}

// extractAPP1Exif walks the JPEG marker chain and returns the TIFF block of
// the first APP1 "Exif\0\0" segment. It never reads past the buffer, and a
// truncated final segment is simply clipped.
func extractAPP1Exif(data []byte) ([]byte, bool) {
	if len(data) < 4 || data[0] != 0xFF || data[1] != 0xD8 {
		return nil, false
	}
	for i := 2; i+2 <= len(data); {
		if data[i] != 0xFF {
			i++ // padding / garbage between segments: resynchronise
			continue
		}
		m := data[i+1]
		switch {
		case m == 0xFF:
			i++ // fill byte
			continue
		case m == 0x01 || m == 0xD8 || (m >= 0xD0 && m <= 0xD7):
			i += 2 // standalone marker, no payload
			continue
		case m == 0xD9 || m == 0xDA:
			return nil, false // EOI or start of scan: EXIF cannot follow
		}
		if i+4 > len(data) {
			return nil, false
		}
		segLen := int(binary.BigEndian.Uint16(data[i+2 : i+4]))
		if segLen < 2 {
			return nil, false
		}
		start := i + 4
		end := i + 2 + segLen
		if end > len(data) {
			end = len(data) // truncated file: use what is there
		}
		if m == 0xE1 && start <= len(data) && end-start >= 6 &&
			string(data[start:start+6]) == "Exif\x00\x00" {
			return data[start+6 : end], true
		}
		i += 2 + segLen
	}
	return nil, false
}

// exifFromBytes parses a whole JPEG buffer and reports the best capture date.
func exifFromBytes(data []byte) exifInfo {
	var info exifInfo
	block, ok := extractAPP1Exif(data)
	if !ok {
		info.Note = "no EXIF APP1 segment"
		return info
	}
	info.HasAPP1 = true
	if len(block) < 8 {
		info.Note = "EXIF segment too short for a TIFF header"
		return info
	}
	t := &tiff{b: block}
	switch {
	case block[0] == 'I' && block[1] == 'I':
		t.big = false
		info.ByteOrder = "II"
	case block[0] == 'M' && block[1] == 'M':
		t.big = true
		info.ByteOrder = "MM"
	default:
		info.Note = "unknown TIFF byte order"
		return info
	}
	if magic, ok := t.u16(2); !ok || magic != 42 {
		info.Note = "bad TIFF magic number"
		return info
	}
	first, ok := t.u32(4)
	if !ok {
		info.Note = "truncated TIFF header"
		return info
	}

	var ifd0Date, exifDate string
	visited := map[int]bool{}
	off := int(first)
	for hops := 0; hops < maxIFDHops; hops++ {
		if off <= 0 || off >= len(block) || visited[off] {
			break // end of chain, out of range, or a cycle
		}
		visited[off] = true
		entries, next, ok := t.readIFD(off)
		if !ok {
			break
		}
		for _, e := range entries {
			switch e.tag {
			case tagDateTime:
				if s, ok := t.ascii(e); ok && ifd0Date == "" {
					ifd0Date = s
				}
			case tagDateTimeOriginal:
				if s, ok := t.ascii(e); ok && exifDate == "" {
					exifDate = s
				}
			case tagExifIFDPointer:
				sub, ok := t.longValue(e)
				if !ok {
					continue
				}
				subOff := int(sub)
				if subOff <= 0 || subOff >= len(block) || visited[subOff] {
					continue
				}
				visited[subOff] = true
				subEntries, _, ok := t.readIFD(subOff)
				if !ok {
					continue
				}
				for _, se := range subEntries {
					switch se.tag {
					case tagDateTimeOriginal:
						if s, ok := t.ascii(se); ok && exifDate == "" {
							exifDate = s
						}
					case tagDateTime:
						if s, ok := t.ascii(se); ok && ifd0Date == "" {
							ifd0Date = s
						}
					}
				}
			}
		}
		off = next
	}

	if d, ok := parseExifDate(exifDate); ok {
		info.Date, info.Tag = d, "DateTimeOriginal"
		return info
	}
	if d, ok := parseExifDate(ifd0Date); ok {
		info.Date, info.Tag = d, "DateTime"
		return info
	}
	info.Note = "EXIF present but no usable date tag"
	return info
}

// readExif reads the head of a file and parses its EXIF metadata.
func readExif(path string) exifInfo {
	f, err := os.Open(path)
	if err != nil {
		return exifInfo{Note: fmt.Sprintf("cannot read: %v", err)}
	}
	defer f.Close()
	data, err := io.ReadAll(io.LimitReader(f, maxHeaderScan))
	if err != nil {
		return exifInfo{Note: fmt.Sprintf("cannot read: %v", err)}
	}
	return exifFromBytes(data)
}

// ---------------------------------------------------------------------------
// Media discovery, hashing, dated paths
// ---------------------------------------------------------------------------

var mediaExts = map[string]bool{
	".jpg": true, ".jpeg": true, ".jpe": true,
	".png": true, ".gif": true, ".tif": true, ".tiff": true,
	".heic": true, ".heif": true, ".webp": true, ".bmp": true,
	".dng": true, ".cr2": true, ".nef": true, ".arw": true,
}

func isMedia(path string) bool {
	return mediaExts[strings.ToLower(filepath.Ext(path))]
}

// mediaFile is one discovered photo plus everything we learned about it.
type mediaFile struct {
	path    string
	rel     string
	size    int64
	mtime   time.Time
	exif    exifInfo
	hash    string // lazily filled; "" until hashFile is called
	hashErr error
}

// date returns the date the file should be filed under, and whether the EXIF
// date had to be substituted with the file's modification time.
func (m mediaFile) date() (time.Time, bool) {
	if m.exif.HasDate() {
		return m.exif.Date, false
	}
	return m.mtime, true
}

func (m mediaFile) dateSource() string {
	if m.exif.HasDate() {
		return "exif:" + m.exif.Tag
	}
	return "mtime (fallback)"
}

// walkMedia lists every media file under root, in stable sorted order, and
// reads each one's EXIF. Non-media files are counted but not returned.
func walkMedia(root string) (files []mediaFile, skippedNonMedia int, warnings []string, err error) {
	absRoot, err := filepath.Abs(root)
	if err != nil {
		return nil, 0, nil, err
	}
	st, err := os.Stat(absRoot)
	if err != nil {
		return nil, 0, nil, err
	}
	if !st.IsDir() {
		return nil, 0, nil, fmt.Errorf("%s is not a directory", absRoot)
	}
	err = filepath.WalkDir(absRoot, func(path string, d os.DirEntry, err error) error {
		if err != nil {
			warnings = append(warnings, fmt.Sprintf("%s: %v", path, err))
			return nil
		}
		if d.IsDir() {
			return nil
		}
		if !d.Type().IsRegular() {
			return nil
		}
		if !isMedia(path) {
			skippedNonMedia++
			return nil
		}
		info, err := d.Info()
		if err != nil {
			warnings = append(warnings, fmt.Sprintf("%s: %v", path, err))
			return nil
		}
		rel, relErr := filepath.Rel(absRoot, path)
		if relErr != nil {
			rel = path
		}
		files = append(files, mediaFile{
			path:  path,
			rel:   filepath.ToSlash(rel),
			size:  info.Size(),
			mtime: info.ModTime(),
			exif:  readExif(path),
		})
		return nil
	})
	sort.Slice(files, func(i, j int) bool { return files[i].rel < files[j].rel })
	return files, skippedNonMedia, warnings, err
}

// hashFile returns the SHA-256 of a file's full contents. This - not the
// filename - is what identity means in DeviceDock.
func hashFile(path string) (string, error) {
	f, err := os.Open(path)
	if err != nil {
		return "", err
	}
	defer f.Close()
	h := sha256.New()
	if _, err := io.Copy(h, f); err != nil {
		return "", err
	}
	return hex.EncodeToString(h.Sum(nil)), nil
}

const (
	layoutYearMonthDir = "year/month"
	layoutYearMonth    = "year-month"
	layoutFlat         = "flat"
)

func validLayout(l string) bool {
	switch l {
	case layoutYearMonthDir, layoutYearMonth, layoutFlat:
		return true
	}
	return false
}

// destRel builds the library-relative path (slash separated) for a photo
// taken at t with the given base filename, under the given layout.
func destRel(layout string, t time.Time, base string) string {
	switch layout {
	case layoutYearMonth:
		return fmt.Sprintf("%04d-%02d/%s", t.Year(), int(t.Month()), base)
	case layoutFlat:
		return fmt.Sprintf("%04d%02d%02d-%s", t.Year(), int(t.Month()), t.Day(), base)
	default: // year/month
		return fmt.Sprintf("%04d/%04d-%02d/%s", t.Year(), t.Year(), int(t.Month()), base)
	}
}

// matchesLayout reports whether relPath is where a photo taken at t belongs,
// under ANY of the supported layouts. verify uses this so that a library
// built with one layout is not flagged wholesale when checked later.
func matchesLayout(relPath string, t time.Time) (bool, string) {
	relPath = filepath.ToSlash(relPath)
	base := relPath[strings.LastIndex(relPath, "/")+1:]
	dir := ""
	if i := strings.LastIndex(relPath, "/"); i >= 0 {
		dir = relPath[:i]
	}
	year := fmt.Sprintf("%04d", t.Year())
	ym := fmt.Sprintf("%04d-%02d", t.Year(), int(t.Month()))
	flatPrefix := fmt.Sprintf("%04d%02d%02d-", t.Year(), int(t.Month()), t.Day())
	switch {
	case dir == year+"/"+ym:
		return true, layoutYearMonthDir
	case dir == ym:
		return true, layoutYearMonth
	case dir == "" && strings.HasPrefix(base, flatPrefix):
		return true, layoutFlat
	}
	return false, ""
}

// expectedFor describes where a file would belong, for error messages.
func expectedFor(t time.Time, base string) string {
	return fmt.Sprintf("%s | %s | %s",
		destRel(layoutYearMonthDir, t, base),
		destRel(layoutYearMonth, t, base),
		destRel(layoutFlat, t, base))
}

// libraryIndex is the content-hash index of an existing library.
type libraryIndex struct {
	byHash map[string][]string // sha256 -> library-relative paths
	files  []mediaFile
	bytes  int64
}

func indexLibrary(lib string) (*libraryIndex, []string, error) {
	idx := &libraryIndex{byHash: map[string][]string{}}
	files, _, warnings, err := walkMedia(lib)
	if err != nil {
		return nil, warnings, err
	}
	for i := range files {
		h, herr := hashFile(files[i].path)
		if herr != nil {
			warnings = append(warnings, fmt.Sprintf("%s: %v", files[i].path, herr))
			files[i].hashErr = herr
			continue
		}
		files[i].hash = h
		idx.byHash[h] = append(idx.byHash[h], files[i].rel)
		idx.bytes += files[i].size
	}
	idx.files = files
	return idx, warnings, nil
}

// copyFile copies src to dst, creating parents. It refuses to overwrite an
// existing file (O_EXCL), so an import can never clobber library content.
// The destination's modification time is set to the photo's own capture date.
func copyFile(src, dst string, stamp time.Time) (int64, error) {
	if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
		return 0, err
	}
	in, err := os.Open(src)
	if err != nil {
		return 0, err
	}
	defer in.Close()
	out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
	if err != nil {
		return 0, err
	}
	n, err := io.Copy(out, in)
	if err != nil {
		out.Close()
		return n, err
	}
	if err := out.Close(); err != nil {
		return n, err
	}
	if !stamp.IsZero() {
		_ = os.Chtimes(dst, stamp, stamp)
	}
	return n, nil
}

// uniqueDest resolves a filename collision between two DIFFERENT photos that
// want the same library path, by inserting -1, -2, ... before the extension.
// taken holds paths already claimed earlier in this same run.
func uniqueDest(lib, rel string, taken map[string]bool) (string, error) {
	try := rel
	ext := filepath.Ext(rel)
	stem := strings.TrimSuffix(rel, ext)
	for i := 0; i < 1000; i++ {
		if i > 0 {
			try = fmt.Sprintf("%s-%d%s", stem, i, ext)
		}
		if taken[try] {
			continue
		}
		if _, err := os.Stat(filepath.Join(lib, filepath.FromSlash(try))); os.IsNotExist(err) {
			return try, nil
		}
	}
	return "", fmt.Errorf("could not find a free name for %s", rel)
}

// ---------------------------------------------------------------------------
// scan command
// ---------------------------------------------------------------------------

type jsonScanFile struct {
	Path       string `json:"path"`
	Size       int64  `json:"size_bytes"`
	Date       string `json:"date"`
	DateSource string `json:"date_source"`
	HasEXIF    bool   `json:"has_exif_segment"`
	ByteOrder  string `json:"exif_byte_order,omitempty"`
	Tag        string `json:"exif_tag,omitempty"`
	Note       string `json:"note,omitempty"`
}

type jsonMonth struct {
	Month    string `json:"month"`
	Files    int    `json:"files"`
	Size     int64  `json:"size_bytes"`
	Fallback int    `json:"mtime_fallback"`
}

type jsonScanResult struct {
	Source          string         `json:"source"`
	MediaFiles      int            `json:"media_files"`
	NonMediaSkipped int            `json:"non_media_skipped"`
	TotalBytes      int64          `json:"total_bytes"`
	TotalHuman      string         `json:"total_human"`
	WithExifDate    int            `json:"with_exif_date"`
	WithoutExifDate int            `json:"without_exif_date"`
	ByTag           map[string]int `json:"by_exif_tag"`
	EarliestExif    string         `json:"earliest_exif_date,omitempty"`
	LatestExif      string         `json:"latest_exif_date,omitempty"`
	Months          []jsonMonth    `json:"months"`
	Files           []jsonScanFile `json:"files"`
	Warnings        []string       `json:"warnings,omitempty"`
}

const dateFmt = "2006-01-02 15:04:05"

func cmdScan(args []string) {
	fs := flag.NewFlagSet("scan", flag.ExitOnError)
	jsonOut := fs.Bool("json", false, "emit JSON instead of text")

	valueFlags := map[string]bool{}
	if err := fs.Parse(reorderFlags(args, valueFlags)); err != nil {
		os.Exit(1)
	}
	rest := fs.Args()
	if len(rest) != 1 {
		fmt.Fprintln(os.Stderr, "error: scan requires exactly one <phone-folder>")
		usage()
		os.Exit(1)
	}

	files, nonMedia, warnings, err := walkMedia(rest[0])
	if err != nil {
		fmt.Fprintf(os.Stderr, "error: %v\n", err)
		os.Exit(1)
	}
	for _, w := range warnings {
		fmt.Fprintf(os.Stderr, "warning: %s\n", w)
	}

	res := jsonScanResult{
		Source:          mustAbs(rest[0]),
		MediaFiles:      len(files),
		NonMediaSkipped: nonMedia,
		ByTag:           map[string]int{},
		Warnings:        warnings,
	}
	type monthAgg struct {
		files, fallback int
		size            int64
	}
	months := map[string]*monthAgg{}
	var earliest, latest time.Time
	for _, f := range files {
		res.TotalBytes += f.size
		if f.exif.HasDate() {
			res.WithExifDate++
			res.ByTag[f.exif.Tag]++
			if earliest.IsZero() || f.exif.Date.Before(earliest) {
				earliest = f.exif.Date
			}
			if latest.IsZero() || f.exif.Date.After(latest) {
				latest = f.exif.Date
			}
		} else {
			res.WithoutExifDate++
		}
		d, fallback := f.date()
		key := fmt.Sprintf("%04d-%02d", d.Year(), int(d.Month()))
		agg := months[key]
		if agg == nil {
			agg = &monthAgg{}
			months[key] = agg
		}
		agg.files++
		agg.size += f.size
		if fallback {
			agg.fallback++
		}
		res.Files = append(res.Files, jsonScanFile{
			Path:       f.rel,
			Size:       f.size,
			Date:       d.Format(dateFmt),
			DateSource: f.dateSource(),
			HasEXIF:    f.exif.HasAPP1,
			ByteOrder:  f.exif.ByteOrder,
			Tag:        f.exif.Tag,
			Note:       f.exif.Note,
		})
	}
	res.TotalHuman = humanBytes(res.TotalBytes)
	if !earliest.IsZero() {
		res.EarliestExif = earliest.Format(dateFmt)
		res.LatestExif = latest.Format(dateFmt)
	}
	keys := make([]string, 0, len(months))
	for k := range months {
		keys = append(keys, k)
	}
	sort.Strings(keys)
	for _, k := range keys {
		res.Months = append(res.Months, jsonMonth{
			Month: k, Files: months[k].files,
			Size: months[k].size, Fallback: months[k].fallback,
		})
	}

	if *jsonOut {
		emitJSON(res)
		return
	}

	fmt.Printf("DeviceDock scan - %s\n\n", res.Source)
	if len(files) == 0 {
		fmt.Println("No media files found.")
		if nonMedia > 0 {
			fmt.Printf("Non-media files skipped: %d\n", nonMedia)
		}
		return
	}
	fmt.Println("Files (date read from each photo's own metadata):")
	for _, f := range res.Files {
		order := f.ByteOrder
		if order == "" {
			order = "--"
		}
		note := ""
		if f.Note != "" {
			note = "  [" + f.Note + "]"
		}
		fmt.Printf("  %-24s  %-19s  %-22s  endian=%s%s\n", f.Path, f.Date, f.DateSource, order, note)
	}
	fmt.Println()
	fmt.Println("Per-month breakdown (EXIF date, or mtime where EXIF has none):")
	for _, m := range res.Months {
		fb := ""
		if m.Fallback > 0 {
			fb = fmt.Sprintf("  (%d by mtime fallback)", m.Fallback)
		}
		fmt.Printf("  %s   %3d files   %10s%s\n", m.Month, m.Files, humanBytes(m.Size), fb)
	}
	fmt.Println()
	fmt.Println("--- Summary ---")
	fmt.Printf("Media files: %d\n", res.MediaFiles)
	fmt.Printf("Total size: %s (%d bytes)\n", res.TotalHuman, res.TotalBytes)
	fmt.Printf("Non-media files skipped: %d\n", res.NonMediaSkipped)
	fmt.Printf("With usable EXIF date: %d\n", res.WithExifDate)
	for _, tag := range []string{"DateTimeOriginal", "DateTime"} {
		if n := res.ByTag[tag]; n > 0 {
			fmt.Printf("  via %s: %d\n", tag, n)
		}
	}
	fmt.Printf("Without usable EXIF date: %d (import will fall back to file mtime)\n", res.WithoutExifDate)
	if res.EarliestExif != "" {
		fmt.Printf("EXIF date range: %s .. %s\n", res.EarliestExif, res.LatestExif)
	} else {
		fmt.Println("EXIF date range: (no file had a usable EXIF date)")
	}
	if len(warnings) > 0 {
		fmt.Printf("Warnings: %d (see stderr)\n", len(warnings))
	}
}

// ---------------------------------------------------------------------------
// import command
// ---------------------------------------------------------------------------

type jsonImportAction struct {
	Source     string `json:"source"`
	Action     string `json:"action"` // copy | skip-already-present | skip-duplicate-in-source | error
	Dest       string `json:"dest,omitempty"`
	ExistingAs string `json:"already_in_library_as,omitempty"`
	Date       string `json:"date"`
	DateSource string `json:"date_source"`
	Fallback   bool   `json:"mtime_fallback"`
	Hash       string `json:"sha256"`
	Size       int64  `json:"size_bytes"`
	Error      string `json:"error,omitempty"`
}

type jsonImportResult struct {
	Source            string             `json:"source"`
	Library           string             `json:"library"`
	Layout            string             `json:"layout"`
	Applied           bool               `json:"applied"`
	DryRun            bool               `json:"dry_run"`
	Considered        int                `json:"media_files_considered"`
	NonMediaSkipped   int                `json:"non_media_skipped"`
	Copied            int                `json:"copied"`
	WouldCopy         int                `json:"would_copy"`
	SkippedPresent    int                `json:"skipped_already_present"`
	SkippedDupInBatch int                `json:"skipped_duplicate_within_source"`
	MtimeFallbacks    int                `json:"mtime_fallbacks"`
	BytesCopied       int64              `json:"bytes_copied"`
	Errors            int                `json:"errors"`
	Actions           []jsonImportAction `json:"actions"`
	Warnings          []string           `json:"warnings,omitempty"`
}

func cmdImport(args []string) {
	fs := flag.NewFlagSet("import", flag.ExitOnError)
	library := fs.String("library", "", "destination photo library (required)")
	layout := fs.String("layout", layoutYearMonthDir, "year/month, year-month, or flat")
	apply := fs.Bool("apply", false, "actually copy files (default: dry run)")
	jsonOut := fs.Bool("json", false, "emit JSON instead of text")

	valueFlags := map[string]bool{"library": true, "layout": true}
	if err := fs.Parse(reorderFlags(args, valueFlags)); err != nil {
		os.Exit(1)
	}
	rest := fs.Args()
	if len(rest) != 1 {
		fmt.Fprintln(os.Stderr, "error: import requires exactly one <phone-folder>")
		usage()
		os.Exit(1)
	}
	if *library == "" {
		fmt.Fprintln(os.Stderr, "error: import requires --library <dir>")
		usage()
		os.Exit(1)
	}
	if !validLayout(*layout) {
		fmt.Fprintf(os.Stderr, "error: unknown --layout %q (want year/month, year-month, or flat)\n", *layout)
		usage()
		os.Exit(1)
	}

	src := mustAbs(rest[0])
	lib := mustAbs(*library)
	if src == lib || strings.HasPrefix(lib+string(os.PathSeparator), src+string(os.PathSeparator)) {
		fmt.Fprintln(os.Stderr, "error: --library must not be inside the phone folder")
		os.Exit(1)
	}

	files, nonMedia, warnings, err := walkMedia(src)
	if err != nil {
		fmt.Fprintf(os.Stderr, "error: %v\n", err)
		os.Exit(1)
	}

	// Index the library by content hash. A library that does not exist yet is
	// simply empty; it is created on --apply.
	idx := &libraryIndex{byHash: map[string][]string{}}
	if st, statErr := os.Stat(lib); statErr == nil && st.IsDir() {
		got, w, ierr := indexLibrary(lib)
		if ierr != nil {
			fmt.Fprintf(os.Stderr, "error: reading library: %v\n", ierr)
			os.Exit(1)
		}
		idx = got
		warnings = append(warnings, w...)
	} else if statErr == nil {
		fmt.Fprintf(os.Stderr, "error: library path %s is not a directory\n", lib)
		os.Exit(1)
	} else if !os.IsNotExist(statErr) {
		fmt.Fprintf(os.Stderr, "error: %v\n", statErr)
		os.Exit(1)
	} else {
		warnings = append(warnings, fmt.Sprintf("library %s does not exist yet; it will be created on --apply", lib))
	}

	res := jsonImportResult{
		Source: src, Library: lib, Layout: *layout,
		Applied: *apply, DryRun: !*apply,
		Considered: len(files), NonMediaSkipped: nonMedia,
	}

	// Hashes claimed during this run: library content plus anything we copy
	// (or would copy) now, so a duplicate inside the source folder is caught
	// too and the dry run reports exactly what --apply would do.
	seen := map[string]string{} // hash -> library-relative path
	for h, paths := range idx.byHash {
		seen[h] = paths[0]
	}
	taken := map[string]bool{}

	for _, f := range files {
		h, herr := hashFile(f.path)
		act := jsonImportAction{Source: f.rel, Size: f.size, Hash: h}
		if herr != nil {
			act.Action, act.Error = "error", herr.Error()
			res.Errors++
			res.Actions = append(res.Actions, act)
			continue
		}
		d, fallback := f.date()
		act.Date = d.Format(dateFmt)
		act.DateSource = f.dateSource()
		act.Fallback = fallback
		if fallback {
			res.MtimeFallbacks++
		}
		if existing, ok := seen[h]; ok {
			act.ExistingAs = existing
			if _, inLib := idx.byHash[h]; inLib {
				act.Action = "skip-already-present"
				res.SkippedPresent++
			} else {
				act.Action = "skip-duplicate-in-source"
				res.SkippedDupInBatch++
			}
			res.Actions = append(res.Actions, act)
			continue
		}

		rel := destRel(*layout, d, filepath.Base(f.rel))
		rel, uerr := uniqueDest(lib, rel, taken)
		if uerr != nil {
			act.Action, act.Error = "error", uerr.Error()
			res.Errors++
			res.Actions = append(res.Actions, act)
			continue
		}
		act.Action, act.Dest = "copy", rel
		taken[rel] = true
		seen[h] = rel

		if *apply {
			n, cerr := copyFile(f.path, filepath.Join(lib, filepath.FromSlash(rel)), d)
			if cerr != nil {
				act.Action, act.Error = "error", cerr.Error()
				res.Errors++
				res.Actions = append(res.Actions, act)
				continue
			}
			res.Copied++
			res.BytesCopied += n
		} else {
			res.WouldCopy++
		}
		res.Actions = append(res.Actions, act)
	}
	res.Warnings = warnings

	if *jsonOut {
		emitJSON(res)
		return
	}

	for _, w := range warnings {
		fmt.Fprintf(os.Stderr, "warning: %s\n", w)
	}
	mode := "DRY RUN (no files will be copied - pass --apply to execute)"
	if *apply {
		mode = "APPLYING (files WILL be copied; source is never modified)"
	}
	fmt.Printf("DeviceDock import - %s\n", mode)
	fmt.Printf("Source:  %s\n", src)
	fmt.Printf("Library: %s   (layout: %s)\n\n", lib, *layout)
	for _, a := range res.Actions {
		switch a.Action {
		case "copy":
			verb := "WOULD COPY"
			if *apply {
				verb = "COPIED    "
			}
			fb := ""
			if a.Fallback {
				fb = "  [mtime fallback]"
			}
			fmt.Printf("  %s %-24s -> %-34s  %s via %s%s\n", verb, a.Source, a.Dest, a.Date, a.DateSource, fb)
		case "skip-already-present":
			fmt.Printf("  SKIP       %-24s    already in library as %s (sha256 %s)\n", a.Source, a.ExistingAs, short(a.Hash))
		case "skip-duplicate-in-source":
			fmt.Printf("  SKIP       %-24s    same content as %s in this batch (sha256 %s)\n", a.Source, a.ExistingAs, short(a.Hash))
		case "error":
			fmt.Printf("  ERROR      %-24s    %s\n", a.Source, a.Error)
		}
	}
	fmt.Println()
	fmt.Println("--- Summary ---")
	fmt.Printf("Media files considered: %d\n", res.Considered)
	fmt.Printf("Non-media files skipped: %d\n", res.NonMediaSkipped)
	if *apply {
		fmt.Printf("Copied into library: %d (%s)\n", res.Copied, humanBytes(res.BytesCopied))
	} else {
		fmt.Printf("Would copy into library: %d\n", res.WouldCopy)
	}
	fmt.Printf("Skipped, already in library (by content hash): %d\n", res.SkippedPresent)
	fmt.Printf("Skipped, duplicate content within source: %d\n", res.SkippedDupInBatch)
	fmt.Printf("Filed by file mtime because EXIF had no date: %d\n", res.MtimeFallbacks)
	if res.Errors > 0 {
		fmt.Printf("Errors: %d\n", res.Errors)
	}
	if !*apply {
		fmt.Println("(dry run: nothing was copied; re-run with --apply to execute this plan)")
	}
}

func short(h string) string {
	if len(h) > 12 {
		return h[:12] + "..."
	}
	return h
}

// ---------------------------------------------------------------------------
// verify command
// ---------------------------------------------------------------------------

type jsonMisfiled struct {
	Path       string `json:"path"`
	Date       string `json:"date"`
	DateSource string `json:"date_source"`
	Expected   string `json:"expected_one_of"`
}

type jsonDupGroup struct {
	Hash  string   `json:"sha256"`
	Size  int64    `json:"size_bytes"`
	Paths []string `json:"paths"`
}

type jsonVerifyResult struct {
	Library       string         `json:"library"`
	FilesChecked  int            `json:"files_checked"`
	TotalBytes    int64          `json:"total_bytes"`
	WithExifDate  int            `json:"with_exif_date"`
	NoExifDate    int            `json:"without_exif_date"`
	Misfiled      []jsonMisfiled `json:"misfiled"`
	Duplicates    []jsonDupGroup `json:"duplicate_content"`
	WastedBytes   int64          `json:"wasted_bytes_in_duplicates"`
	ProblemsFound int            `json:"problems_found"`
	Warnings      []string       `json:"warnings,omitempty"`
}

func cmdVerify(args []string) {
	fs := flag.NewFlagSet("verify", flag.ExitOnError)
	library := fs.String("library", "", "photo library to check (required)")
	jsonOut := fs.Bool("json", false, "emit JSON instead of text")

	valueFlags := map[string]bool{"library": true}
	if err := fs.Parse(reorderFlags(args, valueFlags)); err != nil {
		os.Exit(1)
	}
	if len(fs.Args()) > 0 {
		fmt.Fprintf(os.Stderr, "error: verify takes no positional arguments (got %q)\n", fs.Args()[0])
		usage()
		os.Exit(1)
	}
	if *library == "" {
		fmt.Fprintln(os.Stderr, "error: verify requires --library <dir>")
		usage()
		os.Exit(1)
	}
	lib := mustAbs(*library)
	idx, warnings, err := indexLibrary(lib)
	if err != nil {
		fmt.Fprintf(os.Stderr, "error: %v\n", err)
		os.Exit(1)
	}

	res := jsonVerifyResult{Library: lib, FilesChecked: len(idx.files), TotalBytes: idx.bytes, Warnings: warnings}
	sizeByHash := map[string]int64{}
	for _, f := range idx.files {
		if f.exif.HasDate() {
			res.WithExifDate++
		} else {
			res.NoExifDate++
		}
		if f.hash != "" {
			sizeByHash[f.hash] = f.size
		}
		d, _ := f.date()
		if ok, _ := matchesLayout(f.rel, d); !ok {
			res.Misfiled = append(res.Misfiled, jsonMisfiled{
				Path:       f.rel,
				Date:       d.Format(dateFmt),
				DateSource: f.dateSource(),
				Expected:   expectedFor(d, filepath.Base(f.rel)),
			})
		}
	}
	hashes := make([]string, 0, len(idx.byHash))
	for h, paths := range idx.byHash {
		if len(paths) > 1 {
			hashes = append(hashes, h)
		}
	}
	sort.Strings(hashes)
	for _, h := range hashes {
		paths := append([]string(nil), idx.byHash[h]...)
		sort.Strings(paths)
		res.Duplicates = append(res.Duplicates, jsonDupGroup{Hash: h, Size: sizeByHash[h], Paths: paths})
		res.WastedBytes += sizeByHash[h] * int64(len(paths)-1)
	}
	res.ProblemsFound = len(res.Misfiled) + len(res.Duplicates)

	if *jsonOut {
		emitJSON(res)
		return
	}
	for _, w := range warnings {
		fmt.Fprintf(os.Stderr, "warning: %s\n", w)
	}
	fmt.Printf("DeviceDock verify - %s\n\n", lib)
	fmt.Printf("Files checked: %d (%s)\n", res.FilesChecked, humanBytes(res.TotalBytes))
	fmt.Printf("With usable EXIF date: %d   without: %d (checked against file mtime)\n\n", res.WithExifDate, res.NoExifDate)

	if len(res.Misfiled) == 0 {
		fmt.Println("Misfiled photos (location does not match own date): none")
	} else {
		fmt.Printf("Misfiled photos (location does not match own date): %d\n", len(res.Misfiled))
		for _, m := range res.Misfiled {
			fmt.Printf("  %s\n", m.Path)
			fmt.Printf("      date %s (%s)\n", m.Date, m.DateSource)
			fmt.Printf("      expected one of: %s\n", m.Expected)
		}
	}
	fmt.Println()
	if len(res.Duplicates) == 0 {
		fmt.Println("Duplicate content stored under more than one name: none")
	} else {
		fmt.Printf("Duplicate content stored under more than one name: %d group(s)\n", len(res.Duplicates))
		for _, g := range res.Duplicates {
			fmt.Printf("  sha256 %s  (%d copies, %s each)\n", short(g.Hash), len(g.Paths), humanBytes(g.Size))
			for _, p := range g.Paths {
				fmt.Printf("      %s\n", p)
			}
		}
		fmt.Printf("  Wasted by duplicates: %s\n", humanBytes(res.WastedBytes))
	}
	fmt.Println()
	fmt.Println("--- Summary ---")
	fmt.Printf("Problems found: %d (%d misfiled, %d duplicate group(s))\n",
		res.ProblemsFound, len(res.Misfiled), len(res.Duplicates))
	fmt.Println("verify never modifies the library; it only reports.")
}

// ---------------------------------------------------------------------------
// main
// ---------------------------------------------------------------------------

func mustAbs(p string) string {
	a, err := filepath.Abs(p)
	if err != nil {
		return p
	}
	return a
}

func emitJSON(v any) {
	enc := json.NewEncoder(os.Stdout)
	enc.SetIndent("", "  ")
	if err := enc.Encode(v); err != nil {
		fmt.Fprintf(os.Stderr, "error: %v\n", err)
		os.Exit(1)
	}
}

func main() {
	if len(os.Args) < 2 {
		// Double-clicked in Explorer rather than run from a prompt: ask the
		// one question the program needs and stay on screen. Printing usage
		// and exiting here is what made the window vanish instantly.
		if interactiveConsole() {
			runGuided()
			return
		}
		usage()
		os.Exit(1)
	}
	switch os.Args[1] {
	case "-h", "--help", "help":
		usage()
		return
	case "scan":
		cmdScan(os.Args[2:])
	case "import":
		cmdImport(os.Args[2:])
	case "verify":
		cmdVerify(os.Args[2:])
	default:
		fmt.Fprintf(os.Stderr, "error: unknown command %q\n", os.Args[1])
		usage()
		os.Exit(1)
	}
}
