package invoice

import (
	"context"
	"fmt"
	"net/http"
	"sort"
	"strconv"
	"strings"
	"time"

	"github.com/jackc/pgtype"

	"dev.tandem.ws/tandem/camper/pkg/auth"
	"dev.tandem.ws/tandem/camper/pkg/customer"
	"dev.tandem.ws/tandem/camper/pkg/database"
	"dev.tandem.ws/tandem/camper/pkg/form"
	httplib "dev.tandem.ws/tandem/camper/pkg/http"
	"dev.tandem.ws/tandem/camper/pkg/locale"
	"dev.tandem.ws/tandem/camper/pkg/ods"
	"dev.tandem.ws/tandem/camper/pkg/template"
	"dev.tandem.ws/tandem/camper/pkg/uuid"
)

const (
	removedProductSuffix = ".removed"
	defaultPaymentMethod = 1
)

type AdminHandler struct {
}

func NewAdminHandler() *AdminHandler {
	return &AdminHandler{}
}

func (h *AdminHandler) Handler(user *auth.User, company *auth.Company, conn *database.Conn) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		var head string
		head, r.URL.Path = httplib.ShiftPath(r.URL.Path)

		switch head {
		case "":
			switch r.Method {
			case http.MethodGet:
				serveInvoiceIndex(w, r, user, company, conn)
			case http.MethodPost:
				addInvoice(w, r, user, company, conn)
			default:
				httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPost)
			}
		case "batch":
			switch r.Method {
			case http.MethodPost:
				handleBatchAction(w, r, user, company, conn)
			default:
				httplib.MethodNotAllowed(w, r, http.MethodPost)
			}
		case "new":
			switch r.Method {
			case http.MethodGet:
				f := newInvoiceForm(r.Context(), conn, company, user.Locale)
				query := r.URL.Query()
				if invoiceToDuplicate := query.Get("duplicate"); uuid.Valid(invoiceToDuplicate) {
					f.MustFillFromDatabase(r.Context(), conn, user.Locale, invoiceToDuplicate)
					f.Slug = ""
					f.InvoiceStatus.Selected = []string{"created"}
				} else if bookingToInvoice, err := strconv.Atoi(query.Get("booking")); err == nil {
					f.MustFillFromBooking(r.Context(), conn, user.Locale, bookingToInvoice)
				} else if customerSlug := query.Get("customer"); uuid.Valid(customerSlug) {
					if err := f.Customer.FillFromDatabase(r.Context(), conn, customerSlug); err != nil {
						panic(err)
					}
				}
				f.Date.Val = time.Now().Format("2006-01-02")
				f.MustRender(w, r, user, company, conn)
			case http.MethodPost:
				handleInvoiceAction(w, r, user, company, conn, "", "/invoices/new")
			default:
				httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPost)
			}
		case "product-form":
			switch r.Method {
			case http.MethodGet:
				query := r.URL.Query()
				index, _ := strconv.Atoi(query.Get("index"))
				f := newInvoiceProductForm(index, company, user.Locale, mustGetTaxOptions(r.Context(), conn, company))
				productSlug := query.Get("slug")
				if len(productSlug) > 0 {
					if !f.MustFillFromDatabase(r.Context(), conn, productSlug) {
						http.NotFound(w, r)
						return
					}
					quantity, _ := strconv.Atoi(query.Get("product.quantity." + strconv.Itoa(index)))
					if quantity > 0 {
						f.Quantity.Val = strconv.Itoa(quantity)
					}
					httplib.TriggerAfterSettle(w, "recompute")
				}
				template.MustRenderAdminNoLayout(w, r, user, company, "invoice/product-form.gohtml", f)
			default:
				httplib.MethodNotAllowed(w, r, http.MethodGet)
			}
		default:
			h.invoiceHandler(user, company, conn, head).ServeHTTP(w, r)
		}
	})
}
func (h *AdminHandler) invoiceHandler(user *auth.User, company *auth.Company, conn *database.Conn, slug string) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		var head string
		head, r.URL.Path = httplib.ShiftPath(r.URL.Path)

		switch head {
		case "":
			switch r.Method {
			case http.MethodGet:
				serveInvoice(w, r, user, company, conn, slug)
			case http.MethodPut:
				handleUpdateInvoice(w, r, user, company, conn, slug)
			default:
				httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPut)
			}
		case "edit":
			switch r.Method {
			case http.MethodGet:
				serveEditInvoice(w, r, user, company, conn, slug)
			case http.MethodPost:
				handleEditInvoiceAction(w, r, user, company, conn, slug)
			default:
				httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPost)
			}
		default:
			http.NotFound(w, r)
		}
	})
}

type IndexEntry struct {
	ID           int
	Slug         string
	Date         time.Time
	Number       string
	Total        string
	CustomerName string
	Status       string
	StatusLabel  string
}

func serveInvoiceIndex(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
	filters := newInvoiceFilterForm(r.Context(), conn, company, user.Locale)
	if err := filters.Parse(r); err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	page := &invoiceIndex{
		Invoices:        mustCollectInvoiceEntries(r.Context(), conn, user.Locale, filters),
		TotalAmount:     mustComputeInvoicesTotalAmount(r.Context(), conn, filters),
		Filters:         filters,
		InvoiceStatuses: mustCollectInvoiceStatuses(r.Context(), conn, user.Locale),
	}
	page.MustRender(w, r, user, company)
}

type invoiceIndex struct {
	Invoices        []*IndexEntry
	TotalAmount     string
	Filters         *invoiceFilterForm
	InvoiceStatuses map[string]string
}

func (page *invoiceIndex) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
	template.MustRenderAdmin(w, r, user, company, "invoice/index.gohtml", page)
}

