package attachment

import (
	"fmt"
	"io"
	"mime"
	"os"
	"path/filepath"
	"strings"

	"github.com/diamondburned/cchat"
	"github.com/diamondburned/cchat-gtk/internal/log"
	"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
	"github.com/diamondburned/cchat-gtk/internal/ui/primitives/roundimage"
	"github.com/gotk3/gotk3/cairo"
	"github.com/gotk3/gotk3/gdk"
	"github.com/gotk3/gotk3/gtk"
	"github.com/pkg/errors"
)

const (
	ThumbSize = 72
	IconSize  = 56
)

// File represents a middle format that can be used to create a
// MessageAttachment.
type File struct {
	Prog *Progress
	Name string
	Size int64 // -1 = stream
}

// NewFile creates a new attachment file with a progress state.
func NewFile(name string, size int64, open Open) File {
	return File{
		Prog: NewProgress(NewReusableReader(open), size),
		Name: name,
		Size: size,
	}
}

// AsAttachment turns File into a MessageAttachment. This method will always
// make a new MessageAttachment and will never return an old one.
//
// The reason being MessageAttachment should never be reused, as it hides the
// fact that the io.Reader is reusable.
func (f *File) AsAttachment() cchat.MessageAttachment {
	return cchat.MessageAttachment{
		Name:   f.Name,
		Reader: f.Prog,
	}
}

type Container struct {
	*gtk.Revealer
	Scroll *gtk.ScrolledWindow
	Box    *gtk.Box

	enabled bool

	// states
	files []File
	items map[string]primitives.WidgetDestroyer
}

var attachmentsCSS = primitives.PrepareCSS(`
	.attachments { padding: 5px; padding-bottom: 0 }
`)

func New() *Container {
	box, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 5)
	box.Show()

	primitives.AddClass(box, "attachments")
	primitives.AttachCSS(box, attachmentsCSS)

	scr, _ := gtk.ScrolledWindowNew(nil, nil)
	scr.SetPolicy(gtk.POLICY_EXTERNAL, gtk.POLICY_NEVER)
	scr.SetProperty("kinetic-scrolling", false)
	scr.Add(box)
	scr.Show()

	// Scroll left/right when the wheel goes up/down.
	scr.Connect("scroll-event", func(s *gtk.ScrolledWindow, ev *gdk.Event) bool {
		// Magic thing I found out while print-debugging. DeltaY shows the same
		// offset whether you scroll with Shift or not, which makes sense.
		// DeltaX is always 0.

		var adj = scr.GetHAdjustment()

		switch ev := gdk.EventScrollNewFromEvent(ev); ev.DeltaY() {
		case 1:
			adj.SetValue(adj.GetValue() + adj.GetStepIncrement())
		case -1:
			adj.SetValue(adj.GetValue() - adj.GetStepIncrement())
		default:
			// Not handled.
			return false
		}

		// Handled.
		return true
	})

	rev, _ := gtk.RevealerNew()
	rev.SetRevealChild(false)
	rev.Add(scr)

	return &Container{
		Revealer: rev,
		Scroll:   scr,
		Box:      box,
		items:    map[string]primitives.WidgetDestroyer{},
	}
}

// SetMarginStart sets the inner margin of the attachments carousel.
func (c *Container) SetMarginStart(margin int) {
	c.Box.SetMarginStart(margin)
}

// Enabled returns whether or not the container allows attachments.
func (c *Container) Enabled() bool {
	return c.enabled
}

func (c *Container) SetEnabled(enabled bool) {
	// Set the enabled state; reset the container if we're disabling the
	// attachment box.
	if c.enabled = enabled; !enabled {
		c.Reset()
	}
}

// Files returns the list of attachments
func (c *Container) Files() []File {
	return c.files
}

