// File: capacity_analysis.go
package main

import (
	"encoding/json"
	"flag"
	"io"
	"math"
	"os"
	"sort"
	"strconv"
	"time"
)

/*
Capacity Analysis for your payload format:

Input (stdin):
{
  "user_id": 192,
  "payload": {
    "department_id": 165,
    "year": 2025,
    "month": 12,
    "users": [...],
    "shift_templates": [...],
    "holidays": [...]
  }
}

Masks:
- day_mask bits: Mon=1, Tue=2, Wed=4, Thu=8, Fri=16, Sat=32, Sun=64
  Example: 31 => Mon-Fri, 96 => Sat+Sun, 10 => Tue+Thu

Key features:
- Per-role REQUIRED demand is based on must_have_roles counts only (paidMinutes * count).
  It is NOT multiplied by min_workers (that double-counts and explodes numbers).
- nice_to_have roles add NICE demand as (paidMinutes * count).
- The band (max_workers - min_workers) is "untyped optional workers" and is NOT assigned to roles,
  because your payload does not specify which roles should fill that band.
- Validation: if sum(must_have.count) > min_workers, the template is impossible.
  We auto-fix by bumping min_workers to sumMust (and record a DATA ISSUE).
- Holiday templates (zst_holiday_mode=true, zst_parent_template_id set) override their parent
  templates on specific holiday dates. Parent templates are skipped on those days.
- FTE calculations always round UP (math.Ceil) to ensure adequate staffing.
  Example: if analysis shows 2.56 FTE needed, result will be 3 people.
*/

type InputEnvelope struct {
	UserID  int `json:"user_id"`
	Payload struct {
		DepartmentID int `json:"department_id"`
		Year         int `json:"year"`
		Month        int `json:"month"`
		Users        []struct {
			UserID               int     `json:"user_id"`
			Name                 string  `json:"name"`
			Roles                []int   `json:"roles"`
			RequiredHoursPerWeek float64 `json:"required_hours_per_week"`
		} `json:"users"`
		ShiftTemplates []struct {
			ZstID               int    `json:"zst_id"`
			ZstName             string `json:"zst_name"`
			ZstStartTime        string `json:"zst_start_time"` // "HH:mm"
			ZstEndTime          string `json:"zst_end_time"`   // "HH:mm"
			ZstDayMask          int    `json:"zst_day_mask"`
			ZstMinWorkers       int    `json:"zst_min_workers"`
			ZstMaxWorkers       int    `json:"zst_max_workers"`
			ZstHolidayMode      bool   `json:"zst_holiday_mode"`
			ZstPriority         int    `json:"zst_priority"`
			ZstParentTemplateID *int   `json:"zst_parent_template_id,omitempty"`
			HolidayIds          []int  `json:"holiday_ids"`
			DepartmentIds       []int  `json:"department_ids"`
			MustHaveRoles       []struct {
				RoleID int `json:"role_id"`
				Count  int `json:"count"`
			} `json:"zst_must_have_roles"`
			NiceToHaveRoles []struct {
				RoleID int `json:"role_id"`
				Count  int `json:"count"`
			} `json:"zst_nice_to_have_roles"`
		} `json:"shift_templates"`
		Holidays []struct {
			ID              int    `json:"id"`
			CalculationType string `json:"calculation_type"`
			Month           int    `json:"month"`
			Day             int    `json:"day"`
			EasterOffset    int    `json:"easter_offset"`
		} `json:"holidays"`
	} `json:"payload"`
}

type RoleAssessment struct {
	RoleId                   int     `json:"roleId"`
	RequiredMinutes          int     `json:"requiredMinutes"`
	NiceMinutes              int     `json:"niceMinutes"`
	CapacityMinutes          int     `json:"capacityMinutes"`
	RequiredShortfallMinutes int     `json:"requiredShortfallMinutes"`
	NiceShortfallMinutes     int     `json:"niceShortfallMinutes"`
	FTENeededForRequired     float64 `json:"fteNeededForRequired"`
	FTENeededForNice         float64 `json:"fteNeededForNice"`
	FTENeededTotal           float64 `json:"fteNeededTotal"`
}