func mustCollectInvoiceEntries(ctx context.Context, conn *database.Conn, locale *locale.Locale, filters *invoiceFilterForm) []*IndexEntry {
	where, args := filters.BuildQuery([]interface{}{locale.Language.String()})
	rows, err := conn.Query(ctx, fmt.Sprintf(`
		select invoice_id
		     , invoice.slug
		     , invoice_date
		     , invoice_number
		     , contact.name
		     , invoice.invoice_status
		     , isi18n.name
		     , to_price(total, decimal_digits)
		from invoice
		join contact using (contact_id)
		join invoice_status_i18n isi18n on invoice.invoice_status = isi18n.invoice_status and isi18n.lang_tag = $1
		join invoice_amount using (invoice_id)
		join currency using (currency_code)
		where (%s)
		order by invoice_date desc
		       , invoice_number desc
	`, where), args...)
	if err != nil {
		panic(err)
	}
	defer rows.Close()

	var entries []*IndexEntry
	for rows.Next() {
		entry := &IndexEntry{}
		if err := rows.Scan(&entry.ID, &entry.Slug, &entry.Date, &entry.Number, &entry.CustomerName, &entry.Status, &entry.StatusLabel, &entry.Total); err != nil {
			panic(err)
		}
		entries = append(entries, entry)
	}
	if rows.Err() != nil {
		panic(rows.Err())
	}

	return entries
}

func mustComputeInvoicesTotalAmount(ctx context.Context, conn *database.Conn, filters *invoiceFilterForm) string {
	where, args := filters.BuildQuery(nil)
	text, err := conn.GetText(ctx, fmt.Sprintf(`
		select to_price(sum(total)::integer, decimal_digits)
		from invoice
	    join invoice_amount using (invoice_id)
		join currency using (currency_code)
		where (%s)	
		group by decimal_digits
	`, where), args...)
	if err != nil {
		if database.ErrorIsNotFound(err) {
			return "0.0"
		}
		panic(err)
	}
	return text
}

func mustCollectInvoiceStatuses(ctx context.Context, conn *database.Conn, locale *locale.Locale) map[string]string {
	rows, err := conn.Query(ctx, `
		select invoice_status.invoice_status
		      , isi18n.name
		from invoice_status
			join invoice_status_i18n isi18n using(invoice_status)
		where isi18n.lang_tag = $1
		order by invoice_status
	`, locale.Language)
	if err != nil {
		panic(err)
	}
	defer rows.Close()

	statuses := map[string]string{}
	for rows.Next() {
		var key, name string
		if err := rows.Scan(&key, &name); err != nil {
			panic(err)
		}
		statuses[key] = name
	}
	if rows.Err() != nil {
		panic(rows.Err())
	}

	return statuses
}

type invoiceFilterForm struct {
	locale        *locale.Locale
	company       *auth.Company
	Customer      *form.Select
	InvoiceStatus *form.Select
	InvoiceNumber *form.Input
	FromDate      *form.Input
	ToDate        *form.Input
}

func newInvoiceFilterForm(ctx context.Context, conn *database.Conn, company *auth.Company, locale *locale.Locale) *invoiceFilterForm {
	return &invoiceFilterForm{
		locale:  locale,
		company: company,
		Customer: &form.Select{
			Name:    "customer",
			Options: mustGetContactOptions(ctx, conn, company),
		},
		InvoiceStatus: &form.Select{
			Name:    "invoice_status",
			Options: mustGetInvoiceStatusOptions(ctx, conn, locale),
		},
		InvoiceNumber: &form.Input{
			Name: "number",
		},
		FromDate: &form.Input{
			Name: "from_date",
		},
		ToDate: &form.Input{
			Name: "to_date",
		},
	}
}

func (f *invoiceFilterForm) Parse(r *http.Request) error {
	if err := r.ParseForm(); err != nil {
		return err
	}
	f.Customer.FillValue(r)
	f.InvoiceStatus.FillValue(r)
	f.InvoiceNumber.FillValue(r)
	f.FromDate.FillValue(r)
	f.ToDate.FillValue(r)
	return nil
}

func (f *invoiceFilterForm) BuildQuery(args []interface{}) (string, []interface{}) {
	var where []string
	appendWhere := func(expression string, value interface{}) {
		args = append(args, value)
		where = append(where, fmt.Sprintf(expression, len(args)))
	}
	maybeAppendWhere := func(expression string, value string, conv func(string) interface{}) {
		if value != "" {
			if conv == nil {
				appendWhere(expression, value)
			} else {
				appendWhere(expression, conv(value))
			}
		}
	}

	appendWhere("invoice.company_id = $%d", f.company.ID)
	maybeAppendWhere("contact_id = $%d", f.Customer.String(), func(v string) interface{} {
		customerId, _ := strconv.Atoi(f.Customer.Selected[0])
		return customerId
	})
	maybeAppendWhere("invoice.invoice_status = $%d", f.InvoiceStatus.String(), nil)
	maybeAppendWhere("invoice_number = $%d", f.InvoiceNumber.Val, nil)
	maybeAppendWhere("invoice_date >= $%d", f.FromDate.Val, nil)
	maybeAppendWhere("invoice_date <= $%d", f.ToDate.Val, nil)
	return strings.Join(where, ") AND ("), args
}

func (f *invoiceFilterForm) HasValue() bool {
	return (len(f.Customer.Selected) > 0 && f.Customer.Selected[0] != "") ||
		(len(f.InvoiceStatus.Selected) > 0 && f.InvoiceStatus.Selected[0] != "") ||
		f.InvoiceNumber.Val != "" ||
		f.FromDate.Val != "" ||
		f.ToDate.Val != ""
}