// Reset does NOT close files.
func (c *Container) Reset() {
	// Reset states. We do not touch the old files slice, as other callers may
	// be referencing and using it.
	c.files = nil

	// Clear all items.
	for _, item := range c.items {
		item.Destroy()
	}

	// Reset the map.
	c.items = map[string]primitives.WidgetDestroyer{}

	// Hide the window.
	c.SetRevealChild(false)
}

// AddFiles is used for the file chooser's callback.
func (c *Container) AddFiles(paths []string) {
	for _, path := range paths {
		if err := c.AddFile(path); err != nil {
			log.Error(errors.Wrap(err, "Failed to add file"))
		}
	}
}

// AddFile is used for the file picker.
func (c *Container) AddFile(path string) error {
	// Check the file and get the size.
	s, err := os.Stat(path)
	if err != nil {
		return errors.Wrap(err, "Failed to stat file")
	}

	var filename = c.append(
		filepath.Base(path), s.Size(),
		func() (io.ReadCloser, error) { return os.Open(path) },
	)

	scale := c.GetScaleFactor()

	// Maybe try making a preview. A nil image is fine, so we can skip the error
	// check.
	// TODO: add a filesize check
	pixbuf, _ := gdk.PixbufNewFromFileAtScale(path, ThumbSize*scale, ThumbSize*scale, true)
	c.addPreview(filename, thumbnailPixbuf(pixbuf, scale))
	return nil
}

// AddPixbuf is used for adding pixbufs from the clipboard.
func (c *Container) AddPixbuf(pb *gdk.Pixbuf) {
	var filename = c.append(
		fmt.Sprintf("clipboard_%d.png", len(c.files)+1), -1,
		func() (io.ReadCloser, error) {
			r, w := io.Pipe()
			go func() { w.CloseWithError(pb.WritePNG(w, 9)) }()
			return r, nil
		},
	)

	scale := c.GetScaleFactor()

	c.addPreview(filename, thumbnailPixbuf(pb, scale))
	return
}

// -- internal methods --

// append guarantees there's no collision. It returns the unique filename.
func (c *Container) append(name string, sz int64, open Open) string {
	// Show the preview window.
	c.SetRevealChild(true)

	// Guarantee that the filename will never collide.
	for _, file := range c.files {
		if file.Name == name {
			// Hopefully this works? I'm not sure. But this will keep prepending
			// an underscore.
			name = "_" + name
		}
	}

	c.files = append(c.files, NewFile(name, sz, open))
	return name
}

func (c *Container) remove(name string) {
	for i, file := range c.files {
		if file.Name == name {
			c.files = append(c.files[:i], c.files[i+1:]...)
			break
		}
	}

	if w, ok := c.items[name]; ok {
		w.Destroy()
		delete(c.items, name)
	}

	// Collapse the container if there's nothing.
	if len(c.items) == 0 {
		c.SetRevealChild(false)
	}
}

var previewCSS = primitives.PrepareCSS(`
	.attachment-preview {
		box-shadow: none;
		border: none;

		background-color: alpha(@theme_fg_color, 0.15);
		border-radius: 5px;
	}
`)

var deleteAttBtnCSS = primitives.PrepareCSS(`
	.delete-attachment {
		/* Remove styling from the Gtk themes */
		border: none;
		box-shadow: none;

		/* Add our own styling */
		border-radius: 999px 999px;
		transition: linear 100ms all;
		background-color: alpha(@theme_bg_color, 0.75);
	}
	.delete-attachment:hover {
		background-color: alpha(red, 0.5);
	}
`)

