package zenity

import (
	"context"
	"fmt"
	"path/filepath"
	"runtime"
	"syscall"
	"unicode/utf16"
	"unsafe"

	"github.com/ncruces/zenity/internal/win"
)

func selectFile(opts options) (string, error) {
	name, _, shown, err := fileOpenDialog(opts, false)
	if shown || opts.ctx != nil && opts.ctx.Err() != nil {
		return name, err
	}
	if opts.directory {
		return browseForFolder(opts)
	}

	var args win.OPENFILENAME
	args.StructSize = uint32(unsafe.Sizeof(args))
	args.Owner, _ = opts.attach.(win.HWND)
	args.Flags = win.OFN_NOCHANGEDIR | win.OFN_FILEMUSTEXIST | win.OFN_EXPLORER

	if opts.title != nil {
		args.Title = strptr(*opts.title)
	}
	if opts.showHidden {
		args.Flags |= win.OFN_FORCESHOWHIDDEN
	}
	if opts.fileFilters != nil {
		args.Filter = initFilters(opts.fileFilters)
	}

	var res [32768]uint16
	args.File = &res[0]
	args.MaxFile = uint32(len(res))
	args.InitialDir, args.DefExt = initDirNameExt(opts.filename, res[:])

	defer setup(args.Owner)()
	unhook, err := hookDialog(opts.ctx, opts.windowIcon, nil, nil)
	if err != nil {
		return "", err
	}
	defer unhook()

	ok := win.GetOpenFileName(&args)
	if opts.ctx != nil && opts.ctx.Err() != nil {
		return "", opts.ctx.Err()
	}
	if !ok {
		return "", win.CommDlgError()
	}
	return syscall.UTF16ToString(res[:]), nil
}

func selectFileMultiple(opts options) ([]string, error) {
	_, list, shown, err := fileOpenDialog(opts, true)
	if shown || opts.ctx != nil && opts.ctx.Err() != nil {
		return list, err
	}
	if opts.directory {
		return nil, fmt.Errorf("%w: multiple directory", ErrUnsupported)
	}

	var args win.OPENFILENAME
	args.StructSize = uint32(unsafe.Sizeof(args))
	args.Owner, _ = opts.attach.(win.HWND)
	args.Flags = win.OFN_NOCHANGEDIR | win.OFN_ALLOWMULTISELECT | win.OFN_FILEMUSTEXIST | win.OFN_EXPLORER

	if opts.title != nil {
		args.Title = strptr(*opts.title)
	}
	if opts.showHidden {
		args.Flags |= win.OFN_FORCESHOWHIDDEN
	}
	if opts.fileFilters != nil {
		args.Filter = initFilters(opts.fileFilters)
	}

	var res [32768 + 1024*256]uint16
	args.File = &res[0]
	args.MaxFile = uint32(len(res))
	args.InitialDir, args.DefExt = initDirNameExt(opts.filename, res[:])

	defer setup(args.Owner)()
	unhook, err := hookDialog(opts.ctx, opts.windowIcon, nil, nil)
	if err != nil {
		return nil, err
	}
	defer unhook()

	ok := win.GetOpenFileName(&args)
	if opts.ctx != nil && opts.ctx.Err() != nil {
		return nil, opts.ctx.Err()
	}
	if !ok {
		return nil, win.CommDlgError()
	}

	var i int
	var nul bool
	var split []string
	for j, p := range res {
		if p == 0 {
			if nul {
				break
			}
			if i < j {
				split = append(split, string(utf16.Decode(res[i:j])))
			}
			i = j + 1
			nul = true
		} else {
			nul = false
		}
	}
	if len := len(split) - 1; len > 0 {
		base := split[0]
		for i := 0; i < len; i++ {
			split[i] = filepath.Join(base, string(split[i+1]))
		}
		split = split[:len]
	}
	return split, nil
}