func serveInvoice(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, slug string) {
	pdf := false
	if strings.HasSuffix(slug, ".pdf") {
		pdf = true
		slug = slug[:len(slug)-len(".pdf")]
	}
	if !uuid.Valid(slug) {
		http.NotFound(w, r)
		return
	}
	inv := mustGetInvoice(r.Context(), conn, company, slug)
	if inv == nil {
		http.NotFound(w, r)
		return
	}
	if pdf {
		w.Header().Set("Content-Type", "application/pdf")
		mustWriteInvoicePdf(w, r, user, company, inv)
	} else {
		template.MustRenderAdmin(w, r, user, company, "invoice/view.gohtml", inv)
	}
}

type invoice struct {
	Number              string
	Slug                string
	Date                time.Time
	Invoicer            taxDetails
	Invoicee            taxDetails
	Notes               string
	PaymentInstructions string
	Products            []*invoiceProduct
	Subtotal            string
	Taxes               [][]string
	TaxClasses          []string
	HasDiscounts        bool
	Total               string
	LegalDisclaimer     string
}

type taxDetails struct {
	Name       string
	VATIN      string
	Address    string
	City       string
	PostalCode string
	Province   string
	Email      string
	Phone      string
}

type invoiceProduct struct {
	Name        string
	Description string
	Price       string
	Discount    int
	Quantity    int
	Taxes       map[string]int
	Subtotal    string
	Total       string
}

func mustGetInvoice(ctx context.Context, conn *database.Conn, company *auth.Company, slug string) *invoice {
	inv := &invoice{
		Slug: slug,
	}
	var invoiceId int
	var decimalDigits int
	if err := conn.QueryRow(ctx, `
		select invoice_id
		     , decimal_digits
		     , invoice_number
		     , invoice_date
		     , notes
		     , instructions
		     , contact.name
		     , id_document_number
		     , address
		     , city
		     , province
		     , postal_code
		     , to_price(subtotal, decimal_digits)
		     , to_price(total, decimal_digits)
		from invoice
		    join payment_method using (payment_method_id)
		    join contact using (contact_id)
		    join invoice_amount using (invoice_id)
		    join currency using (currency_code)
		where invoice.slug = $1`, slug).Scan(
		&invoiceId,
		&decimalDigits,
		&inv.Number,
		&inv.Date,
		&inv.Notes,
		&inv.PaymentInstructions,
		&inv.Invoicee.Name,
		&inv.Invoicee.VATIN,
		&inv.Invoicee.Address,
		&inv.Invoicee.City,
		&inv.Invoicee.Province,
		&inv.Invoicee.PostalCode,
		&inv.Subtotal,
		&inv.Total,
	); err != nil {
		if database.ErrorIsNotFound(err) {
			return nil
		}
		panic(err)
	}
	if err := conn.QueryRow(ctx, `
		select business_name
		     , vatin
		     , phone
		     , email
		     , address
		     , city
		     , province
		     , postal_code
		     , legal_disclaimer
		from company
		where company_id = $1
	`, company.ID).Scan(
		&inv.Invoicer.Name,
		&inv.Invoicer.VATIN,
		&inv.Invoicer.Phone,
		&inv.Invoicer.Email,
		&inv.Invoicer.Address,
		&inv.Invoicer.City,
		&inv.Invoicer.Province,
		&inv.Invoicer.PostalCode,
		&inv.LegalDisclaimer); err != nil {
		panic(err)
	}
	if err := conn.QueryRow(ctx, `
		select array_agg(array[name, to_price(amount, $2)])
		from invoice_tax_amount
		    join tax using (tax_id)
		where invoice_id = $1
	`, invoiceId, decimalDigits).Scan(&inv.Taxes); err != nil {
		panic(err)
	}
	rows, err := conn.Query(ctx, `
		select invoice_product.name
		     , description
		     , to_price(price, $2)
		     , (discount_rate * 100)::integer
		     , quantity
		     , to_price(subtotal, $2)
		     , to_price(total, $2)
		     , array_agg(array[tax_class.name, (tax_rate * 100)::integer::text]) filter (where tax_rate is not null)
		from invoice_product
		    join invoice_product_amount using (invoice_product_id)
		    left join invoice_product_tax using (invoice_product_id)
		    left join tax using (tax_id)
		    left join tax_class using (tax_class_id)
		where invoice_id = $1
		group by invoice_product_id
			   , invoice_product.name
		       , description
		       , discount_rate
		       , price
		       , quantity
		       , subtotal
		       , total
		order by invoice_product_id
	`, invoiceId, decimalDigits)
	if err != nil {
		panic(err)
	}
	defer rows.Close()
	taxClasses := map[string]bool{}
	for rows.Next() {
		product := &invoiceProduct{
			Taxes: make(map[string]int),
		}
		var taxes [][]string
		if err := rows.Scan(
			&product.Name,
			&product.Description,
			&product.Price,
			&product.Discount,
			&product.Quantity,
			&product.Subtotal,
			&product.Total,
			&taxes); err != nil {
			panic(err)
		}
		for _, tax := range taxes {
			taxClass := tax[0]
			taxClasses[taxClass] = true
			product.Taxes[taxClass], _ = strconv.Atoi(tax[1])
		}
		if product.Discount > 0 {
			inv.HasDiscounts = true
		}
		inv.Products = append(inv.Products, product)
	}
	for taxClass := range taxClasses {
		inv.TaxClasses = append(inv.TaxClasses, taxClass)
	}
	sort.Strings(inv.TaxClasses)
	if rows.Err() != nil {
		panic(rows.Err())
	}

	return inv
}

func mustRenderNewInvoiceProductsForm(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, action string, form *invoiceForm) {
	page := newInvoiceProductsPage{
		Action:   "/admin/" + action,
		Form:     form,
		Products: mustGetProductChoices(r.Context(), conn, company),
	}
	template.MustRenderAdmin(w, r, user, company, "invoice/products.gohtml", page)
}

