package app

import (
	"context"
	"crypto/subtle"
	"embed"
	"encoding/csv"
	"encoding/json"
	"errors"
	"fmt"
	"html/template"
	"io"
	"log"
	"net/http"
	"net/url"
	"os"
	"path/filepath"
	"strconv"
	"strings"
	"time"
	_ "time/tzdata"
	"unicode/utf8"

	"teammate/internal/jalali"
)

// Embed the complete UI trees so the deployed binary never depends on
// template or static files being present beside it.
//
//go:embed templates static
var assets embed.FS

type Config struct {
	Addr               string
	BaseURL            string
	DatabasePath       string
	SessionSecure      bool
	Timezone           string
	GitHubClientID     string
	GitHubClientSecret string
	GoogleClientID     string
	GoogleClientSecret string
}

func ConfigFromEnv() Config {
	return Config{
		Addr:               env("APP_ADDR", ":8080"),
		BaseURL:            strings.TrimRight(env("APP_BASE_URL", "http://localhost:8080"), "/"),
		DatabasePath:       env("DATABASE_PATH", "./data/teammate.db"),
		SessionSecure:      env("SESSION_SECURE", "false") == "true",
		Timezone:           env("APP_TIMEZONE", "Asia/Tehran"),
		GitHubClientID:     os.Getenv("GITHUB_CLIENT_ID"),
		GitHubClientSecret: os.Getenv("GITHUB_CLIENT_SECRET"),
		GoogleClientID:     os.Getenv("GOOGLE_CLIENT_ID"),
		GoogleClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"),
	}
}

func env(name, fallback string) string {
	if v := os.Getenv(name); v != "" {
		return v
	}
	return fallback
}

type Server struct {
	cfg       Config
	store     *Store
	templates *template.Template
	http      *http.Server
}

type PageData struct {
	Title            string
	User             *User
	CSRF             string
	Flash            string
	Error            string
	Today            string
	TodayJalali      string
	Attendance       *Attendance
	Calendar         Calendar
	Requests         []Request
	PendingCount     int
	Stats            Stats
	OAuthGitHub      bool
	OAuthGoogle      bool
	ReportStart      string
	ReportEnd        string
	SelectedSection  string
	Users            []User
	Workspaces       []Workspace
	Workspace        Workspace
	WorkspaceMembers map[int64]bool
	Day              DayDetail
	Week             WeekDetail
	WorkUpdates      []WorkUpdate
	ReportSummary    []PersonReportSummary
	ReportTotals     ReportTotals
	Board            []BoardColumn
	BoardTags        []BoardTag
	ArchivedTasks    []BoardTask
	BoardFilter      BoardFilter
}

type Stats struct{ Present, Remote, Leave int }

type BoardFilter struct {
	TagID      int64
	AssigneeID int64
	Priority   string
	Active     bool
	Count      int
}

type ReportTotals struct {
	Present int
	Remote  int
	Leave   int
	Done    int
	Blocked int
	Pending int
}

type DayDetail struct {
	Gregorian string
	Jalali    string
	Weekday   string
	DayNote   string
	IsToday   bool
	Prev      string
	Next      string
	Present   int
	Remote    int
	Absent    int
	Members   []RosterMember
}

type WeekDetail struct {
	Enabled     bool
	StartJalali string
	EndJalali   string
	Prev        string
	Next        string
	Present     int
	Remote      int
	Absent      int
	Days        []DayDetail
}

type RosterMember struct {
	User     User
	Status   string
	Label    string
	Detail   string
	CheckIn  string
	CheckOut string
}

type BoardColumn struct {
	Status string
	Title  string
	Hint   string
	Tasks  []BoardTask
}

type Calendar struct {
	Year, Month int
	MonthName   string
	MonthNameFA string
	Prev, Next  string
	Cells       []CalendarCell
}

type CalendarCell struct {
	Day, Weekday int
	Gregorian    string
	InMonth      bool
	IsToday      bool
	IsFriday     bool
	Holiday      string
	Status       string
	StartRequest bool
}

func New(cfg Config) (*Server, error) {
	if cfg.Timezone == "" {
		cfg.Timezone = "Asia/Tehran"
	}
	location, err := time.LoadLocation(cfg.Timezone)
	if err != nil {
		return nil, fmt.Errorf("load application timezone %q: %w", cfg.Timezone, err)
	}
	time.Local = location
	if err := os.MkdirAll(filepath.Dir(cfg.DatabasePath), 0o750); err != nil {
		return nil, err
	}
	store, err := OpenStore(cfg.DatabasePath)
	if err != nil {
		return nil, err
	}
	funcs := template.FuncMap{
		"timeHM": formatStoredClock,
		"dateFA": func(raw string) string {
			t, err := time.Parse("2006-01-02", raw)
			if err != nil {
				return raw
			}
			return jalali.FromTime(t).String()
		},
		"kindLabel": func(v string) string {
			if v == "remote" {
				return "Remote day"
			}
			return "Time off"
		},
		"statusLabel": func(v string) string {
			return strings.ToUpper(v[:1]) + v[1:]
		},
		"initial": func(v string) string {
			r, _ := utf8.DecodeRuneInString(v)
			return string(r)
		},
		"hasTag": func(tags []BoardTag, id int64) bool {
			for _, tag := range tags {
				if tag.ID == id {
					return true
				}
			}
			return false
		},
		"hasUser": func(users []User, id int64) bool {
			for _, user := range users {
				if user.ID == id {
					return true
				}
			}
			return false
		},
	}
	tmpl, err := template.New("root").Funcs(funcs).ParseFS(assets, "templates/*.html")
	if err != nil {
		store.Close()
		return nil, err
	}
	s := &Server{cfg: cfg, store: store, templates: tmpl}
	mux := http.NewServeMux()
	s.routes(mux)
	s.http = &http.Server{
		Addr:              cfg.Addr,
		Handler:           s.securityHeaders(s.withUser(mux)),
		ReadHeaderTimeout: 5 * time.Second,
		ReadTimeout:       15 * time.Second,
		WriteTimeout:      30 * time.Second,
		IdleTimeout:       60 * time.Second,
	}
	return s, nil
}

func (s *Server) ListenAndServe() error {
	err := s.http.ListenAndServe()
	if errors.Is(err, http.ErrServerClosed) {
		return nil
	}
	return err
}

func (s *Server) Close() error { return s.store.Close() }

// staticHandler wraps a file server so font files always get the correct
// Content-Type — Go's http.DetectContentType can return application/octet-stream
// for .ttf files on older Go versions.
func staticHandler(fs http.FileSystem) http.Handler {
	h := http.FileServer(fs)
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		path := strings.TrimPrefix(r.URL.Path, "/static/")
		switch {
		case strings.HasSuffix(path, ".ttf"):
			w.Header().Set("Content-Type", "font/ttf")
		case strings.HasSuffix(path, ".woff"), strings.HasSuffix(path, ".woff2"):
			w.Header().Set("Content-Type", "font/woff2")
		}
		h.ServeHTTP(w, r)
	})
}