func selectFileSave(opts options) (string, error) {
	if opts.directory {
		return selectFile(opts)
	}
	name, shown, err := fileSaveDialog(opts)
	if shown || opts.ctx != nil && opts.ctx.Err() != nil {
		return name, err
	}

	var args win.OPENFILENAME
	args.StructSize = uint32(unsafe.Sizeof(args))
	args.Owner, _ = opts.attach.(win.HWND)
	args.Flags = win.OFN_NOCHANGEDIR | win.OFN_PATHMUSTEXIST | win.OFN_NOREADONLYRETURN | win.OFN_EXPLORER

	if opts.title != nil {
		args.Title = strptr(*opts.title)
	}
	if opts.confirmOverwrite {
		args.Flags |= win.OFN_OVERWRITEPROMPT
	}
	if opts.confirmCreate {
		args.Flags |= win.OFN_CREATEPROMPT
	}
	if opts.showHidden {
		args.Flags |= win.OFN_FORCESHOWHIDDEN
	}
	if opts.fileFilters != nil {
		args.Filter = initFilters(opts.fileFilters)
	}

	var res [32768]uint16
	args.File = &res[0]
	args.MaxFile = uint32(len(res))
	args.InitialDir, args.DefExt = initDirNameExt(opts.filename, res[:])

	defer setup(args.Owner)()
	unhook, err := hookDialog(opts.ctx, opts.windowIcon, nil, nil)
	if err != nil {
		return "", err
	}
	defer unhook()

	ok := win.GetSaveFileName(&args)
	if opts.ctx != nil && opts.ctx.Err() != nil {
		return "", opts.ctx.Err()
	}
	if !ok {
		return "", win.CommDlgError()
	}
	return syscall.UTF16ToString(res[:]), nil
}

func fileOpenDialog(opts options, multi bool) (string, []string, bool, error) {
	uninit, err := coInitialize()
	if err != nil {
		return "", nil, false, err
	}
	defer uninit()

	owner, _ := opts.attach.(win.HWND)
	defer setup(owner)()

	var dialog *win.IFileOpenDialog
	err = win.CoCreateInstance(
		win.CLSID_FileOpenDialog, nil, win.CLSCTX_ALL,
		win.IID_IFileOpenDialog, unsafe.Pointer(&dialog))
	if err != nil {
		return "", nil, false, err
	}
	defer dialog.Release()

	flgs, err := dialog.GetOptions()
	if err != nil {
		return "", nil, false, err
	}
	flgs |= win.FOS_NOCHANGEDIR | win.FOS_FILEMUSTEXIST | win.FOS_FORCEFILESYSTEM
	if multi {
		flgs |= win.FOS_ALLOWMULTISELECT
	}
	if opts.directory {
		flgs |= win.FOS_PICKFOLDERS
	}
	if opts.showHidden {
		flgs |= win.FOS_FORCESHOWHIDDEN
	}
	err = dialog.SetOptions(flgs)
	if err != nil {
		return "", nil, false, err
	}

	if opts.title != nil {
		dialog.SetTitle(strptr(*opts.title))
	}
	if opts.fileFilters != nil {
		dialog.SetFileTypes(initFileTypes(opts.fileFilters))
	}

	if opts.filename != "" {
		var item *win.IShellItem
		dir, name, _ := splitDirAndName(opts.filename)
		dialog.SetFileName(strptr(name))
		if ext := filepath.Ext(name); len(ext) > 1 {
			dialog.SetDefaultExtension(strptr(ext[1:]))
		}
		win.SHCreateItemFromParsingName(strptr(dir), nil, win.IID_IShellItem, &item)
		if item != nil {
			defer item.Release()
			dialog.SetFolder(item)
		}
	}

	unhook, err := hookDialog(opts.ctx, opts.windowIcon, nil, nil)
	if err != nil {
		return "", nil, false, err
	}
	defer unhook()

	if opts.ctx != nil && opts.ctx.Done() != nil {
		wait := make(chan struct{})
		defer close(wait)
		go func() {
			select {
			case <-opts.ctx.Done():
				dialog.Close(win.E_TIMEOUT)
			case <-wait:
			}
		}()
	}

	err = dialog.Show(owner)
	if opts.ctx != nil && opts.ctx.Err() != nil {
		return "", nil, true, opts.ctx.Err()
	}
	if err == win.E_CANCELED {
		return "", nil, true, ErrCanceled
	}
	if err != nil {
		return "", nil, true, err
	}

	if multi {
		items, err := dialog.GetResults()
		if err != nil {
			return "", nil, true, err
		}
		defer items.Release()

		count, err := items.GetCount()
		if err != nil {
			return "", nil, true, err
		}

		var lst []string
		for i := uint32(0); i < count; i++ {
			str, err := shellItemPath(items.GetItemAt(i))
			if err != nil {
				return "", nil, true, err
			}
			lst = append(lst, str)
		}
		return "", lst, true, nil
	} else {
		str, err := shellItemPath(dialog.GetResult())
		if err != nil {
			return "", nil, true, err
		}
		return str, nil, true, nil
	}
}