type WeekRollup struct {
	WeekStartISO    string `json:"weekStartISO"`
	RequiredMinutes int    `json:"requiredMinutes"`
	NiceMinutes     int    `json:"niceMinutes"`
	CapacityMinutes int    `json:"capacityMinutes"`
}

type Overall struct {
	RequiredCoveragePct float64 `json:"requiredCoveragePct"`
	NiceCoveragePct     float64 `json:"niceCoveragePct"`
	ReserveCoveragePct  float64 `json:"reserveCoveragePct"`
	ProgressScore       float64 `json:"progressScore"`
}

type AssessResponse struct {
	RunId        string `json:"runId"`
	CreatedAtISO string `json:"createdAtISO"`
	Window       struct {
		StartISO string `json:"startISO"`
		EndISO   string `json:"endISO"`
	} `json:"window"`
	ReserveTargetPct float64          `json:"reserveTargetPct"`
	Overall          Overall          `json:"overall"`
	Roles            []RoleAssessment `json:"roles"`
	Weeks            []WeekRollup     `json:"weeks"`
	Assumptions      []string         `json:"assumptions"`

	// Optional: echo minimal input back if you want (you can remove this if not needed)
	// InputPayload any `json:"input_payload,omitempty"`
}

type roleWeekKey struct {
	Role int
	Wk   string // YYYY-MM-DD
}

type Holiday struct {
	ID               int
	CalculationType  string // "fixed" or "easter_relative"
	Month            int    // for fixed dates
	Day              int    // for fixed dates
	EasterOffset     int    // for easter-relative dates
}