func mustGetProductChoices(ctx context.Context, conn *database.Conn, company *auth.Company) []*productChoice {
	rows, err := conn.Query(ctx, "select product.slug, product.name, to_price(price, decimal_digits) from product join company using (company_id) join currency using (currency_code) where company_id = $1 order by name", company.ID)
	if err != nil {
		panic(err)
	}
	defer rows.Close()

	var choices []*productChoice
	for rows.Next() {
		entry := &productChoice{}
		if err := rows.Scan(&entry.Slug, &entry.Name, &entry.Price); err != nil {
			panic(err)
		}
		choices = append(choices, entry)
	}
	if rows.Err() != nil {
		panic(rows.Err())
	}

	return choices
}

type newInvoiceProductsPage struct {
	Action   string
	Form     *invoiceForm
	Products []*productChoice
}

type productChoice struct {
	Slug  string
	Name  string
	Price string
}

func addInvoice(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
	f := newInvoiceForm(r.Context(), conn, company, user.Locale)
	if err := f.Parse(r, conn, user.Locale); err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	if err := user.VerifyCSRFToken(r); err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	if ok, err := f.Validate(r.Context(), conn, user.Locale); err != nil {
		panic(err)
	} else if !ok {
		if !httplib.IsHTMxRequest(r) {
			w.WriteHeader(http.StatusUnprocessableEntity)
		}
		f.MustRender(w, r, user, company, conn)
		return
	}
	tx := conn.MustBegin(r.Context())
	defer tx.Rollback(r.Context())
	customerID, err := f.Customer.UpdateOrCreate(r.Context(), company, tx)
	if err != nil {
		panic(err)
	}
	slug, err := tx.AddInvoice(r.Context(), company.ID, f.Date.Val, customerID, f.Notes.Val, defaultPaymentMethod, newInvoiceProducts(f.Products))
	if err != nil {
		panic(err)
	}
	if bookingID, err := strconv.Atoi(f.BookingID.Val); err == nil {
		if _, err := tx.Exec(r.Context(),
			"insert into booking_invoice (booking_id, invoice_id) select $1, invoice_id from invoice where slug = $2",
			bookingID,
			slug,
		); err != nil {
			panic(err)
		}
		if _, err := tx.Exec(r.Context(),
			"update booking set booking_status = 'invoiced' where booking_id = $1",
			bookingID,
		); err != nil {
			panic(err)
		}
	}
	tx.MustCommit(r.Context())
	httplib.Redirect(w, r, "/admin/invoices/"+slug, http.StatusSeeOther)
}

func newInvoiceProducts(src []*invoiceProductForm) database.NewInvoiceProductArray {
	dst := make(database.NewInvoiceProductArray, 0, len(src))
	for _, p := range src {
		dst = append(dst, p.newInvoiceProduct())
	}
	return dst
}

func editedInvoiceProducts(src []*invoiceProductForm) database.EditedInvoiceProductArray {
	dst := make(database.EditedInvoiceProductArray, 0, len(src))
	for _, p := range src {
		dst = append(dst, p.editedInvoiceProduct())
	}
	return dst
}

func handleBatchAction(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
	if err := r.ParseForm(); err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	if err := user.VerifyCSRFToken(r); err != nil {
		http.Error(w, err.Error(), http.StatusForbidden)
		return
	}
	switch r.Form.Get("action") {
	case "download":
		slugs := r.Form["invoice"]
		if len(slugs) == 0 {
			http.Redirect(w, r, "/admin/invoices", http.StatusSeeOther)
			return
		}
		invoices := mustWriteInvoicesPdf(r, user, company, conn, slugs)
		w.Header().Set("Content-Type", "application/zip")
		w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", user.Locale.Pgettext("invoices.zip", "filename")))
		w.WriteHeader(http.StatusOK)
		if _, err := w.Write(invoices); err != nil {
			panic(err)
		}
	case "export":
		filters := newInvoiceFilterForm(r.Context(), conn, company, user.Locale)
		if err := filters.Parse(r); err != nil {
			http.Error(w, err.Error(), http.StatusBadRequest)
			return
		}
		entries := mustCollectInvoiceEntries(r.Context(), conn, user.Locale, filters)
		taxes := mustCollectInvoiceEntriesTaxes(r.Context(), conn, entries)
		taxColumns := mustCollectTaxColumns(r.Context(), conn, company)
		table := mustWriteInvoicesOds(entries, taxes, taxColumns, company, user.Locale)
		ods.MustWriteResponse(w, table, user.Locale.Pgettext("invoices.ods", "filename"))
	default:
		http.Error(w, user.Locale.Gettext("Invalid action"), http.StatusBadRequest)
	}
}

func mustCollectTaxColumns(ctx context.Context, conn *database.Conn, company *auth.Company) map[int]string {
	rows, err := conn.Query(ctx, `
		select tax_id
		     , name
		from tax
		where company_id = $1
	`, company.ID)
	if err != nil {
		panic(err)
	}
	defer rows.Close()

	columns := make(map[int]string)
	for rows.Next() {
		var taxID int
		var name string
		err = rows.Scan(&taxID, &name)
		if err != nil {
			panic(err)
		}

		columns[taxID] = name
	}
	return columns
}

type taxMap map[int]string

func mustCollectInvoiceEntriesTaxes(ctx context.Context, conn *database.Conn, entries []*IndexEntry) map[int]taxMap {
	ids := mustMakeIDArray(entries, func(entry *IndexEntry) int {
		return entry.ID
	})
	return mustMakeTaxMap(ctx, conn, ids, `
		select invoice_id
		     , tax_id
		     , to_price(amount, decimal_digits)
		from invoice_tax_amount
		    join invoice using (invoice_id)
			join currency using (currency_code)
		where invoice_id = any ($1)	
	`)
}