func (s *Server) routes(mux *http.ServeMux) {
	fs := http.FS(assets)
	mux.Handle("GET /static/", staticHandler(fs))
	mux.Handle("GET /uploads/avatars/", http.StripPrefix("/uploads/avatars/", http.FileServer(http.Dir(filepath.Join(filepath.Dir(s.cfg.DatabasePath), "avatars")))))
	mux.HandleFunc("GET /healthz", s.health)
	mux.HandleFunc("GET /login", s.loginPage)
	mux.HandleFunc("POST /login", s.login)
	mux.HandleFunc("GET /register", s.registerPage)
	mux.HandleFunc("POST /register", s.register)
	mux.HandleFunc("POST /logout", s.requireAuth(s.csrf(s.logout)))
	mux.HandleFunc("GET /auth/{provider}", s.oauthStart)
	mux.HandleFunc("GET /auth/{provider}/callback", s.oauthCallback)
	mux.HandleFunc("GET /profile", s.requireAuth(s.profilePage))
	mux.HandleFunc("POST /profile/avatar", s.requireAuth(s.csrf(s.uploadAvatar)))
	mux.HandleFunc("GET /", s.requireAuth(s.dashboard))
	mux.HandleFunc("GET /day", s.requireAuth(s.dayPage))
	mux.HandleFunc("GET /calendar", s.requireAuth(s.calendarPartial))
	mux.HandleFunc("POST /attendance/check-in", s.requireAuth(s.csrf(s.checkIn)))
	mux.HandleFunc("POST /attendance/check-out", s.requireAuth(s.csrf(s.checkOut)))
	mux.HandleFunc("GET /requests", s.requireAuth(s.requestsPage))
	mux.HandleFunc("POST /requests", s.requireAuth(s.csrf(s.createRequest)))
	mux.HandleFunc("POST /requests/{id}/cancel", s.requireAuth(s.csrf(s.cancelRequest)))
	mux.HandleFunc("GET /updates", s.requireAuth(s.updatesPage))
	mux.HandleFunc("POST /updates", s.requireAuth(s.csrf(s.createWorkUpdate)))
	mux.HandleFunc("POST /updates/{id}/delete", s.requireAuth(s.csrf(s.deleteWorkUpdate)))
	mux.HandleFunc("GET /board", s.requireAuth(s.boardPage))
	mux.HandleFunc("GET /board/archive", s.requireAuth(s.archivedBoardPage))
	mux.HandleFunc("POST /board/tasks", s.requireAuth(s.csrf(s.createBoardTask)))
	mux.HandleFunc("POST /board/tasks/{id}/move", s.requireAuth(s.csrf(s.moveBoardTask)))
	mux.HandleFunc("POST /board/tasks/{id}/archive", s.requireAuth(s.csrf(s.archiveBoardTask)))
	mux.HandleFunc("POST /board/tasks/{id}/restore", s.requireAuth(s.csrf(s.restoreBoardTask)))
	mux.HandleFunc("POST /board/tasks/{id}/delete", s.requireAuth(s.csrf(s.deleteBoardTask)))
	mux.HandleFunc("POST /board/tasks/{id}/tags", s.requireAuth(s.csrf(s.setBoardTaskTags)))
	mux.HandleFunc("POST /board/tasks/{id}/details", s.requireAuth(s.csrf(s.setBoardTaskDetails)))
	mux.HandleFunc("POST /board/tasks/{id}/todos", s.requireAuth(s.csrf(s.createBoardTaskTodo)))
	mux.HandleFunc("POST /board/tasks/{id}/todos/{todoID}/toggle", s.requireAuth(s.csrf(s.toggleBoardTaskTodo)))
	mux.HandleFunc("POST /board/tasks/{id}/todos/{todoID}/delete", s.requireAuth(s.csrf(s.deleteBoardTaskTodo)))
	mux.HandleFunc("POST /board/tags", s.requireAuth(s.csrf(s.createBoardTag)))
	mux.HandleFunc("POST /board/tags/{id}/delete", s.requireAuth(s.csrf(s.deleteBoardTag)))
	mux.HandleFunc("GET /admin/requests", s.requireAdmin(s.adminPage))
	mux.HandleFunc("POST /admin/requests/{id}/review", s.requireAdmin(s.csrf(s.reviewRequest)))
	mux.HandleFunc("GET /admin/users", s.requireAdmin(s.usersPage))
	mux.HandleFunc("POST /admin/users", s.requireAdmin(s.csrf(s.createUser)))
	mux.HandleFunc("POST /admin/users/{id}/status", s.requireAdmin(s.csrf(s.setUserStatus)))
	mux.HandleFunc("POST /admin/users/{id}/password", s.requireAdmin(s.csrf(s.changeUserPassword)))
	mux.HandleFunc("POST /admin/users/{id}/delete", s.requireAdmin(s.csrf(s.deleteUser)))
	mux.HandleFunc("GET /admin/workspaces", s.requireAdmin(s.workspacesPage))
	mux.HandleFunc("POST /admin/workspaces", s.requireAdmin(s.csrf(s.createWorkspace)))
	mux.HandleFunc("POST /admin/workspaces/{id}/members", s.requireAdmin(s.csrf(s.setWorkspaceMembers)))
	mux.HandleFunc("POST /workspace/switch", s.requireAuth(s.csrf(s.switchWorkspace)))
	mux.HandleFunc("GET /reports", s.requireAdmin(s.reportPage))
	mux.HandleFunc("GET /reports/attendance.csv", s.requireAdmin(s.reportCSV))
	mux.HandleFunc("GET /reports/work-updates.csv", s.requireAdmin(s.workUpdatesCSV))
	mux.HandleFunc("GET /reports/summary.csv", s.requireAdmin(s.reportSummaryCSV))
}

func (s *Server) dayPage(w http.ResponseWriter, r *http.Request) {
	rawDate := r.URL.Query().Get("date")
	if rawDate == "" {
		rawDate = time.Now().Format("2006-01-02")
	}
	day, ok := parseUserDate(rawDate)
	errorMessage := r.URL.Query().Get("error")
	if !ok {
		day = time.Now().Format("2006-01-02")
		errorMessage = "Choose a valid Persian date."
	}
	selected, _ := time.Parse("2006-01-02", day)
	view := r.URL.Query().Get("view")
	if view != "day" {
		view = "week"
	}
	var detail DayDetail
	var week WeekDetail
	if view == "week" {
		offset := (int(selected.Weekday()) + 1) % 7
		weekStart := selected.AddDate(0, 0, -offset)
		week = WeekDetail{
			Enabled:     true,
			StartJalali: jalali.FromTime(weekStart).String(),
			EndJalali:   jalali.FromTime(weekStart.AddDate(0, 0, 6)).String(),
			Prev:        jalali.FromTime(weekStart.AddDate(0, 0, -7)).String(),
			Next:        jalali.FromTime(weekStart.AddDate(0, 0, 7)).String(),
		}
		for index := 0; index < 7; index++ {
			date := weekStart.AddDate(0, 0, index)
			dayDetail, err := s.buildDayDetail(date.Format("2006-01-02"))
			if err != nil {
				http.Error(w, "could not load the team week view", http.StatusInternalServerError)
				return
			}
			if dayDetail.Gregorian == day {
				detail = dayDetail
			}
			week.Present += dayDetail.Present
			week.Remote += dayDetail.Remote
			week.Absent += dayDetail.Absent
			week.Days = append(week.Days, dayDetail)
		}
	} else {
		var err error
		detail, err = s.buildDayDetail(day)
		if err != nil {
			http.Error(w, "could not load the team day view", http.StatusInternalServerError)
			return
		}
	}
	if detail.Gregorian == "" {
		detail, _ = s.buildDayDetail(day)
	}
	s.render(w, "day.html", PageData{
		Title: "Team " + view, User: currentUser(r), CSRF: csrfToken(r), Day: detail, Week: week,
		Error: errorMessage, SelectedSection: "day",
	})
}

func (s *Server) buildDayDetail(day string) (DayDetail, error) {
	selected, err := time.Parse("2006-01-02", day)
	if err != nil {
		return DayDetail{}, err
	}
	rows, err := s.store.DayRoster(day)
	if err != nil {
		return DayDetail{}, err
	}
	jalaliDate := jalali.FromTime(selected)
	detail := DayDetail{
		Gregorian: day,
		Jalali:    jalaliDate.String(),
		Weekday:   selected.Format("Monday"),
		IsToday:   day == time.Now().Format("2006-01-02"),
		Prev:      jalali.FromTime(selected.AddDate(0, 0, -1)).String(),
		Next:      jalali.FromTime(selected.AddDate(0, 0, 1)).String(),
	}
	if holiday := persianHoliday(jalaliDate.Year, jalaliDate.Month, jalaliDate.Day); holiday != "" {
		detail.DayNote = holiday
	} else if selected.Weekday() == time.Friday {
		detail.DayNote = "Friday weekend"
	}
	today := time.Now().Format("2006-01-02")
	for _, row := range rows {
		member := RosterMember{User: row.User, CheckIn: row.CheckIn, CheckOut: row.CheckOut}
		switch {
		case row.Mode == "office":
			member.Status, member.Label = "present", "Present"
			member.Detail = attendanceDetail(row.CheckIn, row.CheckOut, "Office")
			detail.Present++
		case row.Mode == "remote":
			member.Status, member.Label = "remote", "Remote"
			member.Detail = attendanceDetail(row.CheckIn, row.CheckOut, "Remote")
			detail.Remote++
		case row.RequestKind == "leave":
			member.Status, member.Label, member.Detail = "absent", "Absent", "Approved time off"
			detail.Absent++
		case row.RequestKind == "remote":
			member.Status, member.Label, member.Detail = "remote", "Remote", "Approved remote day"
			detail.Remote++
		case detail.DayNote != "":
			member.Status, member.Label, member.Detail = "absent", "Absent", detail.DayNote
			detail.Absent++
		case day >= today:
			member.Status, member.Label = "present", "Present"
			if day == today {
				member.Detail = "Expected at the office today"
			} else {
				member.Detail = "Expected at the office"
			}
			detail.Present++
		default:
			member.Status, member.Label, member.Detail = "absent", "Absent", "No attendance recorded"
			detail.Absent++
		}
		detail.Members = append(detail.Members, member)
	}
	return detail, nil
}