func main() {
	runID := flag.String("run-id", "capacity", "run id")
	reserveTarget := flag.Float64("reserve-pct", 0.10, "target extra buffer (0.10 = 10%)")
	flag.Parse()

	var env InputEnvelope
	if err := json.NewDecoder(os.Stdin).Decode(&env); err != nil && err != io.EOF {
		os.Exit(2)
	}

	berlin, err := time.LoadLocation("Europe/Berlin")
	if err != nil {
		berlin = time.UTC
	}

	planStart, planEnd := monthWindow(env.Payload.Year, env.Payload.Month, berlin)

	now := time.Now().UTC()
	resp := AssessResponse{
		RunId:            *runID,
		CreatedAtISO:     now.Format(time.RFC3339),
		ReserveTargetPct: *reserveTarget,
		Assumptions: []string{
			"day_mask bits: Mon=1 Tue=2 Wed=4 Thu=8 Fri=16 Sat=32 Sun=64.",
			"Users are treated as fully available (no availability/timeoff provided).",
			"Capacity is bounded by weekly required_hours_per_week per user.",
			"Per-role REQUIRED demand = paidMinutes * must_have.count (no min_workers multiplier).",
			"Per-role NICE demand = paidMinutes * nice_to_have.count. Extra staffing band (max-min) is untyped and not assigned to roles.",
			"Holiday templates override their parent templates on specific holiday dates.",
			"FTE calculations always round UP to ensure adequate staffing (e.g., 2.56 people → 3 people).",
		},
	}
	resp.Window.StartISO = planStart.Format(time.RFC3339)
	resp.Window.EndISO = planEnd.Format(time.RFC3339)

	// Demand aggregation
	roleReq := map[int]int{}
	roleNice := map[int]int{}
	roleWeeksReq := map[roleWeekKey]int{}
	roleWeeksNice := map[roleWeekKey]int{}
	totalReq := 0
	totalNice := 0

	// Collect which roles exist in demand (so capacity only distributes across demanded roles).
	demandRoles := map[int]bool{}

	// Build week keys (Monday start) over the month window
	weekKeys := []string{}
	for ws := weekStartMonday(planStart); !ws.After(weekStartMonday(planEnd)); ws = ws.AddDate(0, 0, 7) {
		weekKeys = append(weekKeys, ws.Format("2006-01-02"))
	}

	// Build holiday data map for quick lookup
	holidayData := make(map[int]Holiday)
	for _, h := range env.Payload.Holidays {
		holidayData[h.ID] = Holiday{
			ID:              h.ID,
			CalculationType: h.CalculationType,
			Month:           h.Month,
			Day:             h.Day,
			EasterOffset:    h.EasterOffset,
		}
	}

	// For each day in month, instantiate templates that apply, compute required/nice minutes per role.
	issues := []string{}

	for day := startOfDay(planStart); !day.After(startOfDay(planEnd)); day = day.AddDate(0, 0, 1) {
		dow := day.Weekday()

		// Determine which templates are overridden by holiday templates on this day
		overriddenParentIDs := make(map[int]bool)
		for _, st := range env.Payload.ShiftTemplates {
			if st.ZstHolidayMode && st.ZstParentTemplateID != nil {
				// This is a holiday override template - check if it applies to this day
				if isHolidayDate(day, st.HolidayIds, holidayData) {
					overriddenParentIDs[*st.ZstParentTemplateID] = true
				}
			}
		}

		for _, st := range env.Payload.ShiftTemplates {
			// Skip parent templates on days when they are overridden by holiday templates
			if !st.ZstHolidayMode && overriddenParentIDs[st.ZstID] {
				continue
			}

			// Holiday override templates only apply on their specific holiday dates
			if st.ZstHolidayMode && st.ZstParentTemplateID != nil {
				if !isHolidayDate(day, st.HolidayIds, holidayData) {
					continue
				}
			}

			// Regular templates use day mask
			if !st.ZstHolidayMode && !maskHasDay(st.ZstDayMask, dow) {
				continue
			}

			start, end, ok := buildShiftInterval(day, st.ZstStartTime, st.ZstEndTime, berlin)
			if !ok || !end.After(start) {
				continue
			}
			// keep within month window
			if !intervalIntersects(start, end, planStart, planEnd.Add(24*time.Hour)) {
				continue
			}

			total := minutesBetween(start, end)
			if total <= 0 {
				continue
			}
			paid := total - mandatoryBreak(total)
			if paid <= 0 {
				continue
			}

			minW := st.ZstMinWorkers
			if minW < 0 {
				minW = 0
			}
			maxW := st.ZstMaxWorkers
			if maxW < minW {
				maxW = minW
			}

			wk := weekStartMonday(start).Format("2006-01-02")

			// Validate: sum(must.count) must be <= min_workers
			sumMust := 0
			for _, rr := range st.MustHaveRoles {
				if rr.Count > 0 {
					sumMust += rr.Count
				}
			}
			if sumMust > minW {
				issues = append(issues,
					"Invalid template (auto-fixed): zst_id="+itoa(st.ZstID)+
						" name="+st.ZstName+
						" has sum(must_have.count)="+itoa(sumMust)+
						" > min_workers="+itoa(minW)+
						" (cannot be satisfied). min_workers was bumped to sumMust.")
				minW = sumMust
				if maxW < minW {
					maxW = minW
				}
			}

			// Per-role REQUIRED demand: must-have counts only
			for _, rr := range st.MustHaveRoles {
				cnt := rr.Count
				if cnt <= 0 {
					continue
				}
				demandRoles[rr.RoleID] = true

				reqMin := paid * cnt
				roleReq[rr.RoleID] += reqMin
				roleWeeksReq[roleWeekKey{Role: rr.RoleID, Wk: wk}] += reqMin
				totalReq += reqMin
			}

			// Per-role NICE demand: nice-to-have counts only
			for _, rr := range st.NiceToHaveRoles {
				cnt := rr.Count
				if cnt <= 0 {
					continue
				}
				demandRoles[rr.RoleID] = true

				niceMin := paid * cnt
				roleNice[rr.RoleID] += niceMin
				roleWeeksNice[roleWeekKey{Role: rr.RoleID, Wk: wk}] += niceMin
				totalNice += niceMin
			}

			// NOTE: (maxW - minW) is untyped optional staffing; not assigned to roles here.
		}
	}

	if len(issues) > 0 {
		resp.Assumptions = append(resp.Assumptions, "DATA ISSUES:")
		resp.Assumptions = append(resp.Assumptions, issues...)
	}

	// Employee weekly budgets + role sets
	type Emp struct {
		Roles            map[int]bool
		WeeklyTargetMins int
	}
	emps := make([]Emp, 0, len(env.Payload.Users))
	for _, u := range env.Payload.Users {
		roleSet := map[int]bool{}
		for _, r := range u.Roles {
			roleSet[r] = true
		}
		target := int(math.Round(u.RequiredHoursPerWeek * 60.0))
		if target <= 0 {
			target = int(math.Round(38.5 * 60.0))
		}
		emps = append(emps, Emp{Roles: roleSet, WeeklyTargetMins: target})
	}

	// Capacity distribution:
	// For each week, each employee distributes their weekly budget across demanded roles they can do.
	roleWeekCap := map[roleWeekKey]int{}
	roleCapTotal := map[int]int{}
	totalCap := 0

	roleList := rolesFromMap(demandRoles)
	if len(roleList) == 0 {
		resp.Overall = Overall{
			RequiredCoveragePct: 1,
			NiceCoveragePct:     1,
			ReserveCoveragePct:  1,
			ProgressScore:       1,
		}
		resp.Roles = []RoleAssessment{}
		resp.Weeks = []WeekRollup{}
		writeJSON(resp)
		return
	}

	for _, wk := range weekKeys {
		for _, e := range emps {
			eligible := make([]int, 0, len(roleList))
			for _, rid := range roleList {
				if e.Roles[rid] {
					eligible = append(eligible, rid)
				}
			}
			if len(eligible) == 0 {
				continue
			}
			share := int(math.Round(float64(e.WeeklyTargetMins) / float64(len(eligible))))
			if share < 0 {
				share = 0
			}
			for _, rid := range eligible {
				k := roleWeekKey{Role: rid, Wk: wk}
				roleWeekCap[k] += share
				roleCapTotal[rid] += share
				totalCap += share
			}
		}
	}

	// Weekly rollups
	weeksOut := make([]WeekRollup, 0, len(weekKeys))
	for _, wk := range weekKeys {
		reqSum := 0
		niceSum := 0
		capSum := 0
		for _, rid := range roleList {
			reqSum += roleWeeksReq[roleWeekKey{Role: rid, Wk: wk}]
			niceSum += roleWeeksNice[roleWeekKey{Role: rid, Wk: wk}]
			capSum += roleWeekCap[roleWeekKey{Role: rid, Wk: wk}]
		}
		ws, _ := time.ParseInLocation("2006-01-02", wk, berlin)
		weeksOut = append(weeksOut, WeekRollup{
			WeekStartISO:    ws.Format(time.RFC3339),
			RequiredMinutes: reqSum,
			NiceMinutes:     niceSum,
			CapacityMinutes: capSum,
		})
	}
	resp.Weeks = weeksOut

	// Per-role assessments
	rolesOut := make([]RoleAssessment, 0, len(roleList))
	for _, rid := range roleList {
		reqMin := roleReq[rid]
		niceMin := roleNice[rid]
		cap := roleCapTotal[rid]

		reqShort := maxInt(0, reqMin-cap)
		rem := maxInt(0, cap-reqMin)
		niceShort := maxInt(0, niceMin-rem)

		fteBase := 38.5 * 60.0
		fteReq := float64(reqShort) / fteBase
		fteNice := float64(niceShort) / fteBase

		rolesOut = append(rolesOut, RoleAssessment{
			RoleId:                   rid,
			RequiredMinutes:          reqMin,
			NiceMinutes:              niceMin,
			CapacityMinutes:          cap,
			RequiredShortfallMinutes: reqShort,
			NiceShortfallMinutes:     niceShort,
			FTENeededForRequired:     math.Ceil(fteReq),
			FTENeededForNice:         math.Ceil(fteNice),
			FTENeededTotal:           math.Ceil(fteReq + fteNice),
		})
	}
	sort.Slice(rolesOut, func(i, j int) bool {
		return rolesOut[i].RequiredShortfallMinutes+rolesOut[i].NiceShortfallMinutes >
			rolesOut[j].RequiredShortfallMinutes+rolesOut[j].NiceShortfallMinutes
	})
	resp.Roles = rolesOut

	// Overall coverage & progress
	requiredCov := pctSafe(minInt(totalCap, totalReq), totalReq)
	remAfterReq := maxInt(0, totalCap-totalReq)
	niceCov := pctSafe(minInt(remAfterReq, totalNice), totalNice)

	totalDemand := totalReq + totalNice
	reserveTargetMin := int(math.Round(float64(totalDemand) * (*reserveTarget)))
	remAfterNice := maxInt(0, remAfterReq-totalNice)
	reserveCov := pctSafe(minInt(remAfterNice, reserveTargetMin), reserveTargetMin)

	progress := score(requiredCov, niceCov, reserveCov)

	resp.Overall = Overall{
		RequiredCoveragePct: round2(requiredCov),
		NiceCoveragePct:     round2(niceCov),
		ReserveCoveragePct:  round2(reserveCov),
		ProgressScore:       round2(progress),
	}

	writeJSON(resp)
}