func (c *Container) addPreview(name string, thumbnail *cairo.Surface) {
	// Make a fallback image first.
	gimg := roundimage.NewImage(4) // border-radius: 4px
	primitives.SetImageIcon(&gimg.Image, iconFromName(name), IconSize)
	gimg.SetSizeRequest(ThumbSize, ThumbSize)
	gimg.SetVAlign(gtk.ALIGN_CENTER)
	gimg.SetHAlign(gtk.ALIGN_CENTER)
	gimg.SetTooltipText(name)
	gimg.Show()
	primitives.AddClass(gimg, "attachment-preview")
	primitives.AttachCSS(gimg, previewCSS)

	// Determine if we could generate an image preview.
	if thumbnail != nil {
		gimg.SetFromSurface(thumbnail)
	}

	// BLOAT!!! Make an overlay of an event box that, when hovered, will show
	// something that allows closing the image.
	del, _ := gtk.ButtonNewFromIconName("window-close-symbolic", gtk.ICON_SIZE_DIALOG)
	del.SetVAlign(gtk.ALIGN_CENTER)
	del.SetHAlign(gtk.ALIGN_CENTER)
	del.SetTooltipText("Remove " + name)
	del.Connect("clicked", func(del *gtk.Button) { c.remove(name) })
	del.Show()
	primitives.AddClass(del, "delete-attachment")
	primitives.AttachCSS(del, deleteAttBtnCSS)

	ovl, _ := gtk.OverlayNew()
	ovl.SetSizeRequest(ThumbSize, ThumbSize)
	ovl.Add(gimg)
	ovl.AddOverlay(del)
	ovl.Show()

	c.items[name] = ovl
	c.Box.PackStart(ovl, false, false, 0)
}

func thumbnailPixbuf(pixbuf *gdk.Pixbuf, scale int) *cairo.Surface {
	if pixbuf == nil {
		return nil
	}

	var (
		originalWidth  = pixbuf.GetWidth()
		originalHeight = pixbuf.GetHeight()

		scaledThumbSize           = ThumbSize * scale
		scaledWidth, scaledHeight = minsize(originalWidth, originalHeight, scaledThumbSize)

		// offset of src on thumbnail; one of those will be 0
		offsetX = float64(scaledThumbSize-scaledWidth) / 2
		offsetY = float64(scaledThumbSize-scaledHeight) / 2
	)

	thumbnail, err := gdk.PixbufNew(
		pixbuf.GetColorspace(),
		true, 8, // always have alpha, 8bpc
		scaledThumbSize, scaledThumbSize,
	)

	if err != nil {
		panic("failed to allocate upload thumbnail pixbuf: " + err.Error())
	}

	// Fill with transparent pixels.
	thumbnail.Fill(0x0)

	pixbuf.Scale(
		thumbnail,
		int(offsetX), int(offsetY),
		// size of src on thumbnail
		scaledWidth, scaledHeight,
		// no offset on source image
		offsetX, offsetY,
		// scale ratio for both sides
		float64(scaledWidth)/float64(originalWidth),
		float64(scaledHeight)/float64(originalHeight),
		// expensive rescale algorithm
		gdk.INTERP_HYPER,
	)

	surface, err := gdk.CairoSurfaceCreateFromPixbuf(thumbnail, scale, nil)
	if err != nil {
		panic("failed to create thumbnail cairo surface: " + err.Error())
	}

	return surface
}

func iconFromName(filename string) string {
	switch t := mime.TypeByExtension(filepath.Ext(filename)); {
	case strings.HasPrefix(t, "image"):
		return "image-x-generic-symbolic"

	case strings.HasPrefix(t, "audio"):
		return "audio-x-generic-symbolic"

	case strings.HasPrefix(t, "application"):
		return "application-x-appliance-symbolic"

	case strings.HasPrefix(t, "text"):
		fallthrough
	default:
		return "text-x-generic-symbolic"
	}
}

// minsize returns the scaled size so that the largest edge is maxsz.
func minsize(w, h, maxsz int) (int, int) {
	if w > h {
		// return the scaled width as max
		// h*max/w is the same as h/w*max but with more accuracy
		return maxsz, h * maxsz / w
	}

	return w * maxsz / h, maxsz
}

func min(w, h int) int {
	if w > h {
		return h
	}
	return w
}

func max(w, h int) int {
	if w > h {
		return w
	}
	return h
}