func attendanceDetail(checkIn, checkOut, location string) string {
	if checkOut != "" {
		return fmt.Sprintf("%s · %s–%s", location, formatStoredClock(checkIn), formatStoredClock(checkOut))
	}
	if checkIn != "" {
		return fmt.Sprintf("%s · checked in %s", location, formatStoredClock(checkIn))
	}
	return location
}

func formatStoredClock(raw string) string {
	raw = strings.TrimSpace(raw)
	if raw == "" {
		return "—"
	}
	if parsed, err := time.Parse(time.RFC3339Nano, raw); err == nil {
		return parsed.In(time.Local).Format("15:04")
	}
	// Older rows were written from time.Time directly. SQLite persisted Go's
	// String form, including a duplicate zone name and monotonic suffix:
	// "2026-07-28 11:48:51.004 +0330 +0330 m=+18.1".
	fields := strings.Fields(raw)
	if len(fields) >= 3 {
		legacy := strings.Join(fields[:3], " ")
		for _, layout := range []string{
			"2006-01-02 15:04:05.999999999 -0700",
			"2006-01-02 15:04:05 -0700",
		} {
			if parsed, err := time.Parse(layout, legacy); err == nil {
				return parsed.In(time.Local).Format("15:04")
			}
		}
	}
	if len(raw) >= 16 {
		return raw[11:16]
	}
	return raw
}

func (s *Server) health(w http.ResponseWriter, r *http.Request) {
	ctx, cancel := context.WithTimeout(r.Context(), time.Second)
	defer cancel()
	if err := s.store.Ping(ctx); err != nil {
		http.Error(w, `{"status":"unhealthy"}`, http.StatusServiceUnavailable)
		return
	}
	w.Header().Set("Content-Type", "application/json")
	_, _ = io.WriteString(w, `{"status":"ok"}`)
}

func (s *Server) render(w http.ResponseWriter, name string, data PageData) {
	if data.User != nil {
		if workspaces, err := s.store.Workspaces(data.User.ID); err == nil {
			data.Workspaces = workspaces
			if data.Workspace.ID == 0 && data.User.WorkspaceID > 0 {
				for _, ws := range workspaces {
					if ws.ID == data.User.WorkspaceID {
						data.Workspace = ws
						break
					}
				}
			}
			if data.Workspace.ID == 0 && len(workspaces) > 0 {
				data.Workspace = workspaces[0]
			}
		}
	}
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	if err := s.templates.ExecuteTemplate(w, name, data); err != nil {
		log.Printf("render %s: %v", name, err)
	}
}

func (s *Server) loginPage(w http.ResponseWriter, r *http.Request) {
	if currentUser(r) != nil {
		http.Redirect(w, r, "/", http.StatusSeeOther)
		return
	}
	s.render(w, "login.html", PageData{
		Title: "Sign in", Error: r.URL.Query().Get("error"),
		OAuthGitHub: s.cfg.GitHubClientID != "", OAuthGoogle: s.cfg.GoogleClientID != "",
	})
}