func writeJSON(v any) {
	enc := json.NewEncoder(os.Stdout)
	enc.SetEscapeHTML(false)
	_ = enc.Encode(v)
}

func monthWindow(year int, month int, loc *time.Location) (time.Time, time.Time) {
	if year <= 0 || month < 1 || month > 12 {
		now := time.Now().In(loc)
		year = now.Year()
		month = int(now.Month())
	}
	start := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, loc)
	end := start.AddDate(0, 1, 0).Add(-time.Nanosecond)
	return start, end
}

func startOfDay(t time.Time) time.Time {
	y, m, d := t.Date()
	return time.Date(y, m, d, 0, 0, 0, 0, t.Location())
}

func weekStartMonday(t time.Time) time.Time {
	wd := int(t.Weekday()) // 0=Sun
	if wd == 0 {
		wd = 7
	}
	d := t.AddDate(0, 0, -(wd - 1))
	y, m, day := d.Date()
	return time.Date(y, m, day, 0, 0, 0, 0, t.Location())
}

func minutesBetween(a, b time.Time) int {
	return int(b.Sub(a).Minutes())
}

func mandatoryBreak(minutes int) int {
	if minutes > 9*60 {
		return 45
	}
	if minutes > 6*60 {
		return 30
	}
	return 0
}