func mustMakeIDArray[T any](entries []*T, id func(entry *T) int) *pgtype.Int4Array {
	ids := make([]int, len(entries))
	i := 0
	for _, entry := range entries {
		ids[i] = id(entry)
		i++
	}
	idArray := &pgtype.Int4Array{}
	if err := idArray.Set(ids); err != nil {
		panic(err)
	}
	return idArray
}

func mustMakeTaxMap(ctx context.Context, conn *database.Conn, ids *pgtype.Int4Array, sql string) map[int]taxMap {
	rows, err := conn.Query(ctx, sql, ids)
	if err != nil {
		panic(err)
	}
	defer rows.Close()

	taxes := make(map[int]taxMap)
	for rows.Next() {
		var entryID int
		var taxID int
		var amount string
		err := rows.Scan(&entryID, &taxID, &amount)
		if err != nil {
			panic(err)
		}

		entryTaxes := taxes[entryID]
		if entryTaxes == nil {
			entryTaxes = make(taxMap)
			taxes[entryID] = entryTaxes
		}
		entryTaxes[taxID] = amount
	}
	if rows.Err() != nil {
		panic(rows.Err())
	}
	return taxes
}

type invoiceForm struct {
	company        *auth.Company
	Slug           string
	Number         string
	BookingID      *form.Input
	InvoiceStatus  *form.Select
	Customer       *customer.ContactForm
	Date           *form.Input
	Notes          *form.Input
	Products       []*invoiceProductForm
	RemovedProduct *invoiceProductForm
	Subtotal       string
	Taxes          [][]string
	Total          string
}

func newInvoiceForm(ctx context.Context, conn *database.Conn, company *auth.Company, l *locale.Locale) *invoiceForm {
	return &invoiceForm{
		company: company,
		BookingID: &form.Input{
			Name: "booking_id",
		},
		InvoiceStatus: &form.Select{
			Name:     "invoice_status",
			Selected: []string{"created"},
			Options:  mustGetInvoiceStatusOptions(ctx, conn, l),
		},
		Customer: customer.NewContactForm(ctx, conn, l),
		Date: &form.Input{
			Name: "date",
		},
		Notes: &form.Input{
			Name: "notes",
		},
	}
}

func mustGetInvoiceStatusOptions(ctx context.Context, conn *database.Conn, locale *locale.Locale) []*form.Option {
	return form.MustGetOptions(ctx, conn, `
		select invoice_status.invoice_status
		     , isi18n.name 
		from invoice_status
		    join invoice_status_i18n isi18n using(invoice_status)
		where isi18n.lang_tag = $1
		order by invoice_status`, locale.Language)
}

func (f *invoiceForm) Parse(r *http.Request, conn *database.Conn, l *locale.Locale) error {
	if err := r.ParseForm(); err != nil {
		return err
	}
	f.BookingID.FillValue(r)
	f.InvoiceStatus.FillValue(r)
	f.Date.FillValue(r)
	f.Notes.FillValue(r)
	if _, ok := r.Form["product.id.0"]; ok {
		taxOptions := mustGetTaxOptions(r.Context(), conn, f.company)
		for index := 0; true; index++ {
			if _, ok := r.Form["product.id."+strconv.Itoa(index)]; !ok {
				break
			}
			productForm := newInvoiceProductForm(index, f.company, l, taxOptions)
			if err := productForm.Parse(r); err != nil {
				return err
			}
			f.Products = append(f.Products, productForm)
		}
	}
	return f.Customer.Parse(r)
}

func (f *invoiceForm) Validate(ctx context.Context, conn *database.Conn, l *locale.Locale) (bool, error) {
	v := form.NewValidator(l)

	v.CheckSelectedOptions(f.InvoiceStatus, l.GettextNoop("Selected invoice status is not valid."))
	if v.CheckRequired(f.Date, l.GettextNoop("Invoice date can not be empty.")) {
		v.CheckValidDate(f.Date, l.GettextNoop("Invoice date must be a valid date."))
	}

	allOK := v.AllOK
	for _, product := range f.Products {
		allOK = product.Validate(l) && allOK
	}

	if ok, err := f.Customer.Valid(ctx, conn, l); err != nil {
		return false, err
	} else if !ok {
		allOK = false
	}

	return allOK, nil
}

func (f *invoiceForm) Update(l *locale.Locale) {
	products := f.Products
	f.Products = nil
	for n, product := range products {
		if product.Quantity.Val != "0" {
			product.Update(l)
			if n != len(f.Products) {
				product.Index = len(f.Products)
				product.Rename()
			}
			f.Products = append(f.Products, product)
		}
	}
}

func (f *invoiceForm) RemoveProduct(index int) {
	products := f.Products
	f.Products = nil
	for n, product := range products {
		if n == index {
			f.RemovedProduct = product
		} else {
			if n != len(f.Products) {
				product.Index = len(f.Products)
				product.Rename()
			}
			f.Products = append(f.Products, product)
		}
	}
	if f.RemovedProduct != nil {
		f.RemovedProduct.RenameWithSuffix(removedProductSuffix)
	}
}

func (f *invoiceForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
	err := conn.QueryRow(r.Context(), "select subtotal, taxes, total from compute_new_invoice_amount($1, $2)", company.ID, newInvoiceProducts(f.Products)).Scan(&f.Subtotal, &f.Taxes, &f.Total)
	if err != nil {
		panic(err)
	}
	if len(f.Products) == 0 {
		f.Products = append(f.Products, newInvoiceProductForm(0, company, user.Locale, mustGetTaxOptions(r.Context(), conn, company)))
	}
	template.MustRenderAdminFiles(w, r, user, company, f, "invoice/form.gohtml", "invoice/product-form.gohtml", "customer/contact.gohtml")
}

const selectProductBySlug = `
		select ''
		     , product_id::text
		     , name
		     , description
		     , to_price(price, decimal_digits)
		     , '1' as quantity
		     , '0' as discount
		     , array_remove(array_agg(tax_id), null)
		from product
	    join company using (company_id)
		join currency using (currency_code)
		left join product_tax using (product_id)
		where product.slug = any ($1)
		group by product_id
		       , name
		       , description
		       , price
		       , decimal_digits
	`