func (s *Server) login(w http.ResponseWriter, r *http.Request) {
	if err := r.ParseForm(); err != nil {
		http.Error(w, "bad request", http.StatusBadRequest)
		return
	}
	identifier := r.FormValue("identifier")
	if identifier == "" {
		identifier = r.FormValue("username")
	}
	u, err := s.store.Authenticate(identifier, r.FormValue("password"))
	if err != nil {
		http.Redirect(w, r, "/login?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
		return
	}
	s.startSession(w, u.ID)
	http.Redirect(w, r, "/", http.StatusSeeOther)
}

func (s *Server) registerPage(w http.ResponseWriter, r *http.Request) {
	if currentUser(r) != nil {
		http.Redirect(w, r, "/", http.StatusSeeOther)
		return
	}
	s.render(w, "register.html", PageData{
		Title: "Create account", Error: r.URL.Query().Get("error"),
		OAuthGitHub: s.cfg.GitHubClientID != "", OAuthGoogle: s.cfg.GoogleClientID != "",
	})
}

func (s *Server) register(w http.ResponseWriter, r *http.Request) {
	if err := r.ParseForm(); err != nil {
		http.Error(w, "bad request", http.StatusBadRequest)
		return
	}
	if r.FormValue("password") != r.FormValue("password_confirm") {
		http.Redirect(w, r, "/register?error=Passwords+do+not+match", http.StatusSeeOther)
		return
	}
	userID, err := s.store.CreateUser(
		r.FormValue("username"), r.FormValue("password"), r.FormValue("display_name"),
		r.FormValue("email"), "member",
	)
	if err != nil {
		http.Redirect(w, r, "/register?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
		return
	}
	s.startSession(w, userID)
	http.Redirect(w, r, "/?flash=Welcome+to+Hamkar", http.StatusSeeOther)
}

func (s *Server) startSession(w http.ResponseWriter, userID int64) {
	token, _, err := s.store.CreateSession(userID)
	if err != nil {
		http.Error(w, "could not create session", http.StatusInternalServerError)
		return
	}
	http.SetCookie(w, &http.Cookie{
		Name: "teammate_session", Value: token, Path: "/", HttpOnly: true,
		Secure: s.cfg.SessionSecure, SameSite: http.SameSiteLaxMode, MaxAge: 30 * 24 * 60 * 60,
	})
}

func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
	if c, err := r.Cookie("teammate_session"); err == nil {
		s.store.DeleteSession(c.Value)
	}
	http.SetCookie(w, &http.Cookie{Name: "teammate_session", Path: "/", MaxAge: -1, HttpOnly: true})
	http.Redirect(w, r, "/login", http.StatusSeeOther)
}

func (s *Server) dashboard(w http.ResponseWriter, r *http.Request) {
	u := currentUser(r)
	now := time.Now()
	today := now.Format("2006-01-02")
	a, _ := s.store.TodayAttendance(u.ID, today)
	cal := s.buildCalendar(u.ID, r.URL.Query().Get("month"))
	start := jalali.ToGregorian(cal.Year, cal.Month, 1).Format("2006-01-02")
	end := jalali.ToGregorian(cal.Year, cal.Month, jalali.DaysInMonth(cal.Year, cal.Month)).Format("2006-01-02")
	present, remote, leave, _ := s.store.Stats(u.ID, start, end)
	requests, _ := s.store.Requests(u.ID, false, "")
	if len(requests) > 4 {
		requests = requests[:4]
	}
	pending, _ := s.store.Requests(u.ID, u.Role == "admin", "pending")
	s.render(w, "dashboard.html", PageData{
		Title: "Dashboard", User: u, CSRF: csrfToken(r), Flash: r.URL.Query().Get("flash"),
		Error: r.URL.Query().Get("error"),
		Today: today, TodayJalali: jalali.FromTime(now).String(), Attendance: a,
		Calendar: cal, Requests: requests, PendingCount: len(pending),
		Stats: Stats{present, remote, leave}, SelectedSection: "dashboard",
	})
}

func (s *Server) calendarPartial(w http.ResponseWriter, r *http.Request) {
	s.render(w, "calendar.html", PageData{User: currentUser(r), Calendar: s.buildCalendar(currentUser(r).ID, r.URL.Query().Get("month"))})
}

func (s *Server) buildCalendar(userID int64, selected string) Calendar {
	today := jalali.FromTime(time.Now())
	year, month := today.Year, today.Month
	if selected != "" {
		if _, err := fmt.Sscanf(selected, "%d-%d", &year, &month); err != nil || month < 1 || month > 12 {
			year, month = today.Year, today.Month
		}
	}
	first := jalali.ToGregorian(year, month, 1)
	last := jalali.ToGregorian(year, month, jalali.DaysInMonth(year, month))
	attendance, _ := s.store.AttendanceBetween(userID, first.Format("2006-01-02"), last.Format("2006-01-02"))
	requests, _ := s.store.Requests(userID, false, "approved")
	// Persian weeks begin Saturday. Go's Sunday=0, so Saturday maps to zero.
	offset := (int(first.Weekday()) + 1) % 7
	cells := make([]CalendarCell, offset, offset+jalali.DaysInMonth(year, month))
	for d := 1; d <= jalali.DaysInMonth(year, month); d++ {
		g := jalali.ToGregorian(year, month, d)
		raw := g.Format("2006-01-02")
		cell := CalendarCell{Day: d, Weekday: (offset + d - 1) % 7, Gregorian: raw, InMonth: true, IsToday: raw == time.Now().Format("2006-01-02")}
		cell.IsFriday = g.Weekday() == time.Friday
		cell.Holiday = persianHoliday(year, month, d)
		if a, ok := attendance[raw]; ok {
			cell.Status = a.Mode
		}
		for _, req := range requests {
			if raw >= req.StartDate && raw <= req.EndDate {
				cell.Status = req.Kind
			}
		}
		cells = append(cells, cell)
	}
	prevY, prevM, nextY, nextM := year, month-1, year, month+1
	if prevM == 0 {
		prevY, prevM = year-1, 12
	}
	if nextM == 13 {
		nextY, nextM = year+1, 1
	}
	return Calendar{
		Year: year, Month: month, MonthName: jalali.MonthNames[month], MonthNameFA: jalali.MonthNamesFA[month],
		Prev: fmt.Sprintf("%04d-%02d", prevY, prevM), Next: fmt.Sprintf("%04d-%02d", nextY, nextM), Cells: cells,
	}
}

func persianHoliday(year, month, day int) string {
	// Official public holidays for Solar Hijri 1405. Lunar holidays are
	// recorded using the dates published for this Persian calendar year.
	if year != 1405 {
		return ""
	}
	holidays := map[string]string{
		"1-1": "Nowruz", "1-2": "Nowruz", "1-3": "Nowruz", "1-4": "Nowruz",
		"1-12": "Islamic Republic Day", "1-13": "Nature Day",
		"2-6": "Eid al-Adha", "2-14": "Eid al-Ghadir",
		"3-14": "Demise of Imam Khomeini", "3-15": "Khordad Uprising",
		"4-3": "Tasua", "4-4": "Ashura",
		"5-13": "Arbaeen", "5-21": "Demise of Prophet Muhammad", "5-23": "Martyrdom of Imam Hassan", "5-30": "Martyrdom of Imam Reza",
		"6-8":   "Prophet Muhammad's Birthday",
		"9-3":   "Martyrdom of Fatima",
		"10-2":  "Imam Ali's Birthday",
		"11-22": "Revolution Day", "12-29": "Oil Nationalization Day",
	}
	return holidays[fmt.Sprintf("%d-%d", month, day)]
}

func (s *Server) checkIn(w http.ResponseWriter, r *http.Request) {
	u := currentUser(r)
	if err := s.store.CheckIn(u.ID, time.Now().Format("2006-01-02"), r.FormValue("mode")); err != nil {
		s.redirectError(w, r, err)
		return
	}
	http.Redirect(w, r, "/?flash=Checked+in+successfully", http.StatusSeeOther)
}

func (s *Server) checkOut(w http.ResponseWriter, r *http.Request) {
	if err := s.store.CheckOut(currentUser(r).ID, time.Now().Format("2006-01-02")); err != nil {
		s.redirectError(w, r, err)
		return
	}
	http.Redirect(w, r, "/?flash=Checked+out+successfully", http.StatusSeeOther)
}

func (s *Server) requestsPage(w http.ResponseWriter, r *http.Request) {
	requests, err := s.store.Requests(currentUser(r).ID, false, "")
	if err != nil {
		http.Error(w, "could not load requests", http.StatusInternalServerError)
		return
	}
	s.render(w, "requests.html", PageData{
		Title: "My requests", User: currentUser(r), CSRF: csrfToken(r), Requests: requests,
		Today: time.Now().Format("2006-01-02"), Error: r.URL.Query().Get("error"), Flash: r.URL.Query().Get("flash"),
		SelectedSection: "requests",
	})
}

func (s *Server) createRequest(w http.ResponseWriter, r *http.Request) {
	start, startOK := parseUserDate(r.FormValue("start_date"))
	end, endOK := parseUserDate(r.FormValue("end_date"))
	if !startOK || !endOK || end < start {
		s.redirectRequestError(w, r, errors.New("choose a valid date range"))
		return
	}
	if err := s.store.CreateRequest(currentUser(r).ID, r.FormValue("kind"), start, end, r.FormValue("reason")); err != nil {
		s.redirectRequestError(w, r, err)
		return
	}
	http.Redirect(w, r, "/requests?flash=Request+sent+for+approval", http.StatusSeeOther)
}

func parseUserDate(raw string) (string, bool) {
	var y, m, d int
	if _, err := fmt.Sscanf(strings.TrimSpace(raw), "%d-%d-%d", &y, &m, &d); err != nil {
		return "", false
	}
	if y >= 1700 {
		t, err := time.Parse("2006-01-02", fmt.Sprintf("%04d-%02d-%02d", y, m, d))
		return t.Format("2006-01-02"), err == nil
	}
	if y < 1200 || m < 1 || m > 12 || d < 1 || d > jalali.DaysInMonth(y, m) {
		return "", false
	}
	return jalali.ToGregorian(y, m, d).Format("2006-01-02"), true
}

func validDate(raw string) bool {
	_, err := time.Parse("2006-01-02", raw)
	return err == nil
}

func (s *Server) cancelRequest(w http.ResponseWriter, r *http.Request) {
	id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
	_ = s.store.CancelRequest(id, currentUser(r).ID)
	http.Redirect(w, r, "/requests?flash=Request+cancelled", http.StatusSeeOther)
}

func (s *Server) updatesPage(w http.ResponseWriter, r *http.Request) {
	updates, err := s.store.WorkUpdates("", "", 100)
	if err != nil {
		http.Error(w, "could not load work updates", http.StatusInternalServerError)
		return
	}
	s.render(w, "updates.html", PageData{
		Title: "Work updates", User: currentUser(r), CSRF: csrfToken(r), WorkUpdates: updates,
		TodayJalali: jalali.FromTime(time.Now()).String(), Error: r.URL.Query().Get("error"),
		Flash: r.URL.Query().Get("flash"), SelectedSection: "updates",
	})
}

func (s *Server) createWorkUpdate(w http.ResponseWriter, r *http.Request) {
	start, startOK := parseUserDate(r.FormValue("period_start"))
	end, endOK := parseUserDate(r.FormValue("period_end"))
	if !startOK || !endOK || end < start {
		http.Redirect(w, r, "/updates?error=Choose+a+valid+reporting+period", http.StatusSeeOther)
		return
	}
	startTime, _ := time.Parse("2006-01-02", start)
	endTime, _ := time.Parse("2006-01-02", end)
	if endTime.Sub(startTime) > 366*24*time.Hour {
		http.Redirect(w, r, "/updates?error=Reporting+period+cannot+exceed+one+year", http.StatusSeeOther)
		return
	}
	if err := s.store.CreateWorkUpdate(
		currentUser(r).ID, start, end, r.FormValue("status"), r.FormValue("note"),
	); err != nil {
		http.Redirect(w, r, "/updates?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
		return
	}
	http.Redirect(w, r, "/updates?flash=Work+update+shared", http.StatusSeeOther)
}

func (s *Server) deleteWorkUpdate(w http.ResponseWriter, r *http.Request) {
	id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
	if err := s.store.DeleteWorkUpdate(id, currentUser(r).ID); err != nil {
		http.Redirect(w, r, "/updates?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
		return
	}
	http.Redirect(w, r, "/updates?flash=Work+update+removed", http.StatusSeeOther)
}

func (s *Server) boardPage(w http.ResponseWriter, r *http.Request) {
	workspaceID := requestWorkspaceID(r)
	tasks, err := s.store.BoardTasksInWorkspace(workspaceID)
	if err != nil {
		http.Error(w, "could not load team board", http.StatusInternalServerError)
		return
	}
	archivedTasks, err := s.store.ArchivedBoardTasksInWorkspace(workspaceID)
	if err != nil {
		http.Error(w, "could not load archived cards", http.StatusInternalServerError)
		return
	}
	users, err := s.store.ActiveUsersInWorkspace(workspaceID)
	if err != nil {
		http.Error(w, "could not load teammates", http.StatusInternalServerError)
		return
	}
	tags, err := s.store.BoardTags()
	if err != nil {
		http.Error(w, "could not load board tags", http.StatusInternalServerError)
		return
	}
	filter := BoardFilter{Priority: r.URL.Query().Get("priority")}
	filter.TagID, _ = strconv.ParseInt(r.URL.Query().Get("tag"), 10, 64)
	filter.AssigneeID, _ = strconv.ParseInt(r.URL.Query().Get("assignee"), 10, 64)
	if !validBoardPriority(filter.Priority) {
		filter.Priority = ""
	}
	if filter.TagID > 0 {
		filter.Count++
	}
	if filter.AssigneeID > 0 {
		filter.Count++
	}
	if filter.Priority != "" {
		filter.Count++
	}
	filter.Active = filter.TagID > 0 || filter.AssigneeID > 0 || filter.Priority != ""
	if filter.Active {
		filtered := make([]BoardTask, 0, len(tasks))
		for _, task := range tasks {
			if boardTaskMatchesFilter(task, filter) {
				filtered = append(filtered, task)
			}
		}
		tasks = filtered
	}
	columns := []BoardColumn{
		{Status: "backlog", Title: "Backlog", Hint: "Untriaged work"},
		{Status: "todo", Title: "To do", Hint: "Ready to start"},
		{Status: "in_progress", Title: "In progress", Hint: "Actively being worked"},
		{Status: "done", Title: "Done", Hint: "Completed work"},
	}
	for _, task := range tasks {
		for index := range columns {
			if columns[index].Status == task.Status {
				columns[index].Tasks = append(columns[index].Tasks, task)
				break
			}
		}
	}
	s.render(w, "board.html", PageData{
		Title: "Team board", User: currentUser(r), CSRF: csrfToken(r), Users: users, Board: columns, BoardTags: tags,
		ArchivedTasks: archivedTasks, BoardFilter: filter,
		TodayJalali: jalali.FromTime(time.Now()).String(), Error: r.URL.Query().Get("error"),
		Flash: r.URL.Query().Get("flash"), SelectedSection: "board",
	})
}

func (s *Server) archivedBoardPage(w http.ResponseWriter, r *http.Request) {
	tasks, err := s.store.ArchivedBoardTasksInWorkspace(requestWorkspaceID(r))
	if err != nil {
		http.Error(w, "could not load archived cards", http.StatusInternalServerError)
		return
	}
	s.render(w, "archived.html", PageData{Title: "Archived cards", User: currentUser(r), CSRF: csrfToken(r), ArchivedTasks: tasks, Error: r.URL.Query().Get("error"), Flash: r.URL.Query().Get("flash"), SelectedSection: "board"})
}

func validBoardPriority(priority string) bool {
	return priority == "" || priority == "low" || priority == "normal" || priority == "high" || priority == "urgent"
}

func boardTaskMatchesFilter(task BoardTask, filter BoardFilter) bool {
	if filter.Priority != "" && task.Importance != filter.Priority {
		return false
	}
	if filter.TagID > 0 {
		found := false
		for _, tag := range task.Tags {
			if tag.ID == filter.TagID {
				found = true
				break
			}
		}
		if !found {
			return false
		}
	}
	if filter.AssigneeID > 0 {
		found := false
		for _, assignee := range task.Assignees {
			if assignee.ID == filter.AssigneeID {
				found = true
				break
			}
		}
		if !found {
			return false
		}
	}
	return true
}

func (s *Server) createBoardTask(w http.ResponseWriter, r *http.Request) {
	workspaceID := requestWorkspaceID(r)
	if raw := r.FormValue("workspace_id"); raw != "" {
		if selected, err := strconv.ParseInt(raw, 10, 64); err == nil {
			if _, err := s.store.WorkspaceMember(selected, currentUser(r).ID); err != nil {
				http.Redirect(w, r, "/board?error=You+do+not+have+access+to+that+workspace", http.StatusSeeOther)
				return
			}
			workspaceID = selected
		}
	}
	if strings.TrimSpace(r.FormValue("title")) == "" {
		http.Redirect(w, r, "/board?error=Task+title+is+required", http.StatusSeeOther)
		return
	}
	dueDate := ""
	if rawDue := strings.TrimSpace(r.FormValue("due_date")); rawDue != "" {
		parsed, ok := parseUserDate(rawDue)
		if !ok {
			http.Redirect(w, r, "/board?error=Choose+a+valid+Jalali+due+date", http.StatusSeeOther)
			return
		}
		dueDate = parsed
	}
	tagIDs := parseIDList(r.Form["tag_ids"])
	if newTagName := strings.TrimSpace(r.FormValue("new_tag_name")); newTagName != "" {
		tag, err := s.store.FindOrCreateBoardTag(newTagName, r.FormValue("new_tag_color"))
		if err != nil {
			http.Redirect(w, r, "/board?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
			return
		}
		tagIDs = append(tagIDs, tag.ID)
	}
	taskID, err := s.store.CreateBoardTaskInWorkspace(
		workspaceID, currentUser(r).ID, r.FormValue("title"), r.FormValue("description"),
		r.FormValue("status"), r.FormValue("importance"), parseIDList(r.Form["assignee_ids"]), dueDate,
	)
	if err != nil {
		http.Redirect(w, r, "/board?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
		return
	}
	if err := s.store.SetBoardTaskTags(taskID, tagIDs); err != nil {
		http.Redirect(w, r, "/board?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
		return
	}
	http.Redirect(w, r, "/board?flash=Task+added+to+the+team+board", http.StatusSeeOther)
}

func parseIDList(values []string) []int64 {
	ids := make([]int64, 0, len(values))
	for _, raw := range values {
		if id, err := strconv.ParseInt(raw, 10, 64); err == nil && id > 0 {
			ids = append(ids, id)
		}
	}
	return ids
}

func (s *Server) moveBoardTask(w http.ResponseWriter, r *http.Request) {
	id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
	if err := s.store.MoveBoardTask(id, r.FormValue("status")); err != nil {
		http.Redirect(w, r, "/board?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
		return
	}
	http.Redirect(w, r, "/board?flash=Task+moved", http.StatusSeeOther)
}

func (s *Server) deleteBoardTask(w http.ResponseWriter, r *http.Request) {
	id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
	user := currentUser(r)
	if err := s.store.DeleteBoardTask(id, user.ID, user.Role == "admin"); err != nil {
		http.Redirect(w, r, "/board?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
		return
	}
	http.Redirect(w, r, "/board?flash=Task+permanently+deleted", http.StatusSeeOther)
}

func (s *Server) archiveBoardTask(w http.ResponseWriter, r *http.Request) {
	s.setBoardTaskArchived(w, r, true)
}

func (s *Server) restoreBoardTask(w http.ResponseWriter, r *http.Request) {
	s.setBoardTaskArchived(w, r, false)
}

func (s *Server) setBoardTaskArchived(w http.ResponseWriter, r *http.Request, archived bool) {
	id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
	user := currentUser(r)
	if err := s.store.SetBoardTaskArchived(id, user.ID, user.Role == "admin", archived); err != nil {
		http.Redirect(w, r, "/board?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
		return
	}
	message := "Task+restored"
	if archived {
		message = "Task+archived"
	}
	http.Redirect(w, r, "/board?flash="+message, http.StatusSeeOther)
}

func (s *Server) setBoardTaskTags(w http.ResponseWriter, r *http.Request) {
	id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
	if err := s.store.SetBoardTaskTags(id, parseIDList(r.Form["tag_ids"])); err != nil {
		http.Redirect(w, r, "/board?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
		return
	}
	http.Redirect(w, r, "/board?flash=Task+labels+updated", http.StatusSeeOther)
}

func (s *Server) setBoardTaskDetails(w http.ResponseWriter, r *http.Request) {
	id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
	if err := s.store.SetBoardTaskDetails(id, parseIDList(r.Form["assignee_ids"]), r.FormValue("importance")); err != nil {
		http.Redirect(w, r, "/board?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
		return
	}
	http.Redirect(w, r, "/board?flash=Card+details+updated", http.StatusSeeOther)
}

func (s *Server) createBoardTaskTodo(w http.ResponseWriter, r *http.Request) {
	taskID, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
	if err := s.store.CreateBoardTaskTodo(taskID, r.FormValue("body")); err != nil {
		http.Redirect(w, r, "/board?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
		return
	}
	http.Redirect(w, r, "/board?flash=To-do+item+added", http.StatusSeeOther)
}

func (s *Server) toggleBoardTaskTodo(w http.ResponseWriter, r *http.Request) {
	taskID, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
	todoID, _ := strconv.ParseInt(r.PathValue("todoID"), 10, 64)
	if err := s.store.SetBoardTaskTodoCompleted(taskID, todoID, r.FormValue("completed") == "on"); err != nil {
		http.Redirect(w, r, "/board?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
		return
	}
	http.Redirect(w, r, "/board?flash=To-do+item+updated", http.StatusSeeOther)
}

func (s *Server) deleteBoardTaskTodo(w http.ResponseWriter, r *http.Request) {
	taskID, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
	todoID, _ := strconv.ParseInt(r.PathValue("todoID"), 10, 64)
	if err := s.store.DeleteBoardTaskTodo(taskID, todoID); err != nil {
		http.Redirect(w, r, "/board?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
		return
	}
	http.Redirect(w, r, "/board?flash=To-do+item+removed", http.StatusSeeOther)
}

func (s *Server) createBoardTag(w http.ResponseWriter, r *http.Request) {
	if err := s.store.CreateBoardTag(r.FormValue("name"), r.FormValue("color")); err != nil {
		http.Redirect(w, r, "/board?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
		return
	}
	http.Redirect(w, r, "/board?flash=Tag+created", http.StatusSeeOther)
}

func (s *Server) deleteBoardTag(w http.ResponseWriter, r *http.Request) {
	id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
	if err := s.store.DeleteBoardTag(id); err != nil {
		http.Redirect(w, r, "/board?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
		return
	}
	http.Redirect(w, r, "/board?flash=Tag+deleted", http.StatusSeeOther)
}

func (s *Server) adminPage(w http.ResponseWriter, r *http.Request) {
	requests, err := s.store.Requests(currentUser(r).ID, true, r.URL.Query().Get("status"))
	if err != nil {
		http.Error(w, "could not load requests", http.StatusInternalServerError)
		return
	}
	s.render(w, "admin.html", PageData{
		Title: "Approvals", User: currentUser(r), CSRF: csrfToken(r), Requests: requests,
		Error: r.URL.Query().Get("error"), Flash: r.URL.Query().Get("flash"), SelectedSection: "admin",
	})
}

func (s *Server) reviewRequest(w http.ResponseWriter, r *http.Request) {
	id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
	err := s.store.ReviewRequest(r.Context(), id, currentUser(r).ID, r.FormValue("decision"), r.FormValue("note"))
	if err != nil {
		http.Redirect(w, r, "/admin/requests?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
		return
	}
	http.Redirect(w, r, "/admin/requests?flash=Request+reviewed", http.StatusSeeOther)
}

func (s *Server) usersPage(w http.ResponseWriter, r *http.Request) {
	users, err := s.store.Users()
	if err != nil {
		http.Error(w, "could not load teammates", http.StatusInternalServerError)
		return
	}
	s.render(w, "users.html", PageData{
		Title: "Teammates", User: currentUser(r), CSRF: csrfToken(r), Users: users,
		Error: r.URL.Query().Get("error"), Flash: r.URL.Query().Get("flash"), SelectedSection: "users",
	})
}

func (s *Server) profilePage(w http.ResponseWriter, r *http.Request) {
	u := currentUser(r)
	s.render(w, "profile.html", PageData{Title: "Profile", User: u, CSRF: csrfToken(r), Flash: r.URL.Query().Get("flash"), Error: r.URL.Query().Get("error"), SelectedSection: "profile"})
}

func (s *Server) uploadAvatar(w http.ResponseWriter, r *http.Request) {
	if err := r.ParseMultipartForm(3 << 20); err != nil {
		http.Redirect(w, r, "/profile?error=Choose+an+image+up+to+2MB", http.StatusSeeOther)
		return
	}
	file, header, err := r.FormFile("avatar")
	if err != nil || header.Size > 2<<20 {
		http.Redirect(w, r, "/profile?error=Choose+an+image+up+to+2MB", http.StatusSeeOther)
		return
	}
	defer file.Close()
	buf := make([]byte, 512)
	n, _ := file.Read(buf)
	kind := http.DetectContentType(buf[:n])
	ext := map[string]string{"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"}[kind]
	if ext == "" {
		http.Redirect(w, r, "/profile?error=Only+JPG,+PNG,+or+WebP+images+are+supported", http.StatusSeeOther)
		return
	}
	if _, err := file.Seek(0, 0); err != nil {
		http.Redirect(w, r, "/profile?error=Could+not+read+image", http.StatusSeeOther)
		return
	}
	dir := filepath.Join(filepath.Dir(s.cfg.DatabasePath), "avatars")
	if err := os.MkdirAll(dir, 0755); err != nil {
		http.Error(w, "could not save avatar", 500)
		return
	}
	name := fmt.Sprintf("%d%s", currentUser(r).ID, ext)
	dst, err := os.Create(filepath.Join(dir, name))
	if err != nil {
		http.Error(w, "could not save avatar", 500)
		return
	}
	defer dst.Close()
	if _, err = io.Copy(dst, file); err != nil {
		http.Error(w, "could not save avatar", 500)
		return
	}
	if err := s.store.SetUserAvatar(currentUser(r).ID, "/uploads/avatars/"+name); err != nil {
		http.Error(w, "could not save avatar", 500)
		return
	}
	http.Redirect(w, r, "/profile?flash=Profile+photo+updated", http.StatusSeeOther)
}

func (s *Server) workspacesPage(w http.ResponseWriter, r *http.Request) {
	u := currentUser(r)
	workspaces, err := s.store.AllWorkspaces()
	if err != nil {
		http.Error(w, "could not load workspaces", http.StatusInternalServerError)
		return
	}
	users, err := s.store.Users()
	if err != nil {
		http.Error(w, "could not load users", 500)
		return
	}
	selected := int64(0)
	if len(workspaces) > 0 {
		selected = workspaces[0].ID
	}
	if raw := r.URL.Query().Get("workspace"); raw != "" {
		selected, _ = strconv.ParseInt(raw, 10, 64)
	}
	members, _ := s.store.WorkspaceMemberIDs(selected)
	selectedWorkspace := Workspace{ID: selected}
	for _, ws := range workspaces {
		if ws.ID == selected {
			selectedWorkspace = ws
			break
		}
	}
	s.render(w, "workspaces.html", PageData{Title: "Workspaces", User: u, CSRF: csrfToken(r), Workspaces: workspaces, Users: users, Workspace: selectedWorkspace, Error: r.URL.Query().Get("error"), Flash: r.URL.Query().Get("flash"), SelectedSection: "workspaces", WorkspaceMembers: members})
}

func (s *Server) setWorkspaceMembers(w http.ResponseWriter, r *http.Request) {
	id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
	var ids []int64
	seen := map[int64]bool{}
	for _, raw := range r.Form["user_ids"] {
		if userID, err := strconv.ParseInt(raw, 10, 64); err == nil && !seen[userID] {
			ids = append(ids, userID)
			seen[userID] = true
		}
	}
	// The admin editing access must retain access to the workspace being managed.
	adminID := currentUser(r).ID
	foundAdmin := false
	for _, id := range ids {
		if id == adminID {
			foundAdmin = true
			break
		}
	}
	if !foundAdmin {
		ids = append(ids, adminID)
	}
	if err := s.store.SetWorkspaceMembers(id, ids); err != nil {
		http.Redirect(w, r, "/admin/workspaces?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
		return
	}
	http.Redirect(w, r, "/admin/workspaces?workspace="+strconv.FormatInt(id, 10)+"&flash=Workspace+members+updated", http.StatusSeeOther)
}

func (s *Server) createWorkspace(w http.ResponseWriter, r *http.Request) {
	u := currentUser(r)
	if _, err := s.store.CreateWorkspace(r.FormValue("name"), r.FormValue("slug"), u.ID); err != nil {
		http.Redirect(w, r, "/admin/workspaces?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
		return
	}
	http.Redirect(w, r, "/admin/workspaces?flash=Workspace+created", http.StatusSeeOther)
}

func (s *Server) switchWorkspace(w http.ResponseWriter, r *http.Request) {
	id, err := strconv.ParseInt(r.FormValue("workspace_id"), 10, 64)
	if err != nil {
		http.Error(w, "invalid workspace", http.StatusBadRequest)
		return
	}
	if _, err := s.store.WorkspaceMember(id, currentUser(r).ID); err != nil {
		http.Error(w, "workspace access denied", http.StatusForbidden)
		return
	}
	http.SetCookie(w, &http.Cookie{Name: "teammate_workspace", Value: strconv.FormatInt(id, 10), Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: 31536000})
	w.Header().Set("Cache-Control", "no-store")
	returnTo := r.FormValue("return_to")
	if returnTo == "" || !strings.HasPrefix(returnTo, "/") {
		returnTo = "/admin/workspaces?workspace=" + strconv.FormatInt(id, 10)
	}
	if r.FormValue("return_to") == "" {
		if ref, err := url.Parse(r.Referer()); err == nil && ref.Path != "" && ref.Path != "/workspace/switch" {
			returnTo = ref.Path
			if ref.RawQuery != "" {
				returnTo += "?" + ref.RawQuery
			}
		}
	}
	http.Redirect(w, r, returnTo, http.StatusSeeOther)
}

func (s *Server) createUser(w http.ResponseWriter, r *http.Request) {
	_, err := s.store.CreateUser(r.FormValue("username"), r.FormValue("password"), r.FormValue("display_name"), r.FormValue("email"), r.FormValue("role"))
	if err != nil {
		http.Redirect(w, r, "/admin/users?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
		return
	}
	http.Redirect(w, r, "/admin/users?flash=Teammate+account+created", http.StatusSeeOther)
}

func (s *Server) setUserStatus(w http.ResponseWriter, r *http.Request) {
	id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
	action := r.FormValue("action")
	if action != "lock" && action != "unlock" {
		http.Redirect(w, r, "/admin/users?error=Choose+a+valid+account+action", http.StatusSeeOther)
		return
	}
	active := action == "unlock"
	if err := s.store.SetUserActive(id, currentUser(r).ID, active); err != nil {
		http.Redirect(w, r, "/admin/users?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
		return
	}
	message := "Account+locked"
	if active {
		message = "Account+unlocked"
	}
	http.Redirect(w, r, "/admin/users?flash="+message, http.StatusSeeOther)
}

func (s *Server) changeUserPassword(w http.ResponseWriter, r *http.Request) {
	id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
	password := r.FormValue("password")
	if password == "" || password != r.FormValue("password_confirm") {
		http.Redirect(w, r, "/admin/users?error=Passwords+must+match", http.StatusSeeOther)
		return
	}
	if err := s.store.SetUserPassword(id, password); err != nil {
		http.Redirect(w, r, "/admin/users?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
		return
	}
	http.Redirect(w, r, "/admin/users?flash=Password+updated", http.StatusSeeOther)
}

func (s *Server) deleteUser(w http.ResponseWriter, r *http.Request) {
	id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
	if err := s.store.DeleteUser(id, currentUser(r).ID); err != nil {
		http.Redirect(w, r, "/admin/users?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
		return
	}
	http.Redirect(w, r, "/admin/users?flash=Account+deleted", http.StatusSeeOther)
}

func (s *Server) reportPage(w http.ResponseWriter, r *http.Request) {
	now := time.Now()
	start := now.AddDate(0, -1, 0).Format("2006-01-02")
	end := now.Format("2006-01-02")
	if requestedStart, requestedEnd := r.URL.Query().Get("start"), r.URL.Query().Get("end"); validDate(requestedStart) && validDate(requestedEnd) && requestedEnd >= requestedStart {
		start, end = requestedStart, requestedEnd
	}
	summaries, err := s.store.ReportSummary(start, end)
	if err != nil {
		http.Error(w, "could not build report summary", http.StatusInternalServerError)
		return
	}
	var totals ReportTotals
	for _, summary := range summaries {
		totals.Present += summary.PresentDays
		totals.Remote += summary.RemoteDays
		totals.Leave += summary.LeaveDays
		totals.Done += summary.DoneUpdates
		totals.Blocked += summary.Blocked
		totals.Pending += summary.Pending
	}
	s.render(w, "reports.html", PageData{
		Title: "Reports", User: currentUser(r), CSRF: csrfToken(r), ReportStart: start,
		ReportEnd: end, ReportSummary: summaries, ReportTotals: totals, SelectedSection: "reports",
	})
}

func (s *Server) reportCSV(w http.ResponseWriter, r *http.Request) {
	start, end := r.URL.Query().Get("start"), r.URL.Query().Get("end")
	if !validDate(start) || !validDate(end) || end < start {
		http.Error(w, "invalid report date range", http.StatusBadRequest)
		return
	}
	rows, err := s.store.ReportRows(start, end)
	if err != nil {
		http.Error(w, "could not generate report", http.StatusInternalServerError)
		return
	}
	defer rows.Close()
	w.Header().Set("Content-Type", "text/csv; charset=utf-8")
	w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="attendance-%s-to-%s.csv"`, start, end))
	_, _ = w.Write([]byte{0xEF, 0xBB, 0xBF}) // Excel-friendly UTF-8 BOM.
	cw := csv.NewWriter(w)
	_ = cw.Write([]string{"Name", "Username", "Date", "Persian date", "Check in", "Check out", "Work mode", "Approved request"})
	for rows.Next() {
		var name, username, day, in, out, mode, request string
		if err := rows.Scan(&name, &username, &day, &in, &out, &mode, &request); err != nil {
			continue
		}
		t, _ := time.Parse("2006-01-02", day)
		_ = cw.Write([]string{name, username, day, jalali.FromTime(t).String(), in, out, mode, request})
	}
	cw.Flush()
}

func (s *Server) workUpdatesCSV(w http.ResponseWriter, r *http.Request) {
	start, end := r.URL.Query().Get("start"), r.URL.Query().Get("end")
	if !validDate(start) || !validDate(end) || end < start {
		http.Error(w, "invalid report date range", http.StatusBadRequest)
		return
	}
	updates, err := s.store.WorkUpdates(start, end, 0)
	if err != nil {
		http.Error(w, "could not generate work-update report", http.StatusInternalServerError)
		return
	}
	w.Header().Set("Content-Type", "text/csv; charset=utf-8")
	w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="work-updates-%s-to-%s.csv"`, start, end))
	_, _ = w.Write([]byte{0xEF, 0xBB, 0xBF})
	cw := csv.NewWriter(w)
	_ = cw.Write([]string{"Name", "Username", "Period start", "Persian start", "Period end", "Persian end", "Status", "Update", "Submitted at"})
	for _, update := range updates {
		startTime, _ := time.Parse("2006-01-02", update.PeriodStart)
		endTime, _ := time.Parse("2006-01-02", update.PeriodEnd)
		_ = cw.Write([]string{
			update.UserName, update.Username, update.PeriodStart, jalali.FromTime(startTime).String(),
			update.PeriodEnd, jalali.FromTime(endTime).String(), update.Status, update.Note, update.CreatedAt,
		})
	}
	cw.Flush()
}

func (s *Server) reportSummaryCSV(w http.ResponseWriter, r *http.Request) {
	start, end := r.URL.Query().Get("start"), r.URL.Query().Get("end")
	if !validDate(start) || !validDate(end) || end < start {
		http.Error(w, "invalid report date range", http.StatusBadRequest)
		return
	}
	summaries, err := s.store.ReportSummary(start, end)
	if err != nil {
		http.Error(w, "could not generate accumulated report", http.StatusInternalServerError)
		return
	}
	w.Header().Set("Content-Type", "text/csv; charset=utf-8")
	w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="team-summary-%s-to-%s.csv"`, start, end))
	_, _ = w.Write([]byte{0xEF, 0xBB, 0xBF})
	cw := csv.NewWriter(w)
	_ = cw.Write([]string{
		"Name", "Username", "Office presence days", "Remote days", "Approved time-off days",
		"Done updates", "Blocked updates", "Pending updates", "Total updates",
	})
	for _, summary := range summaries {
		totalUpdates := summary.DoneUpdates + summary.Blocked + summary.Pending
		_ = cw.Write([]string{
			summary.User.DisplayName, summary.User.Username,
			strconv.Itoa(summary.PresentDays), strconv.Itoa(summary.RemoteDays), strconv.Itoa(summary.LeaveDays),
			strconv.Itoa(summary.DoneUpdates), strconv.Itoa(summary.Blocked), strconv.Itoa(summary.Pending),
			strconv.Itoa(totalUpdates),
		})
	}
	cw.Flush()
}

func (s *Server) redirectError(w http.ResponseWriter, r *http.Request, err error) {
	http.Redirect(w, r, "/?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
}

func (s *Server) redirectRequestError(w http.ResponseWriter, r *http.Request, err error) {
	http.Redirect(w, r, "/requests?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
}

type contextKey string

const userKey contextKey = "user"
const csrfKey contextKey = "csrf"

func currentUser(r *http.Request) *User {
	u, _ := r.Context().Value(userKey).(*User)
	return u
}

func requestWorkspaceID(r *http.Request) int64 {
	if c, err := r.Cookie("teammate_workspace"); err == nil {
		if id, err := strconv.ParseInt(c.Value, 10, 64); err == nil && id > 0 {
			return id
		}
	}
	return 1
}

func csrfToken(r *http.Request) string {
	v, _ := r.Context().Value(csrfKey).(string)
	return v
}

func (s *Server) withUser(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		c, err := r.Cookie("teammate_session")
		if err == nil {
			if u, csrf, err := s.store.Session(c.Value); err == nil {
				u.WorkspaceID = requestWorkspaceID(r)
				if _, err := s.store.WorkspaceMember(u.WorkspaceID, u.ID); err != nil {
					if available, lookupErr := s.store.Workspaces(u.ID); lookupErr == nil && len(available) > 0 {
						u.WorkspaceID = available[0].ID
					}
				}
				ctx := r.Context()
				ctx = context.WithValue(ctx, userKey, u)
				ctx = context.WithValue(ctx, csrfKey, csrf)
				r = r.WithContext(ctx)
			}
		}
		next.ServeHTTP(w, r)
	})
}

func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		if currentUser(r) == nil {
			http.Redirect(w, r, "/login", http.StatusSeeOther)
			return
		}
		next(w, r)
	}
}

func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
	return s.requireAuth(func(w http.ResponseWriter, r *http.Request) {
		if currentUser(r).Role != "admin" {
			http.Error(w, "admin access required", http.StatusForbidden)
			return
		}
		next(w, r)
	})
}

func (s *Server) csrf(next http.HandlerFunc) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		var err error
		if strings.HasPrefix(strings.ToLower(r.Header.Get("Content-Type")), "multipart/form-data") {
			err = r.ParseMultipartForm(8 << 20)
		} else {
			err = r.ParseForm()
		}
		if err != nil || subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(csrfToken(r))) != 1 {
			http.Error(w, "invalid security token; reload the page and try again", http.StatusForbidden)
			return
		}
		next(w, r)
	}
}

func (s *Server) securityHeaders(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("X-Content-Type-Options", "nosniff")
		w.Header().Set("X-Frame-Options", "DENY")
		w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
		w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; font-src 'self' data:; img-src 'self' data: https:; connect-src 'self'")
		next.ServeHTTP(w, r)
	})
}

type oauthProvider struct {
	authorize, token, profile, clientID, secret, scope string
}

func (s *Server) provider(name string) (oauthProvider, bool) {
	switch name {
	case "github":
		return oauthProvider{"https://github.com/login/oauth/authorize", "https://github.com/login/oauth/access_token", "https://api.github.com/user", s.cfg.GitHubClientID, s.cfg.GitHubClientSecret, "read:user user:email"}, s.cfg.GitHubClientID != ""
	case "google":
		return oauthProvider{"https://accounts.google.com/o/oauth2/v2/auth", "https://oauth2.googleapis.com/token", "https://openidconnect.googleapis.com/v1/userinfo", s.cfg.GoogleClientID, s.cfg.GoogleClientSecret, "openid email profile"}, s.cfg.GoogleClientID != ""
	default:
		return oauthProvider{}, false
	}
}

func (s *Server) oauthStart(w http.ResponseWriter, r *http.Request) {
	name := r.PathValue("provider")
	p, ok := s.provider(name)
	if !ok {
		http.Redirect(w, r, "/login?error=OAuth+provider+is+not+configured", http.StatusSeeOther)
		return
	}
	state, _ := randomToken(24)
	http.SetCookie(w, &http.Cookie{Name: "oauth_state", Value: state, Path: "/auth/", HttpOnly: true, Secure: s.cfg.SessionSecure, SameSite: http.SameSiteLaxMode, MaxAge: 600})
	q := url.Values{
		"client_id": {p.clientID}, "redirect_uri": {s.cfg.BaseURL + "/auth/" + name + "/callback"},
		"response_type": {"code"}, "scope": {p.scope}, "state": {state},
	}
	http.Redirect(w, r, p.authorize+"?"+q.Encode(), http.StatusTemporaryRedirect)
}

func (s *Server) oauthCallback(w http.ResponseWriter, r *http.Request) {
	name := r.PathValue("provider")
	p, ok := s.provider(name)
	state, err := r.Cookie("oauth_state")
	if !ok || err != nil || state.Value == "" || subtle.ConstantTimeCompare([]byte(state.Value), []byte(r.URL.Query().Get("state"))) != 1 {
		http.Redirect(w, r, "/login?error=Invalid+OAuth+state", http.StatusSeeOther)
		return
	}
	form := url.Values{
		"client_id": {p.clientID}, "client_secret": {p.secret}, "code": {r.URL.Query().Get("code")},
		"redirect_uri": {s.cfg.BaseURL + "/auth/" + name + "/callback"}, "grant_type": {"authorization_code"},
	}
	req, _ := http.NewRequestWithContext(r.Context(), http.MethodPost, p.token, strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	req.Header.Set("Accept", "application/json")
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		s.oauthFail(w, r)
		return
	}
	defer resp.Body.Close()
	var token struct {
		AccessToken string `json:"access_token"`
	}
	if json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&token) != nil || token.AccessToken == "" {
		s.oauthFail(w, r)
		return
	}
	profileReq, _ := http.NewRequestWithContext(r.Context(), http.MethodGet, p.profile, nil)
	profileReq.Header.Set("Authorization", "Bearer "+token.AccessToken)
	profileReq.Header.Set("Accept", "application/json")
	profileResp, err := http.DefaultClient.Do(profileReq)
	if err != nil {
		s.oauthFail(w, r)
		return
	}
	defer profileResp.Body.Close()
	var profile map[string]any
	decoder := json.NewDecoder(io.LimitReader(profileResp.Body, 1<<20))
	decoder.UseNumber()
	if decoder.Decode(&profile) != nil {
		s.oauthFail(w, r)
		return
	}
	id := fmt.Sprint(profile["id"])
	if name == "google" {
		id = fmt.Sprint(profile["sub"])
	}
	login, _ := profile["login"].(string)
	email, _ := profile["email"].(string)
	display, _ := profile["name"].(string)
	avatar, _ := profile["avatar_url"].(string)
	if name == "google" {
		avatar, _ = profile["picture"].(string)
		login = strings.Split(email, "@")[0]
	}
	userID, err := s.store.UpsertOAuthUser(name, id, login, email, display, avatar)
	if err != nil {
		s.oauthFail(w, r)
		return
	}
	s.startSession(w, userID)
	http.Redirect(w, r, "/", http.StatusSeeOther)
}

func (s *Server) oauthFail(w http.ResponseWriter, r *http.Request) {
	http.Redirect(w, r, "/login?error=Could+not+sign+in+with+OAuth", http.StatusSeeOther)
}