func hhmmToParts(hhmm string) (int, int, bool) {
	if len(hhmm) != 5 || hhmm[2] != ':' {
		return 0, 0, false
	}
	h := int(hhmm[0]-'0')*10 + int(hhmm[1]-'0')
	m := int(hhmm[3]-'0')*10 + int(hhmm[4]-'0')
	if h < 0 || h > 23 || m < 0 || m > 59 {
		return 0, 0, false
	}
	return h, m, true
}

func buildShiftInterval(day time.Time, startHHMM string, endHHMM string, loc *time.Location) (time.Time, time.Time, bool) {
	sh, sm, ok1 := hhmmToParts(startHHMM)
	eh, em, ok2 := hhmmToParts(endHHMM)
	if !ok1 || !ok2 {
		return time.Time{}, time.Time{}, false
	}
	start := time.Date(day.Year(), day.Month(), day.Day(), sh, sm, 0, 0, loc)
	end := time.Date(day.Year(), day.Month(), day.Day(), eh, em, 0, 0, loc)
	if !end.After(start) {
		end = end.AddDate(0, 0, 1)
	}
	return start, end, true
}

func intervalIntersects(aStart, aEnd, bStart, bEnd time.Time) bool {
	start := aStart
	if bStart.After(start) {
		start = bStart
	}
	end := aEnd
	if bEnd.Before(end) {
		end = bEnd
	}
	return end.After(start)
}