func (f *invoiceForm) AddProducts(ctx context.Context, conn *database.Conn, l *locale.Locale, productsSlug []string) {
	f.mustAddProductsFromQuery(ctx, conn, l, selectProductBySlug, productsSlug)
}

func (f *invoiceForm) mustAddProductsFromQuery(ctx context.Context, conn *database.Conn, l *locale.Locale, sql string, args ...interface{}) {
	index := len(f.Products)
	taxOptions := mustGetTaxOptions(ctx, conn, f.company)
	rows, err := conn.Query(ctx, sql, args...)
	if err != nil {
		panic(err)
	}
	defer rows.Close()
	for rows.Next() {
		product := newInvoiceProductForm(index, f.company, l, taxOptions)
		if err := rows.Scan(&product.InvoiceProductId.Val, &product.ProductId.Val, &product.Name.Val, &product.Description.Val, &product.Price.Val, &product.Quantity.Val, &product.Discount.Val, &product.Tax.Selected); err != nil {
			panic(err)
		}
		f.Products = append(f.Products, product)
		index++
	}
	if rows.Err() != nil {
		panic(rows.Err())
	}
}

func (f *invoiceForm) InsertProduct(product *invoiceProductForm) {
	replaced := false
	for n, existing := range f.Products {
		if existing.Quantity.Val == "" || existing.Quantity.Val == "0" {
			product.Index = n
			f.Products[n] = product
			replaced = true
			break
		}
	}
	if !replaced {
		product.Index = len(f.Products)
		f.Products = append(f.Products, product)
	}
	product.Rename()
}

func (f *invoiceForm) MustFillFromDatabase(ctx context.Context, conn *database.Conn, l *locale.Locale, slug string) bool {
	var invoiceId int
	var contactSlug string
	selectedInvoiceStatus := f.InvoiceStatus.Selected
	f.InvoiceStatus.Selected = nil
	err := conn.QueryRow(ctx, `
		select invoice_id
		     , array[invoice_status]
		     , contact.slug
		     , invoice_number
		     , invoice_date::text
		     , notes
		from invoice
		join contact using (contact_id)
		where invoice.slug = $1
	`, slug).Scan(&invoiceId, &f.InvoiceStatus.Selected, &contactSlug, &f.Number, &f.Date.Val, &f.Notes.Val)
	if err != nil {
		if database.ErrorIsNotFound(err) {
			f.InvoiceStatus.Selected = selectedInvoiceStatus
			return false
		}
		panic(err)
	}
	f.Slug = slug
	f.Products = []*invoiceProductForm{}
	f.mustAddProductsFromQuery(ctx, conn, l, "select invoice_product_id::text, coalesce(product_id, 0)::text, name, description, to_price(price, $2), quantity::text, (discount_rate * 100)::integer::text, array_remove(array_agg(tax_id::text), null) from invoice_product left join invoice_product_product using (invoice_product_id) left join invoice_product_tax using (invoice_product_id) where invoice_id = $1 group by invoice_product_id, coalesce(product_id, 0), name, description, discount_rate, price, quantity", invoiceId, f.company.DecimalDigits)
	if err := f.Customer.FillFromDatabase(ctx, conn, contactSlug); err != nil {
		panic(err)
	}
	return true
}

func (f *invoiceForm) MustFillFromBooking(ctx context.Context, conn *database.Conn, l *locale.Locale, bookingID int) bool {
	note := l.Gettext("Re: booking #%s of %s–%s")
	dateFormat := l.Pgettext("MM/DD/YYYY", "to_char")
	err := conn.QueryRow(ctx, `
		select format($2, left(slug::text, 10), to_char(lower(stay), $3), to_char(upper(stay), $3))
		from booking
		where booking_id = $1
	`, bookingID, note, dateFormat).Scan(&f.Notes.Val)
	if err != nil {
		if database.ErrorIsNotFound(err) {
			return false
		}
		panic(err)
	}
	f.BookingID.Val = strconv.Itoa(bookingID)
	f.Products = []*invoiceProductForm{}
	f.mustAddProductsFromQuery(ctx, conn, l, `
		select '', '', quantity || ' × ' || product.name, '', to_price(round(price / (1 + rate))::integer, $2), '1', '0', array[tax_id::text] from (
		    select $4 as name, subtotal_nights as price, upper(stay) - lower(stay) as quantity, 2 as tax_id from booking where booking_id = $1
		    union all
		    select $5, subtotal_adults, number_adults, 2 from booking where booking_id = $1
		    union all
		    select $6, subtotal_teenagers, number_teenagers, 2 from booking where booking_id = $1
		    union all
		    select $7, subtotal_children, number_children, 2 from booking where booking_id = $1
		    union all
		    select $8, subtotal_dogs, number_dogs, 2 from booking where booking_id = $1
		    union all
			select coalesce(i18n.name, type_option.name), subtotal, units, 2
			from booking_option
				join campsite_type_option as type_option using (campsite_type_option_id)
				left join campsite_type_option_i18n as i18n on i18n.campsite_type_option_id = type_option.campsite_type_option_id and lang_tag = $3
		    union all
		    select $9, subtotal_tourist_tax, number_adults, 4 from booking where booking_id = $1
		) as product
		join tax using (tax_id)
		where quantity > 0
	`,
		bookingID,
		f.company.DecimalDigits,
		l.Language,
		l.Pgettext("Night", "cart"),
		l.Pgettext("Adults aged 17 or older", "input"),
		l.Pgettext("Teenagers from 11 to 16 years old", "input"),
		l.Pgettext("Children from 2 to 10 years old", "input"),
		l.Pgettext("Dogs", "input"),
		l.Pgettext("Tourist tax", "cart"),
	)
	if err := f.Customer.FillFromBooking(ctx, conn, bookingID); err != nil && !database.ErrorIsNotFound(err) {
		panic(err)
	}
	return true
}

