package meta

import (
	"fmt"
	"path/filepath"
	"reflect"
	"runtime/debug"
	"strconv"
	"strings"
	"time"

	"github.com/tidwall/gjson"

	"github.com/photoprism/photoprism/pkg/clean"
	"github.com/photoprism/photoprism/pkg/http/header"
	"github.com/photoprism/photoprism/pkg/media"
	"github.com/photoprism/photoprism/pkg/media/projection"
	"github.com/photoprism/photoprism/pkg/media/video"
	"github.com/photoprism/photoprism/pkg/rnd"
	"github.com/photoprism/photoprism/pkg/time/tz"
	"github.com/photoprism/photoprism/pkg/txt"
)

// Common MIME types used to detect video contexts in ExifTool sidecars.
const (
	MimeVideoMp4  = "video/mp4"
	MimeQuicktime = "video/quicktime"
)

// Exiftool parses JSON sidecar data as created by Exiftool.
func (data *Data) Exiftool(jsonData []byte, originalName string) (err error) {
	defer func() {
		if e := recover(); e != nil {
			err = fmt.Errorf("metadata: %s (exiftool panic)\nstack: %s", e, debug.Stack())
		}
	}()

	j := gjson.GetBytes(jsonData, "@flatten|@join")

	logName := "json file"

	if originalName != "" {
		logName = clean.Log(filepath.Base(originalName))
	}

	if !j.IsObject() {
		return fmt.Errorf("metadata: data is not an object in %s (exiftool)", logName)
	}

	data.json = make(map[string]string)
	jsonValues := j.Map()

	for key, val := range jsonValues {
		data.json[key] = val.String()
	}

	if fileName, ok := data.json["FileName"]; ok && fileName != "" && originalName != "" && fileName != originalName {
		return fmt.Errorf("metadata: original name %s does not match %s (exiftool)", clean.Log(originalName), clean.Log(fileName))
	} else if fileName != "" && originalName == "" {
		logName = clean.Log(filepath.Base(fileName))
	}

	v := reflect.ValueOf(data).Elem()

	// Iterate through all config fields
	for i := 0; i < v.NumField(); i++ {
		fieldValue := v.Field(i)

		tagData := v.Type().Field(i).Tag.Get("meta")

		// Automatically assign values to fields with "flag" tag
		if tagData != "" {
			tagValues := strings.Split(tagData, ",")

			var jsonValue gjson.Result
			var tagValue string

			for _, tagValue = range tagValues {
				if r, ok := jsonValues[tagValue]; !ok {
					continue
				} else if txt.Empty(r.String()) {
					continue
				} else {
					jsonValue = r
					break
				}
			}

			// Skip empty values.
			if !jsonValue.Exists() {
				continue
			}

			switch t := fieldValue.Interface().(type) {
			case time.Time:
				if !fieldValue.IsZero() {
					continue
				}

				if dateTime := txt.ParseTime(jsonValue.String(), ""); !dateTime.IsZero() {
					fieldValue.Set(reflect.ValueOf(dateTime))
				}
			case time.Duration:
				if !fieldValue.IsZero() {
					continue
				}

				fieldValue.Set(reflect.ValueOf(Duration(jsonValue.String())))
			case int, int64:
				if !fieldValue.IsZero() {
					continue
				}

				if intVal := jsonValue.Int(); intVal != 0 {
					fieldValue.SetInt(intVal)
				} else if intVal = txt.Int64(jsonValue.String()); intVal != 0 {
					fieldValue.SetInt(intVal)
				}
			case float32, float64:
				if !fieldValue.IsZero() {
					continue
				}

				if f := jsonValue.Float(); f != 0 {
					fieldValue.SetFloat(f)
				} else if f = txt.Float64(jsonValue.String()); f != 0 {
					fieldValue.SetFloat(f)
				}
			case uint, uint64:
				if !fieldValue.IsZero() {
					continue
				}

				if uintVal := jsonValue.Uint(); uintVal > 0 {
					fieldValue.SetUint(uintVal)
				} else if intVal := txt.Int64(jsonValue.String()); intVal > 0 {
					fieldValue.SetUint(uint64(intVal))
				}
			case []string:
				existing := fieldValue.Interface().([]string)
				fieldValue.Set(reflect.ValueOf(txt.AddToWords(existing, SanitizeUnicode(jsonValue.String()))))
			case Keywords:
				existing := fieldValue.Interface().(Keywords)
				fieldValue.Set(reflect.ValueOf(txt.AddToWords(existing, SanitizeUnicode(jsonValue.String()))))
			case projection.Type:
				if !fieldValue.IsZero() {
					continue
				}

				fieldValue.Set(reflect.ValueOf(projection.Type(SanitizeUnicode(jsonValue.String()))))
			case string:
				if !fieldValue.IsZero() {
					continue
				}

				fieldValue.SetString(SanitizeUnicode(jsonValue.String()))
			case bool:
				if !fieldValue.IsZero() {
					continue
				}

				boolVal := false
				strVal := jsonValue.String()

				// Cast string to bool.
				switch strVal {
				case "1", "true":
					boolVal = true
				case "", "0", "false":
					boolVal = false
				default:
					boolVal = txt.NotEmpty(strVal)
				}

				fieldValue.SetBool(boolVal)
			default:
				log.Warnf("metadata: cannot assign value of type %s to %s (exiftool)", t, tagValue)
			}
		}
	}

	// Nanoseconds.
	if data.TakenNs <= 0 {
		for _, name := range exifSubSecTags {
			if s := data.json[name]; txt.IsPosInt(s) {
				data.TakenNs = txt.Int(s + strings.Repeat("0", 9-len(s)))
				break
			}
		}
	}

	// Set latitude and longitude if known and not already set.
	if data.Lat == 0 && data.Lng == 0 {
		if data.GPSPosition != "" {
			lat, lng := GpsToLatLng(data.GPSPosition)
			data.Lat, data.Lng = NormalizeGPS(lat, lng)
		} else if data.GPSLatitude != "" && data.GPSLongitude != "" {
			data.Lat, data.Lng = NormalizeGPS(GpsToDecimal(data.GPSLatitude), GpsToDecimal(data.GPSLongitude))
		}
	}

	if data.Altitude == 0 {
		// Parseable floating point number?
		if fl := GpsFloatRegexp.FindAllString(data.json["GPSAltitude"], -1); len(fl) != 1 {
			// Ignore.
		} else if alt, err := strconv.ParseFloat(fl[0], 64); err == nil && alt != 0 {
			data.Altitude = alt
		}
	}

	hasTimeOffset := false

	// Has Media Create Date?
	if !data.CreatedAt.IsZero() {
		data.TakenAt = data.CreatedAt
	}

	// Fallback to GPS UTC Time?
	if data.TakenAt.IsZero() && data.TakenAtLocal.IsZero() && !data.TakenGps.IsZero() {
		data.TimeZone = tz.UTC
		data.TakenAt = data.TakenGps.UTC()
		data.TakenAtLocal = time.Time{}
	}

	// Check plausibility of the local <> UTC time difference.
	if !data.TakenAt.IsZero() && !data.TakenAtLocal.IsZero() {
		if d := data.TakenAt.Sub(data.TakenAtLocal).Abs(); d > time.Hour*27 {
			log.Infof("metadata: %s has an invalid local time offset (%s)", logName, d.String())
			log.Debugf("metadata: %s was taken at %s, local time %s, create time %s, time zone %s", logName, clean.Log(data.TakenAt.UTC().String()), clean.Log(data.TakenAtLocal.String()), clean.Log(data.CreatedAt.String()), clean.Log(data.TimeZone))
			data.TakenAtLocal = data.TakenAt
			data.TakenAt = data.TakenAt.UTC()
		}
	}

	// Has time zone offset?
	if _, offset := data.TakenAtLocal.Zone(); offset != 0 && !data.TakenAtLocal.IsZero() {
		hasTimeOffset = true
	} else if mt, ok := data.json["MIMEType"]; ok && data.TakenAtLocal.IsZero() && (mt == MimeVideoMp4 || mt == MimeQuicktime) {
		// Assume default time zone for MP4 & Quicktime videos is UTC.
		// see https://exiftool.org/TagNames/QuickTime.html
		log.Tracef("metadata: default time zone for %s is UTC (%s)", logName, clean.Log(mt))
		data.TimeZone = tz.UTC
		data.TakenAt = data.TakenAt.UTC()
		data.TakenAtLocal = time.Time{}
	}

	// Set time zone and calculate UTC time.
	if data.Lat != 0 && data.Lng != 0 {
		if zone := tz.Position(data.Lat, data.Lng); zone != "" {
			data.TimeZone = zone
		}

		if loc := tz.Find(data.TimeZone); !data.TakenAtLocal.IsZero() {
			if tl, parseErr := time.ParseInLocation("2006:01:02 15:04:05", data.TakenAtLocal.Format("2006:01:02 15:04:05"), loc); parseErr == nil {
				data.TakenAtLocal = tz.Strip(data.TakenAtLocal)
				data.TakenAt = tl.Truncate(time.Second).UTC()
			} else {
				log.Errorf("metadata: %s (exiftool)", clean.Error(parseErr)) // this should never happen
			}
		} else if !data.TakenAt.IsZero() {
			if localUtc, parseErr := time.ParseInLocation("2006:01:02 15:04:05", data.TakenAt.In(loc).Format("2006:01:02 15:04:05"), time.UTC); parseErr == nil {
				data.TakenAtLocal = localUtc
				data.TakenAt = data.TakenAt.UTC()
			} else {
				log.Errorf("metadata: %s (exiftool)", clean.Error(parseErr)) // this should never happen
			}
		}
	} else if hasTimeOffset {
		if localUtc, parseErr := time.ParseInLocation("2006:01:02 15:04:05", data.TakenAtLocal.Format("2006:01:02 15:04:05"), time.UTC); parseErr == nil {
			data.TakenAtLocal = localUtc.Truncate(time.Second).UTC()
		}

		data.TakenAt = data.TakenAt.Truncate(time.Second).UTC()
	}

	// Set UTC offset as time zone?
	if data.TimeZone != "" && data.TimeZone != tz.Local && data.TimeZone != tz.UTC || data.TakenAt.IsZero() {
		// Don't change existing time zone.
	} else if utcOffset := tz.UtcOffset(data.TakenAt, data.TakenAtLocal, data.TimeOffset); utcOffset != "" {
		data.TimeZone = utcOffset

		if data.TakenAtLocal.IsZero() {
			data.TakenAtLocal = tz.Strip(data.TakenAt)
		}

		data.TakenAt = data.TakenAt.UTC()
		log.Infof("metadata: %s has time offset %s (exiftool)", logName, clean.Log(utcOffset))
	} else if data.TimeOffset != "" {
		log.Infof("metadata: %s has invalid time offset %s (exiftool)", logName, clean.Log(data.TimeOffset))
	}

	// Normalize time zone name.
	data.TimeZone = tz.Name(data.TimeZone)

	// Set local time based on UTC time if empty.
	if data.TakenAtLocal.IsZero() && !data.TakenAt.IsZero() {
		if loc := tz.Find(data.TimeZone); loc.String() == tz.Local {
			data.TakenAtLocal = tz.Strip(data.TakenAt)
			data.TakenAt = data.TakenAt.UTC()
		} else if localUtc, parseErr := time.ParseInLocation("2006:01:02 15:04:05", data.TakenAt.In(loc).Format("2006:01:02 15:04:05"), time.UTC); parseErr == nil {
			data.TakenAtLocal = localUtc
			data.TakenAt = data.TakenAt.UTC()
		} else {
			log.Errorf("metadata: %s (exiftool)", clean.Error(parseErr)) // this should never happen
		}
	}

	// Add nanoseconds to the calculated UTC and local time.
	if data.TakenAt.Nanosecond() == 0 {
		if ns := time.Duration(data.TakenNs); ns > 0 && ns <= time.Second {
			data.TakenAt = data.TakenAt.Truncate(time.Second).UTC().Add(ns)
			data.TakenAtLocal = data.TakenAtLocal.Truncate(time.Second).Add(ns)
		}
	}

	// Use actual image width and height if available, see issue #2447.
	if jsonValues["ImageWidth"].Exists() && jsonValues["ImageHeight"].Exists() {
		if val := jsonValues["ImageWidth"].Int(); val > 0 {
			data.Width = int(val)
		}

		if val := jsonValues["ImageHeight"].Int(); val > 0 {
			data.Height = int(val)
		}
	}

	// Image orientation, see https://www.daveperrett.com/articles/2012/07/28/exif-orientation-handling-is-a-ghetto/.
	if orientation, ok := data.json["Orientation"]; ok && orientation != "" {
		switch orientation {
		case "1", "Horizontal (normal)":
			data.Orientation = 1
		case "2":
			data.Orientation = 2
		case "3", "Rotate 180 CW":
			data.Orientation = 3
		case "4":
			data.Orientation = 4
		case "5":
			data.Orientation = 5
		case "6", "Rotate 90 CW":
			data.Orientation = 6
		case "7":
			data.Orientation = 7
		case "8", "Rotate 270 CW":
			data.Orientation = 8
		}
	}

	if data.Orientation == 0 {
		// Set orientation based on rotation.
		switch data.Rotation {
		case 0:
			data.Orientation = 1
		case -180, 180:
			data.Orientation = 3
		case 90:
			data.Orientation = 6
		case -90, 270:
			data.Orientation = 8
		}
	}

	// Normalize codec name.
	data.Codec = strings.ToLower(data.Codec)
	if strings.Contains(data.Codec, CodecJpeg) { // JPEG Image?
		data.Codec = CodecJpeg
	} else if c, ok := video.Codecs[data.Codec]; ok { // Video codec?
		data.Codec = c
	} else if strings.HasPrefix(data.Codec, "a_") { // Audio codec?
		data.Codec = ""
	}

	// Validate and normalize optional DocumentID.
	if data.DocumentID != "" {
		data.DocumentID = rnd.SanitizeUUID(data.DocumentID)
	}

	// Validate and normalize optional InstanceID.
	if data.InstanceID != "" {
		data.InstanceID = rnd.SanitizeUUID(data.InstanceID)
	}

	if projection.Equirectangular.Equal(data.Projection) {
		data.AddKeywords(KeywordPanorama)
	}

	if data.Caption != "" {
		data.AutoAddKeywords(data.Caption)
		data.Caption = SanitizeCaption(data.Caption)
	}

	data.Title = SanitizeTitle(data.Title)
	data.Subject = SanitizeMeta(data.Subject)
	data.Artist = SanitizeMeta(data.Artist)

	// Ignore numeric model names as they are probably invalid.
	if txt.IsUInt(data.LensModel) {
		data.LensModel = ""
	}

	// Flag Samsung/Google Motion Photos as live media.
	if data.HasVideoEmbedded && (data.MimeType == header.ContentTypeJpeg || data.MimeType == header.ContentTypeHeic) {
		data.MediaType = media.Live
	}

	return nil
}