// Mon=1..Sun=64
func maskHasDay(mask int, day time.Weekday) bool {
	var bit int
	switch day {
	case time.Monday:
		bit = 1
	case time.Tuesday:
		bit = 2
	case time.Wednesday:
		bit = 4
	case time.Thursday:
		bit = 8
	case time.Friday:
		bit = 16
	case time.Saturday:
		bit = 32
	case time.Sunday:
		bit = 64
	default:
		bit = 0
	}
	return (mask & bit) != 0
}

func rolesFromMap(m map[int]bool) []int {
	out := make([]int, 0, len(m))
	for k := range m {
		out = append(out, k)
	}
	sort.Ints(out)
	return out
}

func round2(f float64) float64 {
	return math.Round(f*100) / 100
}

func pctSafe(num, den int) float64 {
	if den <= 0 {
		if num > 0 {
			return 1.0
		}
		return 1.0
	}
	p := float64(num) / float64(den)
	if p < 0 {
		return 0
	}
	if p > 1 {
		return 1
	}
	return p
}

func score(reqCov, niceCov, reserveCov float64) float64 {
	base := 0.6*reqCov + 0.3*niceCov + 0.1*reserveCov
	if reqCov < 1.0 {
		maxAllowed := 0.6 * reqCov
		if base > maxAllowed {
			return maxAllowed
		}
	}
	return base
}

func maxInt(a, b int) int {
	if a > b {
		return a
	}
	return b
}

func minInt(a, b int) int {
	if a < b {
		return a
	}
	return b
}

func itoa(n int) string {
	return strconv.FormatInt(int64(n), 10)
}

// calculateEaster calculates Easter Sunday for a given year using the Computus algorithm (Gauss formula).
func calculateEaster(year int) time.Time {
	a := year % 19
	b := year / 100
	c := year % 100
	d := b / 4
	e := b % 4
	f := (b + 8) / 25
	g := (b - f + 1) / 3
	h := (19*a + b - d - g + 15) % 30
	i := c / 4
	k := c % 4
	l := (32 + 2*e + 2*i - h - k) % 7
	m := (a + 11*h + 22*l) / 451
	month := (h + l - 7*m + 114) / 31
	day := ((h + l - 7*m + 114) % 31) + 1

	return time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC)
}

// isHolidayDate checks if a given date is covered by a holiday ID.
// Note: For simplicity, we need the holiday data. Since PHP sends holiday_ids but not holiday details,
// we'll need to fetch holiday data from the database or have PHP include it in the payload.
// For now, we'll implement a placeholder that always returns false - needs enhancement.
func isHolidayDate(date time.Time, holidayIds []int, holidayData map[int]Holiday) bool {
	if len(holidayIds) == 0 || len(holidayData) == 0 {
		return false
	}

	for _, hid := range holidayIds {
		holiday, ok := holidayData[hid]
		if !ok {
			continue
		}

		var holidayDate time.Time
		if holiday.CalculationType == "easter_relative" {
			easter := calculateEaster(date.Year())
			holidayDate = easter.AddDate(0, 0, holiday.EasterOffset)
		} else {
			// Fixed date - check for leap year edge case
			if holiday.Month == 2 && holiday.Day == 29 && !isLeapYear(date.Year()) {
				continue
			}
			holidayDate = time.Date(date.Year(), time.Month(holiday.Month), holiday.Day, 0, 0, 0, 0, time.UTC)
		}

		if isSameDay(date, holidayDate) {
			return true
		}
	}

	return false
}

// isSameDay checks if two times represent the same calendar day
func isSameDay(a, b time.Time) bool {
	ay, am, ad := a.Date()
	by, bm, bd := b.Date()
	return ay == by && am == bm && ad == bd
}

// isLeapYear checks if a year is a leap year
func isLeapYear(year int) bool {
	return year%4 == 0 && (year%100 != 0 || year%400 == 0)
}