func fileSaveDialog(opts options) (string, bool, error) {
	uninit, err := coInitialize()
	if err != nil {
		return "", false, err
	}
	defer uninit()

	owner, _ := opts.attach.(win.HWND)
	defer setup(owner)()

	var dialog *win.IFileSaveDialog
	err = win.CoCreateInstance(
		win.CLSID_FileSaveDialog, nil, win.CLSCTX_ALL,
		win.IID_IFileSaveDialog, unsafe.Pointer(&dialog))
	if err != nil {
		return "", false, err
	}
	defer dialog.Release()

	flgs, err := dialog.GetOptions()
	if err != nil {
		return "", false, err
	}
	flgs |= win.FOS_NOCHANGEDIR | win.FOS_PATHMUSTEXIST | win.FOS_NOREADONLYRETURN | win.FOS_FORCEFILESYSTEM
	if opts.confirmOverwrite {
		flgs |= win.FOS_OVERWRITEPROMPT
	}
	if opts.confirmCreate {
		flgs |= win.FOS_CREATEPROMPT
	}
	if opts.showHidden {
		flgs |= win.FOS_FORCESHOWHIDDEN
	}
	err = dialog.SetOptions(flgs)
	if err != nil {
		return "", false, err
	}

	if opts.title != nil {
		dialog.SetTitle(strptr(*opts.title))
	}
	if opts.fileFilters != nil {
		dialog.SetFileTypes(initFileTypes(opts.fileFilters))
	}

	if opts.filename != "" {
		var item *win.IShellItem
		dir, name, _ := splitDirAndName(opts.filename)
		dialog.SetFileName(strptr(name))
		if ext := filepath.Ext(name); len(ext) > 1 {
			dialog.SetDefaultExtension(strptr(ext[1:]))
		}
		win.SHCreateItemFromParsingName(strptr(dir), nil, win.IID_IShellItem, &item)
		if item != nil {
			defer item.Release()
			dialog.SetFolder(item)
		}
	}

	unhook, err := hookDialog(opts.ctx, opts.windowIcon, nil, nil)
	if err != nil {
		return "", false, err
	}
	defer unhook()

	if opts.ctx != nil && opts.ctx.Done() != nil {
		wait := make(chan struct{})
		defer close(wait)
		go func() {
			select {
			case <-opts.ctx.Done():
				dialog.Close(win.E_TIMEOUT)
			case <-wait:
			}
		}()
	}

	err = dialog.Show(owner)
	if opts.ctx != nil && opts.ctx.Err() != nil {
		return "", true, opts.ctx.Err()
	}
	if err == win.E_CANCELED {
		return "", true, ErrCanceled
	}
	if err != nil {
		return "", true, err
	}

	str, err := shellItemPath(dialog.GetResult())
	if err != nil {
		return "", true, err
	}
	return str, true, nil
}

func shellItemPath(item *win.IShellItem, err error) (string, error) {
	if err != nil {
		return "", err
	}
	defer item.Release()
	return item.GetDisplayName(win.SIGDN_FILESYSPATH)
}