func mustGetTaxOptions(ctx context.Context, conn *database.Conn, company *auth.Company) []*form.Option {
	return form.MustGetOptions(ctx, conn, "select tax_id::text, tax.name from tax where tax.company_id = $1 order by tax.rate desc", company.ID)
}

func mustGetContactOptions(ctx context.Context, conn *database.Conn, company *auth.Company) []*form.Option {
	return form.MustGetOptions(ctx, conn, "select contact_id::text, name from contact where company_id = $1 order by name", company.ID)
}

type invoiceProductForm struct {
	locale           *locale.Locale
	company          *auth.Company
	Index            int
	InvoiceProductId *form.Input
	ProductId        *form.Input
	Name             *form.Input
	Description      *form.Input
	Price            *form.Input
	Quantity         *form.Input
	Discount         *form.Input
	Tax              *form.Select
}

func newInvoiceProductForm(index int, company *auth.Company, locale *locale.Locale, taxOptions []*form.Option) *invoiceProductForm {
	f := &invoiceProductForm{
		locale:           locale,
		company:          company,
		Index:            index,
		InvoiceProductId: &form.Input{},
		ProductId:        &form.Input{},
		Name:             &form.Input{},
		Description:      &form.Input{},
		Price:            &form.Input{},
		Quantity:         &form.Input{},
		Discount:         &form.Input{},
		Tax: &form.Select{
			Options: taxOptions,
		},
	}
	f.Rename()
	return f
}

func (f *invoiceProductForm) Rename() {
	f.RenameWithSuffix("." + strconv.Itoa(f.Index))
}

func (f *invoiceProductForm) RenameWithSuffix(suffix string) {
	f.InvoiceProductId.Name = "product.invoice_product_id" + suffix
	f.ProductId.Name = "product.id" + suffix
	f.Name.Name = "product.name" + suffix
	f.Description.Name = "product.description" + suffix
	f.Price.Name = "product.price" + suffix
	f.Quantity.Name = "product.quantity" + suffix
	f.Discount.Name = "product.discount" + suffix
	f.Tax.Name = "product.tax" + suffix
}

func (f *invoiceProductForm) Parse(r *http.Request) error {
	if err := r.ParseForm(); err != nil {
		return err
	}
	f.InvoiceProductId.FillValue(r)
	f.ProductId.FillValue(r)
	f.Name.FillValue(r)
	f.Description.FillValue(r)
	f.Price.FillValue(r)
	f.Quantity.FillValue(r)
	f.Discount.FillValue(r)
	f.Tax.FillValue(r)
	return nil
}

func (f *invoiceProductForm) Validate(l *locale.Locale) bool {
	v := form.NewValidator(l)
	if f.InvoiceProductId.Val != "" {
		if v.CheckValidInteger(f.InvoiceProductId, l.GettextNoop("Invoice product ID must be an integer.")) {
			v.CheckMinInteger(f.InvoiceProductId, 1, l.GettextNoop("Invoice product ID one or greater."))
		}
	}
	if f.ProductId.Val != "" {
		if v.CheckValidInteger(f.ProductId, l.GettextNoop("Product ID must be an integer.")) {
			v.CheckMinInteger(f.ProductId, 0, l.GettextNoop("Product ID must zero or greater."))
		}
	}
	v.CheckRequired(f.Name, l.GettextNoop("Name can not be empty."))
	if v.CheckRequired(f.Price, l.GettextNoop("Price can not be empty.")) {
		if v.CheckValidDecimal(f.Price, l.GettextNoop("Price must be a decimal number.")) {
			v.CheckMinDecimal(f.Price, 0, l.GettextNoop("Price must be zero or greater."))
		}
	}
	if v.CheckRequired(f.Quantity, l.GettextNoop("Quantity can not be empty.")) {
		if v.CheckValidInteger(f.Quantity, l.GettextNoop("Quantity must be an integer.")) {
			v.CheckMinInteger(f.Quantity, 1, l.GettextNoop("Quantity must one or greater."))
		}
	}
	if v.CheckRequired(f.Discount, l.GettextNoop("Discount can not be empty.")) {
		if v.CheckValidInteger(f.Discount, l.GettextNoop("Discount must be an integer.")) {
			if v.CheckMinInteger(f.Discount, 0, l.GettextNoop("Discount must be a percentage between 0 and 100.")) {
				v.CheckMaxInteger(f.Discount, 100, l.GettextNoop("Discount must be a percentage between 0 and 100."))
			}
		}
	}
	v.CheckSelectedOptions(f.Tax, l.GettextNoop("Selected tax is not valid."))
	return v.AllOK
}

func (f *invoiceProductForm) Update(l *locale.Locale) {
	v := form.NewValidator(l)
	if !v.CheckValidDecimal(f.Price, "") || !v.CheckMinDecimal(f.Price, 0, "") {
		f.Price.Val = "0.0"
		f.Price.Error = nil
	}
	if !v.CheckValidInteger(f.Quantity, "") || !v.CheckMinInteger(f.Quantity, 0, "") {
		f.Quantity.Val = "1"
		f.Quantity.Error = nil
	}
	if !v.CheckValidInteger(f.Discount, "") || !v.CheckMinInteger(f.Discount, 0, "") || !v.CheckMaxInteger(f.Discount, 100, "") {
		f.Discount.Val = "0"
		f.Discount.Error = nil
	}
}