func browseForFolder(opts options) (string, error) {
	uninit, err := coInitialize()
	if err != nil {
		return "", err
	}
	defer uninit()

	var args win.BROWSEINFO
	args.Owner, _ = opts.attach.(win.HWND)
	args.Flags = win.BIF_RETURNONLYFSDIRS

	if opts.title != nil {
		args.Title = strptr(*opts.title)
	}
	if opts.filename != "" {
		args.LParam = strptr(opts.filename)
		args.CallbackFunc = syscall.NewCallback(browseForFolderCallback)
	}

	unhook, err := hookDialog(opts.ctx, opts.windowIcon, nil, nil)
	if err != nil {
		return "", err
	}
	defer unhook()

	ptr := win.SHBrowseForFolder(&args)
	if opts.ctx != nil && opts.ctx.Err() != nil {
		return "", opts.ctx.Err()
	}
	if ptr == nil {
		return "", ErrCanceled
	}
	defer win.CoTaskMemFree(unsafe.Pointer(ptr))

	var res [32768]uint16
	win.SHGetPathFromIDListEx(ptr, &res[0], len(res), 0)

	str := syscall.UTF16ToString(res[:])
	return str, nil
}

func browseForFolderCallback(wnd win.HWND, msg uint32, lparam, data uintptr) uintptr {
	if msg == win.BFFM_INITIALIZED {
		win.SendMessage(wnd, win.BFFM_SETSELECTION, 1, data)
	}
	return 0
}

func coInitialize() (context.CancelFunc, error) {
	runtime.LockOSThread()
	// .NET uses MTA for all background threads, so do the same.
	// If someone needs STA because they're doing UI,
	// they should initialize COM themselves before.
	err := win.CoInitializeEx(0, win.COINIT_MULTITHREADED|win.COINIT_DISABLE_OLE1DDE)
	if err == win.S_FALSE {
		// COM was already initialized, we simply increased the ref count.
		// Make this a no-op by decreasing our ref count.
		win.CoUninitialize()
		return runtime.UnlockOSThread, nil
	}
	// Don't uninitialize COM; this is against the docs, but it's what .NET does.
	// Eventually all threads will have COM initialized.
	if err == nil || err == win.RPC_E_CHANGED_MODE {
		return runtime.UnlockOSThread, nil
	}
	runtime.UnlockOSThread()
	return nil, err
}

func initDirNameExt(filename string, name []uint16) (dir *uint16, ext *uint16) {
	d, n, _ := splitDirAndName(filename)
	e := filepath.Ext(n)
	if n != "" {
		copy(name, syscall.StringToUTF16(n))
	}
	if d != "" {
		dir = syscall.StringToUTF16Ptr(d)
	}
	if len(e) > 1 {
		ext = syscall.StringToUTF16Ptr(e[1:])
	}
	return
}

func initFilters(filters FileFilters) *uint16 {
	filters.simplify()
	filters.name()
	var res []uint16
	for _, f := range filters {
		if len(f.Patterns) == 0 {
			continue
		}
		res = append(res, syscall.StringToUTF16(f.Name)...)
		for _, p := range f.Patterns {
			res = append(res, syscall.StringToUTF16(p)...)
			res[len(res)-1] = ';'
		}
		res[len(res)-1] = 0
	}
	if res != nil {
		res = append(res, 0)
		return &res[0]
	}
	return nil
}

func initFileTypes(filters FileFilters) (int, *win.COMDLG_FILTERSPEC) {
	filters.simplify()
	filters.name()
	var res []win.COMDLG_FILTERSPEC
	for _, f := range filters {
		if len(f.Patterns) == 0 {
			continue
		}
		var spec []uint16
		for _, p := range f.Patterns {
			spec = append(spec, syscall.StringToUTF16(p)...)
			spec[len(spec)-1] = ';'
		}
		spec[len(spec)-1] = 0
		res = append(res, win.COMDLG_FILTERSPEC{
			Name: syscall.StringToUTF16Ptr(f.Name),
			Spec: &spec[0],
		})
	}
	return len(res), &res[0]
}