func (f *invoiceProductForm) MustFillFromDatabase(ctx context.Context, conn *database.Conn, slug string) bool {
	err := conn.QueryRow(ctx, selectProductBySlug, []string{slug}).Scan(
		f.InvoiceProductId,
		f.ProductId,
		f.Name,
		f.Description,
		f.Price,
		f.Quantity,
		f.Discount,
		f.Tax)
	if err != nil {
		if database.ErrorIsNotFound(err) {
			return false
		}
		panic(err)
	}
	return true
}

func (f *invoiceProductForm) newInvoiceProduct() *database.NewInvoiceProduct {
	productId := 0
	if f.ProductId.Val != "" {
		productId = f.ProductId.Int()
	}
	var taxes []int
	if len(f.Tax.Selected) > 0 {
		taxes = make([]int, 0, len(f.Tax.Selected))
		for _, t := range f.Tax.Selected {
			id, _ := strconv.Atoi(t)
			taxes = append(taxes, id)
		}
	}
	return &database.NewInvoiceProduct{
		ProductId:   productId,
		Name:        f.Name.Val,
		Description: f.Description.Val,
		Price:       f.Price.Val,
		Quantity:    f.Quantity.Int(),
		Discount:    float64(f.Discount.Int()) / 100.0,
		Taxes:       taxes,
	}
}

func (f *invoiceProductForm) editedInvoiceProduct() *database.EditedInvoiceProduct {
	invoiceProductId := 0
	if f.InvoiceProductId.Val != "" {
		invoiceProductId = f.InvoiceProductId.Int()
	}
	return &database.EditedInvoiceProduct{
		NewInvoiceProduct: f.newInvoiceProduct(),
		InvoiceProductId:  invoiceProductId,
	}
}

func handleUpdateInvoice(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, slug string) {
	if !uuid.Valid(slug) {
		http.NotFound(w, r)
		return
	}
	f := newInvoiceForm(r.Context(), conn, company, user.Locale)
	if err := f.Parse(r, conn, user.Locale); err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	if err := user.VerifyCSRFToken(r); err != nil {
		http.Error(w, err.Error(), http.StatusForbidden)
		return
	}
	if r.FormValue("quick") == "status" {
		slug = conn.MustGetText(r.Context(), "update invoice set invoice_status = $1 where slug = $2 returning slug", f.InvoiceStatus, slug)
		if slug == "" {
			http.NotFound(w, r)
			return
		}
		httplib.Relocate(w, r, "/admin/invoices", http.StatusSeeOther)
	} else {
		if ok, err := f.Validate(r.Context(), conn, user.Locale); err != nil {
			panic(err)
		} else if !ok {
			if !httplib.IsHTMxRequest(r) {
				w.WriteHeader(http.StatusUnprocessableEntity)
			}
			f.MustRender(w, r, user, company, conn)
			return
		}
		tx := conn.MustBegin(r.Context())
		defer tx.Rollback(r.Context())
		customerID, err := f.Customer.UpdateOrCreate(r.Context(), company, tx)
		if err != nil {
			panic(err)
		}
		slug, err = tx.EditInvoice(r.Context(), slug, f.InvoiceStatus.String(), customerID, f.Notes.Val, defaultPaymentMethod, editedInvoiceProducts(f.Products))
		if err != nil {
			panic(err)
		}
		tx.MustCommit(r.Context())
		if slug == "" {
			http.NotFound(w, r)
			return
		}
		httplib.Redirect(w, r, "/admin/invoices/"+slug, http.StatusSeeOther)
	}
}

func serveEditInvoice(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, slug string) {
	if !uuid.Valid(slug) {
		http.NotFound(w, r)
		return
	}
	f := newInvoiceForm(r.Context(), conn, company, user.Locale)
	if !f.MustFillFromDatabase(r.Context(), conn, user.Locale, slug) {
		http.NotFound(w, r)
		return
	}
	f.MustRender(w, r, user, company, conn)
}

func handleEditInvoiceAction(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, slug string) {
	if !uuid.Valid(slug) {
		http.NotFound(w, r)
		return
	}
	actionUri := fmt.Sprintf("/invoices/%s/edit", slug)
	handleInvoiceAction(w, r, user, company, conn, slug, actionUri)
}

func handleInvoiceAction(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, slug string, action string) {
	f := newInvoiceForm(r.Context(), conn, company, user.Locale)
	if err := f.Parse(r, conn, user.Locale); err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	if err := user.VerifyCSRFToken(r); err != nil {
		http.Error(w, err.Error(), http.StatusForbidden)
		return
	}
	f.Slug = slug
	actionField := r.Form.Get("action")
	switch actionField {
	case "update":
		f.Update(user.Locale)
		w.WriteHeader(http.StatusOK)
		f.MustRender(w, r, user, company, conn)
	case "select-products":
		mustRenderNewInvoiceProductsForm(w, r, user, company, conn, action, f)
	case "add-products":
		f.AddProducts(r.Context(), conn, user.Locale, r.Form["slug"])
		f.MustRender(w, r, user, company, conn)
	case "restore-product":
		restoredProduct := newInvoiceProductForm(0, company, user.Locale, mustGetTaxOptions(r.Context(), conn, company))
		restoredProduct.RenameWithSuffix(removedProductSuffix)
		if err := restoredProduct.Parse(r); err != nil {
			panic(err)
		}
		f.InsertProduct(restoredProduct)
		f.Update(user.Locale)
		f.MustRender(w, r, user, company, conn)
	default:
		prefix := "remove-product."
		if strings.HasPrefix(actionField, prefix) {
			index, err := strconv.Atoi(actionField[len(prefix):])
			if err != nil {
				http.Error(w, user.Locale.Gettext("Invalid action"), http.StatusBadRequest)
			} else {
				f.RemoveProduct(index)
				f.Update(user.Locale)
				f.MustRender(w, r, user, company, conn)
			}
		} else {
			http.Error(w, user.Locale.Gettext("Invalid action"), http.StatusBadRequest)
		}
	}
}
