import calendar
import logging
from datetime import date, timedelta

from odoo import _, api, fields, models
from odoo.exceptions import UserError, ValidationError
from odoo.fields import Datetime

_logger = logging.getLogger(__name__)

HR_OFFICER_GROUP = "hr_holidays.group_hr_holidays_user"

# Once a leave request exists, only HR may change its actual content
# (dates/type/reason/duration) -- the requester's only remaining
# self-service action is Cancel (handled separately via can_cancel /
# the cancel wizard, which runs sudo()'d and so is unaffected by this).
_XENO_LOCKED_LEAVE_FIELDS = {
    "employee_id", "holiday_status_id", "name",
    "request_date_from", "request_date_to",
    "request_date_from_period", "request_date_to_period",
    "request_hour_from", "request_hour_to", "xeno_request_unit",
}


class HrLeave(models.Model):
    _inherit = "hr.leave"

    xeno_can_edit_content = fields.Boolean(
        compute="_compute_xeno_can_edit_content",
        help="Whether the current user may edit this leave's actual "
             "content (dates/type/reason/duration). True while the "
             "request is still being drafted (no id yet) or for Time "
             "Off Officers; false for the requester once it's saved --"
             "they can only Cancel it from then on.",
    )

    @api.depends_context("uid")
    def _compute_xeno_can_edit_content(self):
        is_officer = self.env.user.has_group(HR_OFFICER_GROUP)
        for leave in self:
            # In the web client's form-editing context records are virtual
            # NewId objects, which are falsy even when they wrap a real
            # saved record -- checking leave.id alone would make saved
            # records look like drafts in the browser and render every
            # field editable there. _origin resolves back to the real
            # record (empty for genuinely new drafts).
            leave.xeno_can_edit_content = is_officer or not leave._origin.id

    xeno_approval_step_ids = fields.One2many(
        "xeno.leave.approval.step", "leave_id", string="Approval Chain")

    xeno_welcome_back_sent = fields.Boolean(
        default=False, copy=False,
        help="Guards _cron_xeno_welcome_back_from_maternity from posting "
             "the new-baby message more than once for the same leave.")

    xeno_source_holiday_id = fields.Many2one(
        "resource.calendar.leaves", string="Deducted For Holiday",
        copy=False, index=True,
        help="Set only for leaves auto-created by a Company Holiday "
             "(xeno_holidays' automatic hook, not the manual wizard, which "
             "isn't tied to one specific holiday record). Lets deleting "
             "that holiday find and reverse exactly the leaves it caused, "
             "see resource.calendar.leaves.unlink().")

    xeno_is_system_deduction = fields.Boolean(
        default=False, copy=False,
        help="True for any leave created by a system-driven deduction "
             "rather than an employee's own request -- currently only "
             "_xeno_deduct_for_company_holiday (manual wizard + "
             "xeno_holidays' automatic hook both set this), but the same "
             "flag is meant to be set by any future deduction source too "
             "(e.g. a planned Late Deduction feature, not yet built). "
             "Someone else chose the employee/type/days directly, so the "
             "Analysis checklist's frequency/pace checks don't apply.")

    xeno_system_deduction_type = fields.Selection(
        [("company_holiday", "Company Holiday Deduction"),
         ("late_deduction", "Late Deduction")],
        copy=False,
        help="Which kind of system deduction this is (blank for an "
             "employee's own request) -- lets the Leave List page's two "
             "separate 'Show ... deductions' checkboxes filter each kind "
             "independently. 'late_deduction' isn't produced by any "
             "feature yet; reserved for a planned Late Deduction feature.")

    @api.model_create_multi
    def create(self, vals_list):
        for vals in vals_list:
            self._xeno_check_leave_timing(vals)
            self._xeno_check_weekend_dates(vals)
            self._xeno_check_holiday_dates(vals)
            self._xeno_check_activity_restricted_dates(vals)
            # keep server-side creates consistent with the form's onchange:
            # the half-day basis dictates the period, callers can't disagree
            if vals.get("xeno_request_unit") == "first_half":
                vals["request_date_from_period"] = "am"
                vals["request_date_to_period"] = "am"
            elif vals.get("xeno_request_unit") == "second_half":
                vals["request_date_from_period"] = "pm"
                vals["request_date_to_period"] = "pm"
        records = super().create(vals_list)
        # Pro-rated balance check runs before chain-building so a blocked
        # request never fires approver step emails (the raise rolls the
        # whole create back regardless, but this keeps side effects clean).
        records._xeno_check_prorated_balance()
        records._xeno_sync_allocation_used_days()
        records._xeno_build_approval_chain()
        return records

    def _compute_duration(self):
        """A company holiday is, by this company's own policy, not a paid
        non-working day -- HR deducts a real leave day for it instead (see
        wizard/leave_holiday_deduct.py). Odoo's own calendar-aware duration
        computation (core's _compute_duration/_get_durations) always
        returns 0 for a date that's a declared public holiday, since there
        are no working hours to overlap -- correct for a normal leave
        request, wrong for this specific deliberate-deduction flow. Only
        for records created via that wizard (xeno_force_full_day_duration
        in context) does this override the computed 0 with a real day
        count and the employee's own average daily hours; every other
        leave keeps core's genuine calendar-based computation untouched.
        """
        super()._compute_duration()
        if self.env.context.get("xeno_force_full_day_duration"):
            for leave in self:
                if not leave.number_of_days and leave.request_date_from and leave.request_date_to:
                    days = (leave.request_date_to - leave.request_date_from).days + 1
                    hours_per_day = leave.employee_id.resource_calendar_id.hours_per_day or 8.0
                    leave.number_of_days = days
                    leave.number_of_hours = days * hours_per_day

        # Late check-in deductions are a fractional day (e.g. 47 minutes
        # over the monthly grace period / 480 minutes-per-day = 0.098),
        # not a whole-day count -- core's calendar-based computation has
        # no concept of "a fraction of today", so this bypasses it with
        # an explicit value the caller already computed
        # (_xeno_process_late_deductions), same override technique as
        # xeno_force_full_day_duration above.
        forced_days = self.env.context.get("xeno_force_number_of_days")
        if forced_days is not None:
            for leave in self:
                hours_per_day = leave.employee_id.resource_calendar_id.hours_per_day or 8.0
                leave.number_of_days = forced_days
                leave.number_of_hours = forced_days * hours_per_day

    def _xeno_build_approval_chain(self):
        """Materialize this leave's approval chain from the configured
        department/employee approver steps (xeno.leave.approver.build_chain),
        snapshotting it onto xeno.leave.approval.step -- mirrors XENHR
        creating LeaveApprovalStep rows on submission. Only for leaves that
        actually need multi-step approval (state == 'confirm'); a leave
        created directly in another state (e.g. by a data import) gets no
        chain and falls back to legacy behavior everywhere it's checked.
        """
        if self.env.context.get("xeno_hr_direct_create"):
            # HR creating a leave directly on an employee's behalf (see
            # wizard/leave_hr_create.py) skips the approval chain
            # entirely, not just a bypassed/pre-approved one -- mirrors
            # XENHR's storeForEmployee, which never materializes
            # LeaveApprovalStep rows for this path either.
            return
        Approver = self.env["xeno.leave.approver"].sudo()
        Step = self.env["xeno.leave.approval.step"].sudo()
        for leave in self:
            if leave.state != "confirm" or not leave.employee_id:
                continue
            chain = Approver.build_chain(leave.employee_id)
            Step.create([{
                "leave_id": leave.id,
                "step_order": step["step_order"],
                "label": step["label"],
                "approver_user_id": step["approver_user_id"],
            } for step in chain])
            first_step = leave._xeno_current_pending_step()
            if first_step:
                leave._xeno_send_step_email(first_step)

    def write(self, vals):
        if not self.env.su and not self.env.user.has_group(HR_OFFICER_GROUP):
            locked_touched = set(vals) & _XENO_LOCKED_LEAVE_FIELDS
            if locked_touched and self._xeno_validation_enabled("locked_fields"):
                raise UserError(_(
                    "A submitted leave request can no longer be edited "
                    "directly. Cancel it and submit a new one, or ask HR "
                    "to update it."))
        res = super().write(vals)
        if "state" in vals:
            self._xeno_sync_allocation_used_days()
        return res

    def _xeno_sync_allocation_used_days(self):
        """Keep xeno.leave.allocation's "Used" column (see the Leave Balance
        Grid pivot view) in sync with actually-validated leave. Mirrors
        XENHR's leave_balances.used_days -- a value HR can also manually
        correct on the allocation, this just keeps it accurate by default
        rather than requiring HR to update it by hand every time.
        """
        Alloc = self.env["hr.leave.allocation"].sudo()
        Leave = self.env["hr.leave"].sudo()
        pairs = {(l.employee_id.id, l.holiday_status_id.id) for l in self
                 if l.employee_id and l.holiday_status_id}
        for emp_id, type_id in pairs:
            total = sum(Leave.search([
                ("employee_id", "=", emp_id),
                ("holiday_status_id", "=", type_id),
                ("state", "=", "validate"),
            ]).mapped("number_of_days"))
            Alloc.search([
                ("employee_id", "=", emp_id),
                ("holiday_status_id", "=", type_id),
            ]).write({"xeno_used_days": total})

    @api.model
    def _xeno_validation_enabled(self, code):
        """Whether an admin-toggleable leave validation (see the "Leave
        Validations" page / xeno.leave.validation.rule) is currently on.
        Fail safe: a missing row, or a rule that isn't a 'toggle' kind, is
        always enforced -- only an explicitly-disabled toggle rule is
        skipped.
        """
        rule = self.env["xeno.leave.validation.rule"].sudo().search(
            [("code", "=", code), ("kind", "=", "toggle")], limit=1)
        return (not rule) or rule.enabled

    def _xeno_check_leave_timing(self, vals):
        """Enforce per-leave-type advance-notice / max-backdate limits.

        Mirrors XENHR's LeaveApplicationController@store validation exactly
        (same priority: advance_days, else max_backdate_days, else no past
        dates at all) -- but only for self-service creation. XENHR's
        storeForEmployee (HR/admin creating *for someone else*) skips this
        check entirely, so here it's skipped only when a Time Off Officer
        is creating a request for a *different* employee than their own --
        NOT simply whenever the acting user happens to hold that role.
        An HR officer requesting their own leave through the normal
        self-service flow must still be checked like anyone else (bug
        found in testing: susu, an HR/admin account, could book Annual
        Leave for tomorrow because the old check bypassed on role alone).

        The one unconditional bypass is xeno_hr_direct_create: that context
        is set only by HR-initiated on-behalf creation (the Create Leave
        (HR) wizard and the holiday-deduction feature), which XENHR's
        storeForEmployee explicitly exempts from the timing limits -- these
        are deliberately allowed to backdate, regardless of whose employee
        record it is (the wizard's target can even coincide with the acting
        HR user's own employee, which is exactly the case that slipped
        through the role/target heuristic below).
        """
        if not self._xeno_validation_enabled("timing"):
            return
        if self.env.context.get("xeno_hr_direct_create"):
            return
        if self.env.user.has_group(HR_OFFICER_GROUP):
            target_employee_id = vals.get("employee_id")
            own_employee_id = self.env.user.employee_id.id
            if target_employee_id and target_employee_id != own_employee_id:
                return
        leave_type_id = vals.get("holiday_status_id")
        request_date_from = vals.get("request_date_from")
        if not leave_type_id or not request_date_from:
            return
        leave_type = self.env["hr.leave.type"].sudo().browse(leave_type_id)
        start_date = fields.Date.to_date(request_date_from)
        today = fields.Date.context_today(self)
        advance_days = leave_type.xeno_advance_days or 0
        max_backdate_days = leave_type.xeno_max_backdate_days or 0

        if advance_days > 0:
            earliest = today + timedelta(days=advance_days)
            if start_date < earliest:
                raise ValidationError(_(
                    "%(type)s requires at least %(days)s day(s) advance "
                    "notice. Earliest allowed start date is %(earliest)s.",
                    type=leave_type.name, days=advance_days, earliest=earliest))
        elif max_backdate_days > 0:
            earliest = today - timedelta(days=max_backdate_days)
            if start_date < earliest:
                raise ValidationError(_(
                    "%(type)s allows backdating up to %(days)s day(s). "
                    "Earliest allowed start date is %(earliest)s.",
                    type=leave_type.name, days=max_backdate_days, earliest=earliest))
        else:
            if start_date < today:
                raise ValidationError(_(
                    "Start date must be today or in the future for %(type)s.",
                    type=leave_type.name))

    def _xeno_check_weekend_dates(self, vals):
        """The company is closed Saturday/Sunday -- picking a weekend as the
        request's start or end date is almost always an accidental
        mis-click on the date picker, not an intended non-working day (a
        multi-day request that merely *spans* a weekend in the middle,
        e.g. Friday to Monday, is untouched -- only the two edges the
        employee actually picked are checked). Blocks with a clear alert
        naming the offending date(s) rather than silently letting core's
        calendar-aware duration computation count it as 0 days.

        Same bypasses as _xeno_check_leave_timing: HR on-behalf creation
        (xeno_hr_direct_create), an Officer creating for a different
        employee, and any system-driven deduction (xeno_is_system_deduction
        -- e.g. the company-holiday deduction, which deliberately targets
        specific calendar dates HR chose, not something the employee picked).
        """
        if not self._xeno_validation_enabled("weekend_dates"):
            return
        if self.env.context.get("xeno_hr_direct_create") or vals.get("xeno_is_system_deduction"):
            return
        if self.env.user.has_group(HR_OFFICER_GROUP):
            target_employee_id = vals.get("employee_id")
            own_employee_id = self.env.user.employee_id.id
            if target_employee_id and target_employee_id != own_employee_id:
                return
        start = vals.get("request_date_from")
        if not start:
            return
        end = vals.get("request_date_to") or start
        start_date = fields.Date.to_date(start)
        end_date = fields.Date.to_date(end)
        weekday_names = {5: _("Saturday"), 6: _("Sunday")}

        flagged = []
        if start_date.weekday() in weekday_names:
            flagged.append("%s (%s)" % (start_date, weekday_names[start_date.weekday()]))
        if end_date != start_date and end_date.weekday() in weekday_names:
            flagged.append("%s (%s)" % (end_date, weekday_names[end_date.weekday()]))
        if flagged:
            raise ValidationError(_(
                "The company is closed on weekends. The date you selected "
                "-- %s -- falls on a weekend. Please check the date and "
                "try again.", ", ".join(flagged)))

    def _xeno_check_holiday_dates(self, vals):
        """A declared public/company holiday is already a non-working,
        unpaid-leave-exempt day by this company's policy (see
        _compute_duration's own docstring) -- HR deducts a real leave day
        for it separately via the Holiday Deduction wizard when that's
        actually intended, so a self-service request picking a holiday as
        its own start/end date is (like a weekend pick) almost always an
        accidental date-picker mis-click, not something the employee meant.
        Same edges-only semantics as _xeno_check_weekend_dates: a multi-day
        request that merely spans a holiday in the middle is unaffected,
        only the two dates the employee actually picked are checked.

        Same bypasses as the other submission-time checks: HR on-behalf
        creation (xeno_hr_direct_create -- this is exactly the path the
        Holiday Deduction wizard itself uses), an Officer creating for a
        different employee, and any system-driven deduction
        (xeno_is_system_deduction).
        """
        if not self._xeno_validation_enabled("holiday_dates"):
            return
        if self.env.context.get("xeno_hr_direct_create") or vals.get("xeno_is_system_deduction"):
            return
        if self.env.user.has_group(HR_OFFICER_GROUP):
            target_employee_id = vals.get("employee_id")
            own_employee_id = self.env.user.employee_id.id
            if target_employee_id and target_employee_id != own_employee_id:
                return
        start = vals.get("request_date_from")
        if not start:
            return
        end = vals.get("request_date_to") or start
        start_date = fields.Date.to_date(start)
        end_date = fields.Date.to_date(end)

        check_dates = {start_date}
        if end_date != start_date:
            check_dates.add(end_date)
        holidays = self.env["resource.calendar.leaves"].sudo().search([
            ("resource_id", "=", False),
            ("date_from", "<=", "%s 23:59:59" % max(check_dates)),
            ("date_to", ">=", "%s 00:00:00" % min(check_dates)),
        ])
        flagged = []
        for d in sorted(check_dates):
            holiday = holidays.filtered(
                lambda h, d=d: h.date_from.date() <= d <= h.date_to.date())[:1]
            if holiday:
                flagged.append("%s (%s)" % (d, holiday.name))
        if flagged:
            raise ValidationError(_(
                "The date you selected -- %s -- is a declared company "
                "holiday. Please check the date and try again; if you "
                "genuinely need this recorded as a leave day, ask HR.",
                ", ".join(flagged)))

    def _xeno_check_activity_restricted_dates(self, vals):
        """Blocks a self-service leave request whose date range overlaps a
        company activity flagged Restrict Leave on These Dates (e.g. a
        mandatory Guest Visit day -- see xeno.hr.activity in
        xeno_theme_slate, whose xeno_leave already depends on).

        This is deliberately NOT an emergency override: there is no
        in-system way for the employee to bypass this themselves. A
        genuine emergency has to go through HR directly, who can still
        create the leave on the employee's behalf via the exact same
        on-behalf paths this check already exempts (xeno_hr_direct_create /
        an Officer creating for a different employee) -- the system does
        not auto-approve emergency overrides, matching the requested
        design exactly.
        """
        if not self._xeno_validation_enabled("activity_restricted_dates"):
            return
        if self.env.context.get("xeno_hr_direct_create") or vals.get("xeno_is_system_deduction"):
            return
        if self.env.user.has_group(HR_OFFICER_GROUP):
            target_employee_id = vals.get("employee_id")
            own_employee_id = self.env.user.employee_id.id
            if target_employee_id and target_employee_id != own_employee_id:
                return
        start = vals.get("request_date_from")
        if not start:
            return
        end = vals.get("request_date_to") or start
        start_date = fields.Date.to_date(start)
        end_date = fields.Date.to_date(end)
        activities = self.env["xeno.hr.activity"].sudo().search([
            ("is_leave_restricted", "=", True),
            ("date_from", "<=", end_date),
            ("date_to", ">=", start_date),
        ])
        if activities:
            names = ", ".join(
                "%s (%s to %s)" % (a.name, a.date_from, a.date_to) for a in activities)
            raise ValidationError(_(
                "Leave cannot be requested for this period -- it overlaps a "
                "restricted company activity: %s. If this is a genuine "
                "emergency, contact HR directly; this cannot be "
                "self-submitted or auto-approved.", names))

    def _xeno_check_prorated_balance(self):
        """Block a self-service request that exceeds the pro-rated monthly
        entitlement of a pro-rated leave type (hr.leave.type.xeno_prorated).

        A pro-rated type's yearly allocation is treated as accruing evenly:
        per_month = yearly_total / 12, so by the request's month M the
        employee has accrued per_month * M days. The request is rejected if
        it would push the total already used/booked this year (up to and
        including month M) past that accrued amount. When blocked, the error
        also lists alternative leave types the employee actually has balance
        in, so it guides rather than dead-ends (see
        _xeno_prorated_alternatives).

        Bypassed for the same on-behalf/system paths as the timing check
        (xeno_hr_direct_create, an Officer creating for someone else, sudo,
        system deductions) and skipped entirely if the 'prorated_balance'
        rule is switched off on the Leave Validations page.
        """
        if not self._xeno_validation_enabled("prorated_balance"):
            return
        if self.env.context.get("xeno_hr_direct_create") or self.env.su:
            return
        is_officer = self.env.user.has_group(HR_OFFICER_GROUP)
        own_employee_id = self.env.user.employee_id.id
        Leave = self.env["hr.leave"].sudo()
        Alloc = self.env["hr.leave.allocation"].sudo()
        for leave in self:
            lt = leave.holiday_status_id
            emp = leave.employee_id
            if not lt or not emp or not lt.xeno_prorated:
                continue
            if leave.state in ("cancel", "refuse"):
                continue
            if leave.xeno_is_system_deduction:
                continue
            # Officer booking for a *different* employee is an on-behalf
            # action (mirrors _xeno_check_leave_timing) -- their own
            # self-service leave is still checked.
            if is_officer and emp.id != own_employee_id:
                continue
            requested = leave.number_of_days or 0.0
            if requested <= 0:
                continue
            start = leave.request_date_from or (leave.date_from and leave.date_from.date())
            if not start:
                continue
            year = start.year
            month_num = start.month
            year_start = date(year, 1, 1)
            month_end = date(year, month_num, calendar.monthrange(year, month_num)[1])

            yearly_total = sum(Alloc.search([
                ("employee_id", "=", emp.id),
                ("holiday_status_id", "=", lt.id),
                ("state", "=", "validate"),
            ]).mapped("number_of_days"))
            per_month = yearly_total / 12.0
            accrued = per_month * month_num

            # Everything already used or awaiting decision this year whose
            # start falls on/before this request's month -- the cumulative
            # consumption the accrual has to cover. Exclude this record.
            consumed = sum(Leave.search([
                ("id", "!=", leave.id),
                ("employee_id", "=", emp.id),
                ("holiday_status_id", "=", lt.id),
                ("state", "in", ("confirm", "validate1", "validate")),
                ("request_date_from", ">=", year_start),
                ("request_date_from", "<=", month_end),
            ]).mapped("number_of_days"))
            available = accrued - consumed

            if requested > available + 0.01:
                month_label = start.strftime("%B %Y")
                alternatives = leave._xeno_prorated_alternatives(lt, requested)
                if alternatives:
                    alt_msg = _(" You could use instead: %s.", alternatives)
                else:
                    alt_msg = _(" You have no other leave type with remaining "
                                "balance -- please contact HR.")
                raise ValidationError(_(
                    "%(type)s is pro-rated: only %(avail)s day(s) are accrued "
                    "by %(month)s (%(accrued)s accrued − %(used)s already "
                    "used/booked this year), but you requested %(req)s.%(alt)s",
                    type=lt.name,
                    avail=("%g" % round(max(available, 0.0), 2)),
                    month=month_label,
                    accrued=("%g" % round(accrued, 2)),
                    used=("%g" % round(consumed, 2)),
                    req=("%g" % round(requested, 2)),
                    alt=alt_msg,
                ))

    def _xeno_prorated_alternatives(self, exclude_type, requested):
        """A human-readable, comma-separated list of other leave types the
        employee can actually fall back on -- allocation types with real
        remaining balance (yearly allocated − used/booked this year, NOT
        pro-rated: the fallback is about what's genuinely available), plus
        no-allocation types (e.g. Unpaid) shown as unlimited. Ordered so
        types that fully cover the request come first, then unlimited ones,
        then partial balances; capped to keep the message readable.
        """
        self.ensure_one()
        emp = self.employee_id
        year = (self.request_date_from or self.date_from.date()).year
        Leave = self.env["hr.leave"].sudo()
        Alloc = self.env["hr.leave.allocation"].sudo()
        types = self.env["hr.leave.type"].sudo().search([("id", "!=", exclude_type.id)])
        options = []  # (rank, sort_val, label)
        for lt in types:
            if not lt.requires_allocation:
                options.append((1, 0.0, _("%s (no limit)", lt.name)))
                continue
            total = sum(Alloc.search([
                ("employee_id", "=", emp.id),
                ("holiday_status_id", "=", lt.id),
                ("state", "=", "validate"),
            ]).mapped("number_of_days"))
            if total <= 0:
                continue
            consumed = sum(Leave.search([
                ("employee_id", "=", emp.id),
                ("holiday_status_id", "=", lt.id),
                ("state", "in", ("confirm", "validate1", "validate")),
            ]).mapped("number_of_days"))
            remaining = total - consumed
            if remaining <= 0.01:
                continue
            rank = 0 if remaining >= requested else 2
            options.append((rank, -remaining, _(
                "%(name)s (%(rem)s left)", name=lt.name, rem=("%g" % round(remaining, 2)))))
        options.sort(key=lambda o: (o[0], o[1]))
        labels = [o[2] for o in options[:4]]
        return ", ".join(labels)

    xeno_flexible_duration = fields.Boolean(
        related="holiday_status_id.xeno_flexible_duration", readonly=True,
    )
    # Entitlement/procedure detail, surfaced on the Apply for Leave form as
    # soon as the employee picks a Leave Type -- see hr.leave.type.xeno_detail.
    xeno_leave_type_detail = fields.Html(
        related="holiday_status_id.xeno_detail", readonly=True,
    )
    # Mirrors XENHR's leave_base_types exactly (Full-day / Part-time /
    # First-half / Second-half), per the Apply for Leave form design.
    xeno_request_unit = fields.Selection(
        [
            ("full_day", "Full-day Leave"),
            ("part_time", "Part-time Leave"),
            ("first_half", "First-half Day Leave"),
            ("second_half", "Second-half Day Leave"),
        ],
        string="Leave Type Basis", default="full_day",
        help="Only used (and editable) when the selected Time Off type "
             "has Flexible Duration enabled -- lets the requester pick "
             "the leave basis per request, mirroring XENHR's "
             "leave_base_types (Full-day/Part-time/First-half/"
             "Second-half).",
    )

    # Balance summary strip on the Apply form (Total/Used/Pending/
    # Remaining), mirroring XENHR's Apply for Leave dialog.
    xeno_balance_total = fields.Float(compute="_compute_xeno_balance", digits=(16, 2))
    xeno_balance_used = fields.Float(compute="_compute_xeno_balance", digits=(16, 2))
    xeno_balance_pending = fields.Float(compute="_compute_xeno_balance", digits=(16, 2))
    xeno_balance_remaining = fields.Float(compute="_compute_xeno_balance", digits=(16, 2))

    @api.depends("employee_id", "holiday_status_id")
    def _compute_xeno_balance(self):
        Leave = self.env["hr.leave"].sudo()
        Alloc = self.env["hr.leave.allocation"].sudo()
        for leave in self:
            emp = leave.employee_id
            lt = leave.holiday_status_id
            emp_id = emp._origin.id or emp.id
            lt_id = lt._origin.id or lt.id
            if not emp_id or not lt_id or not isinstance(emp_id, int) or not isinstance(lt_id, int):
                leave.xeno_balance_total = 0.0
                leave.xeno_balance_used = 0.0
                leave.xeno_balance_pending = 0.0
                leave.xeno_balance_remaining = 0.0
                continue
            total = sum(Alloc.search([
                ("employee_id", "=", emp_id),
                ("holiday_status_id", "=", lt_id),
                ("state", "=", "validate"),
            ]).mapped("number_of_days"))
            used = sum(Leave.search([
                ("employee_id", "=", emp_id),
                ("holiday_status_id", "=", lt_id),
                ("state", "=", "validate"),
            ]).mapped("number_of_days"))
            pending = sum(Leave.search([
                ("employee_id", "=", emp_id),
                ("holiday_status_id", "=", lt_id),
                ("state", "in", ["confirm", "validate1"]),
            ]).mapped("number_of_days"))
            leave.xeno_balance_total = total
            leave.xeno_balance_used = used
            leave.xeno_balance_pending = pending
            leave.xeno_balance_remaining = total - used - pending

    @api.onchange("xeno_request_unit")
    def _onchange_xeno_request_unit_periods(self):
        if self.xeno_request_unit == "first_half":
            self.request_date_from_period = "am"
            self.request_date_to_period = "am"
        elif self.xeno_request_unit == "second_half":
            self.request_date_from_period = "pm"
            self.request_date_to_period = "pm"

    # Overrides the core related field (related='holiday_status_id.request_unit')
    # so that everything downstream that already depends on it --
    # request_unit_half/request_unit_hours, the date/duration computation,
    # the half-day calendar hatching -- automatically respects the
    # per-request choice for flexible leave types, with no further changes
    # needed to core hr_holidays logic.
    leave_type_request_unit = fields.Selection(
        selection=[("day", "Day"), ("half_day", "Half-Day"), ("hour", "Hours")],
        compute="_compute_xeno_leave_type_request_unit", readonly=True,
        related=None,  # explicitly clear the base's related='holiday_status_id.request_unit'
    )

    _XENO_UNIT_MAP = {
        "full_day": "day",
        "part_time": "hour",
        "first_half": "half_day",
        "second_half": "half_day",
    }

    @api.depends(
        "holiday_status_id.request_unit",
        "holiday_status_id.xeno_flexible_duration",
        "xeno_request_unit",
    )
    def _compute_xeno_leave_type_request_unit(self):
        for leave in self:
            if leave.holiday_status_id.xeno_flexible_duration and leave.xeno_request_unit:
                leave.leave_type_request_unit = self._XENO_UNIT_MAP.get(
                    leave.xeno_request_unit, "day")
            else:
                leave.leave_type_request_unit = leave.holiday_status_id.request_unit

    @api.onchange("holiday_status_id")
    def _onchange_xeno_holiday_status_id(self):
        if self.holiday_status_id.xeno_flexible_duration and not self.xeno_request_unit:
            self.xeno_request_unit = "full_day"

    @api.constrains("xeno_request_unit", "request_hour_from", "request_hour_to",
                     "request_date_from", "employee_id")
    def _check_xeno_part_time_hours(self):
        """Part-time leave lets HR/the requester pick any clock-time range
        via a plain time widget, with nothing stopping them from picking a
        range that runs outside the employee's actual scheduled hours for
        that day (e.g. 15:00-19:00 when the day ends at 18:00). Catch that
        at save time and say exactly what's wrong.

        The range only needs to sit within the day's working *span*
        (earliest attendance start .. latest attendance end); a pick that
        merely crosses an unpaid lunch break in the middle is fine. The old
        implementation compared the raw clock span to Odoo's break-excluding
        number_of_hours, which falsely rejected any range spanning lunch --
        e.g. 09:00-14:00 on a 09:00-12:30 / 13:30-18:00 calendar reads as
        4 working hours over a 5-hour span, yet is entirely within hours.
        """
        if not self._xeno_validation_enabled("part_time_hours"):
            return
        for leave in self:
            if leave.xeno_request_unit != "part_time" or not leave.employee_id:
                continue
            if leave.request_hour_to <= leave.request_hour_from:
                raise ValidationError(_("End time must be after start time."))
            calendar = (leave.employee_id.resource_calendar_id
                        or leave.employee_id.company_id.resource_calendar_id)
            if not calendar or not leave.request_date_from:
                continue
            dow = str(leave.request_date_from.weekday())
            # For a two-week calendar, week_type varies by date; taking all
            # of the weekday's lines (both week types) just widens the span,
            # which is lenient rather than wrongly strict -- acceptable.
            attendances = calendar.attendance_ids.filtered(lambda a: a.dayofweek == dow)
            if not attendances:
                # No schedule configured for that weekday -- don't second-guess.
                continue
            day_start = min(attendances.mapped("hour_from"))
            day_end = max(attendances.mapped("hour_to"))
            if (leave.request_hour_from < day_start - 0.01
                    or leave.request_hour_to > day_end + 0.01):
                raise ValidationError(_(
                    "%(start)s-%(end)s is outside %(employee)s's scheduled "
                    "working hours that day (%(ws)s-%(we)s). Pick a range "
                    "within their working hours.",
                    start=self._xeno_format_hour(leave.request_hour_from),
                    end=self._xeno_format_hour(leave.request_hour_to),
                    employee=leave.employee_id.name,
                    ws=self._xeno_format_hour(day_start),
                    we=self._xeno_format_hour(day_end),
                ))
            if leave.number_of_hours <= 0.01:
                raise ValidationError(_(
                    "%(start)s-%(end)s falls entirely within a non-working "
                    "period (e.g. the lunch break) for %(employee)s. Pick a "
                    "range that includes working time.",
                    start=self._xeno_format_hour(leave.request_hour_from),
                    end=self._xeno_format_hour(leave.request_hour_to),
                    employee=leave.employee_id.name,
                ))

    @api.constrains("employee_id", "date_from", "date_to", "state")
    def _check_xeno_no_overlap(self):
        """susu (2026-07-21): core only ever shows a soft dashboard warning
        for overlapping leaves (_compute_dashboard_warning_message below) --
        it never actually blocks the second request. Turn that into a hard
        rule instead: an employee can't have two leave requests covering
        the same day(s) unless the earlier one has already been cancelled
        or rejected. Mirrors the exact overlap definition core's own
        warning already uses (date_from/date_to, the calendar-aware
        computed range, not the raw request_date_from/to), just enforced
        rather than merely flagged.
        """
        if not self._xeno_validation_enabled("overlap"):
            return
        for leave in self:
            if not leave.employee_id or not leave.date_from or not leave.date_to:
                continue
            if leave.state in ("cancel", "refuse"):
                continue
            conflict = self.search([
                ("id", "!=", leave._origin.id or leave.id),
                ("employee_id", "=", leave.employee_id.id),
                ("state", "not in", ("cancel", "refuse")),
                ("date_from", "<", leave.date_to),
                ("date_to", ">", leave.date_from),
            ], limit=1)
            if conflict:
                raise ValidationError(_(
                    "%(employee)s already has a leave request from "
                    "%(date_from)s to %(date_to)s (%(state)s) that overlaps "
                    "with this period. Cancel or wait for that request to "
                    "be rejected before submitting a new one for the same "
                    "day(s).",
                    employee=leave.employee_id.name,
                    date_from=fields.Date.to_date(conflict.date_from),
                    date_to=fields.Date.to_date(conflict.date_to),
                    state=dict(conflict._fields["state"].selection)[conflict.state],
                ))

    def _compute_dashboard_warning_message(self):
        super()._compute_dashboard_warning_message()
        # Company terminology: "booked time off" -> "requested leave",
        # matching the rest of this module's Leave/Leave Request wording.
        # A targeted replace on core's own generated message, rather than
        # re-deriving the whole conflict list, keeps this in lockstep with
        # any future change to the surrounding date/employee-name logic.
        for leave in self:
            if not leave.dashboard_warning_message:
                continue
            leave.dashboard_warning_message = (
                leave.dashboard_warning_message
                .replace(
                    "You've already booked time off which overlaps with this period:",
                    _("You've already requested leave which overlaps with this period:"))
                .replace(
                    "An employee already booked time off which overlaps with this period:",
                    _("An employee already requested leave which overlaps with this period:"))
            )

    @api.model
    def _xeno_format_hour(self, hour_float):
        h = int(hour_float)
        m = round((hour_float - h) * 60)
        return "%02d:%02d" % (h, m)

    xeno_is_approver_for_me = fields.Boolean(
        compute="_compute_xeno_is_approver_for_me", search="_search_xeno_is_approver_for_me",
        string="I am the configured approver",
    )

    def _xeno_current_pending_step(self):
        """The lowest-step_order pending xeno.leave.approval.step for this
        leave, or an empty recordset if it has no materialized chain (e.g.
        a legacy leave from before this feature existed) -- callers must
        treat "no step" as "fall back to pre-chain behavior", not "blocked".
        """
        self.ensure_one()
        return self.sudo().xeno_approval_step_ids.filtered(
            lambda s: s.state == "pending").sorted("step_order")[:1]

    def _xeno_step_acting_user(self, step, acting_user, acting_is_officer):
        """Whether acting_user may decide `step` on this leave, evaluated
        against an EXPLICIT user rather than self.env.user.

        Split out of _xeno_can_act_on_step so the searchable-field path can
        run this check over sudo()'d records (it has to enumerate every
        pending leave company-wide) without either of the two traps that
        come with that: env.is_superuser() is true for any sudo()'d call
        and would wave everything through, and reading employee_id on a
        leave the acting user can't see would raise AccessError instead of
        just excluding it.
        """
        # Never your own leave -- was only enforced in
        # _xeno_can_approve_or_refuse (the actual approve/reject action
        # gate), not here, so an HR Officer's own leave could still show
        # up in their own "Pending My Approval" list whenever its current
        # step was a blank "any HR" one -- fixed by moving the check into
        # this shared method instead, so every caller gets it, list
        # visibility included.
        if self.employee_id.user_id == acting_user:
            return False
        if step.approver_user_id:
            return step.approver_user_id == acting_user
        return acting_is_officer

    def _xeno_can_act_on_step(self, step):
        if self.env.is_superuser():
            return True
        return self._xeno_step_acting_user(
            step, self.env.user, self.env.user.has_group(HR_OFFICER_GROUP))

    @api.depends("xeno_approval_step_ids.state", "xeno_approval_step_ids.approver_user_id")
    def _compute_xeno_is_approver_for_me(self):
        for leave in self:
            step = leave._xeno_current_pending_step()
            leave.xeno_is_approver_for_me = bool(step) and leave._xeno_can_act_on_step(step)

    def _search_xeno_is_approver_for_me(self, operator, value):
        # Odoo may normalize ('field', '=', True) into ('field', 'in', {True})
        # before this is called -- handle both plain bools and set/list forms.
        if isinstance(value, bool):
            want = value
        else:
            want = True in value
        if operator in ("!=", "not in", "<>"):
            want = not want
        # sudo() ONLY to enumerate every pending leave company-wide,
        # bypassing this user's own hr.leave record-rule visibility (which
        # would otherwise clip the search before the real check below ever
        # runs). The permission decision itself is still made for the REAL
        # acting user, passed explicitly to _xeno_step_acting_user.
        #
        # Two real bugs this shape exists to avoid, both previously hit:
        # 1. Checking _xeno_can_act_on_step on the sudo'd records matched
        #    every pending leave in the company for every user, because
        #    env.is_superuser() is true for ANY sudo()'d call, not just
        #    the literal superuser.
        # 2. Re-browsing the ids in the acting user's own environment to
        #    dodge (1) then raised AccessError for any regular employee:
        #    reading employee_id on a colleague's leave they have no
        #    record-rule access to is an ACL read, so the whole search
        #    blew up instead of simply not matching those rows. Every
        #    field read below therefore stays on the sudo'd recordset.
        acting_user = self.env.user
        acting_is_officer = acting_user.has_group(HR_OFFICER_GROUP)
        matching_ids = []
        for leave in self.sudo().search([("state", "in", ("confirm", "validate1"))]):
            step = leave._xeno_current_pending_step()
            is_mine = bool(step) and leave._xeno_step_acting_user(
                step, acting_user, acting_is_officer)
            if is_mine == want:
                matching_ids.append(leave.id)
        return [("id", "in", matching_ids)]

    def _xeno_can_approve_or_refuse(self):
        """Narrows Odoo's own can_approve/can_validate/can_refuse to the
        acting user being allowed to act on this leave's *current pending
        approval-chain step* (xeno.leave.approval.step) -- a specific
        approver_user_id must match exactly, a blank one requires a Time
        Off Officer/Administrator, and earlier steps must already be
        approved (guaranteed by construction: the "current" step is always
        the lowest-order still-pending one). Never allow approving/
        refusing your own leave. A leave with no materialized chain at all
        (created before this feature existed) falls back to Odoo's own
        group-based result unchanged, exactly like before this feature.

        Fixes a real bug found in testing: Odoo's own can_approve/
        can_refuse grant any Time Off Officer approval rights over any
        leave, including their own -- since "My Team Leaves" approvers
        are Officers too (needed for Odoo's own state machine to permit
        the transition at all), this let any configured approver, or
        anyone else with that role, approve/refuse leaves that weren't
        theirs to decide, including self-approval.
        """
        self.ensure_one()
        if self.env.is_superuser():
            return True
        if self.employee_id.user_id == self.env.user:
            return False
        step = self._xeno_current_pending_step()
        if step:
            return self._xeno_can_act_on_step(step)
        return True

    @api.depends_context("uid")
    @api.depends("state", "employee_id", "department_id",
                 "xeno_approval_step_ids.state", "xeno_approval_step_ids.approver_user_id")
    def _compute_can_approve(self):
        super()._compute_can_approve()
        for leave in self:
            if leave.can_approve:
                leave.can_approve = leave._xeno_can_approve_or_refuse()

    @api.depends_context("uid")
    @api.depends("state", "employee_id", "department_id",
                 "xeno_approval_step_ids.state", "xeno_approval_step_ids.approver_user_id")
    def _compute_can_validate(self):
        super()._compute_can_validate()
        for leave in self:
            if leave.can_validate:
                leave.can_validate = leave._xeno_can_approve_or_refuse()

    @api.depends_context("uid")
    @api.depends("state", "employee_id", "department_id",
                 "xeno_approval_step_ids.state", "xeno_approval_step_ids.approver_user_id")
    def _compute_can_refuse(self):
        super()._compute_can_refuse()
        for leave in self:
            if leave.can_refuse:
                leave.can_refuse = leave._xeno_can_approve_or_refuse()

    @api.depends_context("uid")
    @api.depends("state", "employee_id", "department_id",
                 "xeno_approval_step_ids.state", "xeno_approval_step_ids.approver_user_id")
    def _compute_can_back_to_approve(self):
        super()._compute_can_back_to_approve()
        for leave in self:
            if leave.can_back_to_approve:
                leave.can_back_to_approve = leave._xeno_can_approve_or_refuse()

    @api.depends_context("uid")
    @api.depends("state", "employee_id", "request_date_from", "xeno_is_system_deduction")
    def _compute_can_cancel(self):
        """Replaces core's own cancel eligibility for an employee's OWN
        leave with this company's actual policy:

        - Core never allows cancelling from 'confirm' (submitted, still
          awaiting the first approval step) -- only from validate1/
          validate/refuse. This company wants a still-pending request to
          be self-cancellable too, so 'confirm'/'validate1' (pending) and
          'validate' (approved) are all allowed here.
        - Core DOES allow cancelling an already-Refused leave -- this
          company doesn't want that (a rejection is a closed decision,
          not something to undo), so 'refuse' is excluded here.
        - A system deduction (company holiday / late check-in deduction --
          xeno_is_system_deduction) was never something the employee
          asked for, so they can't cancel it themselves; only HR can
          reverse those (xeno_holidays' automatic reversal on holiday
          delete / the late-deduction cron).
        - Only a leave starting today or later can be self-cancelled --
          same past/future philosophy as _xeno_check_leave_timing, and
          with no role-based bypass for the requester's OWN leave either
          (see that method's docstring for the exact bug this mirrors:
          susu, an HR/admin account, could sidestep this company's rules
          on their own leave just by holding the Officer role). An HR
          officer acting on someone ELSE's leave is untouched -- this
          override only ever narrows/widens the leave's own requester.
        """
        super()._compute_can_cancel()
        if self.env.is_superuser():
            return
        today = fields.Date.context_today(self)
        for leave in self:
            if leave.employee_id.user_id != self.env.user:
                continue
            if (leave.xeno_is_system_deduction
                    or leave.state not in ("confirm", "validate1", "validate")):
                leave.can_cancel = False
                continue
            start = leave.request_date_from and fields.Date.to_date(leave.request_date_from)
            leave.can_cancel = not (start and start < today)

    def action_approve(self, check_state=True):
        """Approve the current pending step of this leave's chain, not the
        whole leave outright -- matches XENHR's step-wise approveStep():
        only when the LAST pending step is approved does the leave itself
        actually transition (delegating to Odoo core's own action_approve
        so allocation consumption / notifications happen exactly as they
        always have). A leave with no materialized chain (legacy, from
        before this feature existed) is approved in one shot as before.
        """
        to_finalize = self.browse()
        for leave in self:
            step = leave._xeno_current_pending_step()
            if not step:
                to_finalize += leave
                continue
            if not self.env.is_superuser() and not leave._xeno_can_act_on_step(step):
                raise UserError(_("Only the configured approver for this "
                                   "step can approve this leave request."))
            step.sudo().write({
                "state": "approved",
                "decided_by": self.env.user.id,
                "decided_at": Datetime.now(),
            })
            remaining = leave._xeno_current_pending_step()
            if remaining:
                leave.message_post(body=_(
                    "%(prev_label)s approved by %(user)s. Waiting on: "
                    "%(next_label)s.",
                    prev_label=step.label, user=self.env.user.name,
                    next_label=remaining.label))
                leave._xeno_send_step_email(remaining)
            else:
                to_finalize += leave
        if to_finalize:
            super(HrLeave, to_finalize).action_approve(check_state=check_state)
            to_finalize._xeno_send_final_notice()
        return True

    def action_refuse(self):
        # Odoo's own action_refuse() doesn't check can_refuse at all
        # (unlike action_approve(), which does) -- enforce it here.
        if not self.env.is_superuser():
            for leave in self:
                if not leave.can_refuse:
                    raise UserError(_(
                        "Only the configured approver can refuse this "
                        "leave request."))
        for leave in self:
            step = leave._xeno_current_pending_step()
            if not step:
                # No step still pending -- either a legacy leave with no
                # materialized chain at all (nothing to flip, leave step
                # empty), or an HR override reversing an already fully
                # approved chain (action_xeno_force_reject on a 'validate'
                # leave). In the latter case every step already reads
                # "approved" -- without flipping the last one to
                # "rejected" here, the chain widget and the final-notice
                # email's approval summary (both read straight off these
                # step records) kept showing every step approved even
                # though the leave itself just became Refused.
                step = leave.sudo().xeno_approval_step_ids.filtered(
                    lambda s: s.state == "approved").sorted("step_order")[-1:]
            if step:
                # Rejecting any single step rejects the whole leave
                # application, matching XENHR's rejectStep() exactly.
                step.sudo().write({
                    "state": "rejected",
                    "decided_by": self.env.user.id,
                    "decided_at": Datetime.now(),
                })
        res = super().action_refuse()
        self._xeno_send_final_notice()
        return res

    def message_post(self, **kwargs):
        """Suppress Odoo core's own "Your leave has been accepted/refused"
        notification to the requestor (hardcoded, unconditional, inside
        _action_validate()/action_refuse()) so they get exactly one email:
        our own polished _xeno_send_final_notice(). Core's call is
        recognizable as partner_ids pointing at nobody but the leave's own
        employee with no subtype override; any other message_post (our own
        chatter notes, notifications to other people) is untouched.
        """
        partner_ids = kwargs.get("partner_ids")
        if partner_ids and not kwargs.get("subtype_xmlid") and len(self) == 1:
            employee_partner = self.employee_id.user_id.partner_id
            if employee_partner and set(partner_ids) == {employee_partner.id}:
                kwargs = dict(kwargs)
                kwargs.pop("partner_ids")
        return super().message_post(**kwargs)

    def _xeno_check_hr_officer(self):
        if not self.env.user.has_group(HR_OFFICER_GROUP):
            raise UserError(_("Only Time Off Officers can force-approve, "
                               "force-reject, or edit a submitted leave."))

    def action_xeno_force_approve(self):
        self._xeno_check_hr_officer()
        for leave in self:
            prev = dict(leave._fields["state"].selection).get(leave.state)
            leave.message_post(body=_(
                "Force-approved by %(user)s (HR override, bypassing the "
                "normal approval chain). Previous status: %(prev)s.",
                user=self.env.user.name, prev=prev))
            # Bypass the whole chain, not just the current step: mark every
            # still-pending step approved so the chain reads as fully
            # resolved, matching XENHR's override() (HR/Admin bypass).
            leave.sudo().xeno_approval_step_ids.filtered(
                lambda s: s.state == "pending"
            ).write({
                "state": "approved",
                "decided_by": self.env.user.id,
                "decided_at": Datetime.now(),
            })
        # Call the real core transition directly -- this method already IS
        # the bypass, so it must not go through this module's own
        # step-wise action_approve() override again.
        res = super(HrLeave, self).action_approve(check_state=False)
        self._xeno_send_final_notice()
        return res

    def action_xeno_force_reject(self):
        self._xeno_check_hr_officer()
        blocked = self.filtered(lambda l: l.state in ("refuse", "cancel"))
        if blocked:
            raise UserError(_(
                "%s is already %s -- nothing to force-reject.",
                blocked[0].display_name, blocked[0].state))
        for leave in self:
            leave.message_post(body=_(
                "Force-rejected by %(user)s (HR override).", user=self.env.user.name))
        return self.action_refuse()

    def _xeno_hr_created_approve(self):
        """Immediately validate a batch of leaves created by
        wizard/leave_hr_create.py (HR creating directly on an employee's
        behalf) -- no approval chain exists on these (see the
        xeno_hr_direct_create context guard in _xeno_build_approval_chain),
        so there's nothing to mark as bypassed, just the real core
        validate transition, same as action_xeno_force_approve's own
        bypass call.
        """
        for leave in self:
            leave.message_post(body=_("Added by HR/Admin."))
        return super(HrLeave, self).action_approve(check_state=False)

    def _xeno_hr_created_reject(self):
        """Same bypass as _xeno_hr_created_approve() above, for rejecting
        instead -- used by the Leave Request Import feature to backfill
        already-decided historical records without spamming the requestor
        with a real rejection email for something that was decided in the
        past (this module's own action_refuse() always sends one via
        _xeno_send_final_notice(), core's plain action_refuse() does not)."""
        for leave in self:
            leave.message_post(body=_("Recorded as rejected by HR/Admin (import)."))
        return super(HrLeave, self).action_refuse()

    # Cascade order for the company-holiday-deduction policy: draw down
    # Annual Leave first, falling back to Personal Leave and then Unpaid
    # Leave if the current tier can't cover the whole period -- never
    # split a single deduction across two types. Matched by name since
    # these leave types were created ad hoc during the migration with no
    # stable external ID. Shared by wizard/leave_holiday_deduct.py (manual,
    # HR-driven) and xeno_holidays' automatic hook on company-holiday
    # creation -- one implementation, two callers.
    _XENO_HOLIDAY_DEDUCT_CASCADE_NAMES = [
        "Annual Leave . AL", "Personal Leave . PL", "Unpaid Leave . UL"]

    @api.model
    def _xeno_holiday_deduct_cascade_types(self):
        """Returns a real hr.leave.type recordset, ordered to match
        _XENO_HOLIDAY_DEDUCT_CASCADE_NAMES exactly (a 3-record recordset
        built via ordered |= concatenation, safe to unpack positionally
        as `annual, personal, unpaid = ...` or use recordset methods like
        .ids/.mapped() on directly -- unlike a plain list)."""
        Type = self.env["hr.leave.type"]
        types = Type.browse()
        for name in self._XENO_HOLIDAY_DEDUCT_CASCADE_NAMES:
            leave_type = Type.search([("name", "=", name)], limit=1)
            if not leave_type:
                raise UserError(_(
                    "Leave type '%(name)s' is required for the company "
                    "holiday deduction cascade but no longer exists. "
                    "Check Time Off > Configuration > Leave Types.",
                    name=name))
            types |= leave_type
        return types

    @api.model
    def _xeno_holiday_deduct_remaining_balance(self, employee, leave_type):
        Alloc = self.env["hr.leave.allocation"].sudo()
        Leave = self.sudo()
        total = sum(Alloc.search([
            ("employee_id", "=", employee.id),
            ("holiday_status_id", "=", leave_type.id),
            ("state", "=", "validate"),
        ]).mapped("number_of_days"))
        used = sum(Leave.search([
            ("employee_id", "=", employee.id),
            ("holiday_status_id", "=", leave_type.id),
            ("state", "=", "validate"),
        ]).mapped("number_of_days"))
        pending = sum(Leave.search([
            ("employee_id", "=", employee.id),
            ("holiday_status_id", "=", leave_type.id),
            ("state", "in", ["confirm", "validate1"]),
        ]).mapped("number_of_days"))
        return total - used - pending

    @api.model
    def _xeno_deduct_for_company_holiday(self, employees, date_from, date_to,
                                          reason=None, source_holiday=None):
        """Create + immediately approve one full-day (or date_from-date_to
        span) leave per employee, cascading Annual -> Personal -> Unpaid
        per _XENO_HOLIDAY_DEDUCT_CASCADE_NAMES. Runs sudo()'d -- callers
        (the wizard, or xeno_holidays' automatic hook) are responsible for
        their own permission checks; this method just does the work.

        source_holiday, when given (only by xeno_holidays' automatic hook,
        never the manual wizard -- that one isn't tied to one specific
        holiday record), links the created leave back to it so deleting
        that holiday can find and reverse exactly the leaves it caused.
        """
        if not employees:
            return self.browse()
        days_needed = (date_to - date_from).days + 1
        annual, personal, unpaid = self._xeno_holiday_deduct_cascade_types()
        Leave = self.sudo()
        created = self.browse()
        for employee in employees:
            if self._xeno_holiday_deduct_remaining_balance(employee, annual) >= days_needed:
                leave_type = annual
            elif self._xeno_holiday_deduct_remaining_balance(employee, personal) >= days_needed:
                leave_type = personal
            else:
                leave_type = unpaid
            leave = Leave.with_context(
                xeno_hr_direct_create=True, xeno_force_full_day_duration=True,
            ).create({
                "employee_id": employee.id,
                "holiday_status_id": leave_type.id,
                "xeno_request_unit": "full_day",
                "request_date_from": date_from,
                "request_date_to": date_to,
                "name": reason or _("Company holiday deduction"),
                "xeno_source_holiday_id": source_holiday.id if source_holiday else False,
                "xeno_is_system_deduction": True,
                "xeno_system_deduction_type": "company_holiday",
            })
            leave.message_post(body=_(
                "Company holiday deduction: %(days)s day(s) charged to "
                "%(type)s.", days=days_needed, type=leave_type.name))
            created |= leave
        created._xeno_hr_created_approve()
        return created

    def _xeno_reverse_holiday_deduction(self):
        """Give the day back: called when the Company Holiday that caused
        this deduction is being deleted. Only reverses leaves still in
        'validate' state (an already-cancelled/refused one has nothing to
        give back); runs the same core refuse transition Force Reject
        uses, bypassing the HR-officer check since this is a system
        action triggered by deleting the holiday, not a human decision."""
        leaves = self.sudo().filtered(lambda l: l.state == "validate")
        if not leaves:
            return
        for leave in leaves:
            leave.message_post(body=_(
                "Automatically reversed: the Company Holiday this leave "
                "was deducted for has been deleted. The day has been "
                "given back."))
        super(HrLeave, leaves).action_refuse()

    # -- Late check-in monthly deduction ------------------------------------
    # Company policy: 10 minutes of late check-in per month is forgiven;
    # beyond that, the excess is deducted from Annual Leave if its balance
    # covers it, otherwise unconditionally from Personal Leave (only 2
    # tiers -- unlike the 3-tier holiday-deduct cascade, there's no Unpaid
    # fallback specified for this policy).
    _XENO_LATE_DEDUCT_GRACE_MINUTES = 10
    _XENO_LATE_DEDUCT_WORKDAY_MINUTES = 480  # 8h -- 1 full leave day equivalent
    _XENO_LATE_DEDUCT_CASCADE_NAMES = ["Annual Leave . AL", "Personal Leave . PL"]

    @api.model
    def _xeno_late_deduct_cascade_types(self):
        Type = self.env["hr.leave.type"]
        types = Type.browse()
        for name in self._XENO_LATE_DEDUCT_CASCADE_NAMES:
            leave_type = Type.search([("name", "=", name)], limit=1)
            if not leave_type:
                raise UserError(_(
                    "Leave type '%(name)s' is required for the late check-in "
                    "deduction cascade but no longer exists. Check Time Off "
                    "> Configuration > Leave Types.", name=name))
            types |= leave_type
        return types

    @api.model
    def _xeno_process_late_deductions(self, year, month):
        """Sums each employee's total late-checkin minutes for the given
        month (xeno_attendance's own monthly report computation --
        xeno.attendance.report.get_month_report, the same figure shown on
        the Monthly Attendance page's Late column), deducts the excess
        beyond the monthly grace period as a fractional leave day.
        Idempotent per (employee, year, month) via the unique constraint
        on xeno.late.deduction.log -- already-processed employees are
        silently skipped on a re-run (e.g. cron + manual trigger both
        firing for the same month). Returns (employee_count, total_days).
        """
        annual, personal = self._xeno_late_deduct_cascade_types()
        Employee = self.env["hr.employee"].sudo()
        Log = self.env["xeno.late.deduction.log"].sudo()
        Leave = self.sudo()

        report = self.env["xeno.attendance.report"].sudo().get_month_report(year, month)
        last_day = date(year, month, calendar.monthrange(year, month)[1])
        month_label = last_day.strftime("%B %Y")

        already = set(Log.search([
            ("year", "=", year), ("month", "=", month),
        ]).employee_id.ids)

        employee_count = 0
        total_days = 0.0
        for row in report.get("rows", []):
            late_minutes = row.get("late") or 0
            if late_minutes <= self._XENO_LATE_DEDUCT_GRACE_MINUTES:
                continue
            code = row.get("employee_code")
            if not code:
                continue
            employee = Employee.search([("xeno_employee_code", "=", str(code))], limit=1)
            if not employee or employee.id in already:
                continue

            excess_minutes = late_minutes - self._XENO_LATE_DEDUCT_GRACE_MINUTES
            deduct_days = excess_minutes / self._XENO_LATE_DEDUCT_WORKDAY_MINUTES

            # A summed balance > 0 (_xeno_holiday_deduct_remaining_balance)
            # doesn't guarantee core's own _check_validity() will accept a
            # leave against it -- an allocation can exist but not be valid
            # for this specific date (e.g. a narrower validity window than
            # its raw day count implies). Confirmed live: several real
            # employees have a positive summed Annual balance yet core
            # rejects the leave with "no allocation for this time off
            # type." So this tries Annual, falls back to Personal on
            # failure (not just a balance comparison), and skips (to be
            # retried on the next run) only if both fail outright.
            preferred = annual if (
                self._xeno_holiday_deduct_remaining_balance(employee, annual)
                >= deduct_days) else personal
            fallback = personal if preferred == annual else annual

            leave = None
            leave_type = None
            for candidate in (preferred, fallback):
                try:
                    with self.env.cr.savepoint():
                        candidate_leave = Leave.with_context(
                            xeno_hr_direct_create=True,
                            xeno_force_number_of_days=deduct_days,
                        ).create({
                            "employee_id": employee.id,
                            "holiday_status_id": candidate.id,
                            "xeno_request_unit": "full_day",
                            "request_date_from": last_day,
                            "request_date_to": last_day,
                            "name": _(
                                "Late check-in deduction for %(month)s: "
                                "%(minutes)s min over the %(grace)s-min "
                                "monthly grace period.",
                                month=month_label, minutes=late_minutes,
                                grace=self._XENO_LATE_DEDUCT_GRACE_MINUTES),
                            "xeno_is_system_deduction": True,
                            "xeno_system_deduction_type": "late_deduction",
                        })
                        candidate_leave._xeno_hr_created_approve()
                    leave, leave_type = candidate_leave, candidate
                    break
                except (ValidationError, UserError) as exc:
                    _logger.warning(
                        "Late check-in deduction: could not charge %s's "
                        "%.2f day(s) to %s (%s) -- trying next tier.",
                        employee.name, deduct_days, candidate.name, exc)

            if not leave:
                _logger.warning(
                    "Late check-in deduction: skipped %s for %s -- "
                    "neither Annual nor Personal could be charged "
                    "(likely no valid allocation for this date). Will "
                    "retry on the next run.", employee.name, month_label)
                continue

            leave.message_post(body=_(
                "Late check-in deduction: %(minutes)s min late in "
                "%(month)s (%(grace)s min grace) -> %(days).2f day(s) "
                "charged to %(type)s.",
                minutes=late_minutes, month=month_label,
                grace=self._XENO_LATE_DEDUCT_GRACE_MINUTES,
                days=deduct_days, type=leave_type.name))
            Log.create({
                "employee_id": employee.id, "year": year, "month": month,
                "late_minutes": late_minutes, "deducted_days": deduct_days,
                "leave_type_id": leave_type.id, "leave_id": leave.id,
            })
            employee_count += 1
            total_days += deduct_days

        return employee_count, total_days

    @api.model
    def _cron_xeno_process_late_deductions(self):
        """Runs daily (like _cron_xeno_welcome_back_from_maternity) but
        only acts during the first week of the month, processing the
        just-ended previous month -- deliberately a daily check with a
        day-of-month guard rather than a strict monthly interval, so a
        paused/delayed cron still catches up within that window instead
        of drifting or skipping a month. Safe to run more than once (or
        every day of the window): the unique constraint on
        xeno.late.deduction.log means only employees not yet processed
        for that month are ever touched."""
        today = fields.Date.context_today(self)
        if today.day > 7:
            return
        prev_month_end = today.replace(day=1) - timedelta(days=1)
        self._xeno_process_late_deductions(prev_month_end.year, prev_month_end.month)

    # Matched by name, same convention as _XENO_HOLIDAY_DEDUCT_CASCADE_NAMES.
    # Paternity Leave doesn't exist as a real type yet (per susu's decision
    # 2026-07-17) -- Maternity only for now.
    _XENO_WELCOME_BACK_LEAVE_TYPE_NAME = "Maternity Leave . ML"

    @api.model
    def _cron_xeno_welcome_back_from_maternity(self):
        """Daily: an employee whose approved Maternity Leave ended
        yesterday is back at work today -- post a congratulations note on
        their employee record and email them, once per leave (guarded by
        xeno_welcome_back_sent). Date-driven rather than tied to their
        first actual attendance scan, which would depend on GPS/kiosk
        reliability for a one-off nice-to-have message."""
        leave_type = self.env["hr.leave.type"].search(
            [("name", "=", self._XENO_WELCOME_BACK_LEAVE_TYPE_NAME)], limit=1)
        if not leave_type:
            return
        yesterday = fields.Date.context_today(self) - timedelta(days=1)
        leaves = self.sudo().search([
            ("holiday_status_id", "=", leave_type.id),
            ("state", "=", "validate"),
            ("request_date_to", "=", yesterday),
            ("xeno_welcome_back_sent", "=", False),
        ])
        for leave in leaves:
            employee = leave.employee_id
            message = _(
                "Welcome back, %(name)s! Congratulations on your new "
                "baby, and best wishes to your family. We're glad to "
                "have you back with the team.", name=employee.name)
            employee.message_post(body=message)
            email = employee.work_email or (
                employee.user_id.email if employee.user_id else False)
            if email:
                # Shared branded email shell (xeno_theme_slate, which this
                # module already depends on) -- same one birthday wishes
                # uses, so both personal-notice emails look consistent.
                html = employee._xeno_render_wish_email_html(
                    title=_("Welcome back, %(name)s!", name=employee.name),
                    message=message, emoji="👶🎉")
                self.env["mail.mail"].sudo().create({
                    "subject": _("Welcome back, %(name)s!", name=employee.name),
                    "body_html": html,
                    "email_to": email,
                    "auto_delete": True,
                }).send()
            leave.xeno_welcome_back_sent = True

    @api.model
    def xeno_get_attendance_report(self, date_str):
        """Company-wide "who's on approved/pending leave" for one date --
        the same data the HR Dashboard's own Employee Attendance Report
        widget shows (xeno_hr_dashboard), but safe to expose to every
        internal user (no groups= restriction), for the same widget on the
        self-service My Profile landing page. Deliberately NOT a raw
        client-side hr.leave search_read: core's own hr_holidays record
        rules would silently scope a regular employee down to just their
        own (+ direct reports') leaves there, the same "looks company-wide,
        is actually partial" trap already avoided elsewhere in this
        codebase (My Team Leaves reads xeno.leave.approver, never hr.leave,
        for exactly this reason). sudo()'d, and only ever returns the same
        handful of already-public-facing fields the HR widget shows --
        no reason, no attachments, nothing sensitive.
        """
        leaves = self.sudo().search([
            ("state", "in", ["confirm", "validate1", "validate"]),
            ("request_date_from", "<=", date_str),
            ("request_date_to", ">=", date_str),
        ])
        rows = [{
            "id": leave.id,
            "employeeName": leave.employee_id.name,
            "position": leave.employee_id.job_title or "-",
            "department": leave.employee_id.department_id.name or "-",
            "leaveTypeName": leave.holiday_status_id.name,
            "requestUnit": leave.xeno_request_unit,
            "requestHourFrom": leave.request_hour_from,
            "requestHourTo": leave.request_hour_to,
            "numberOfDays": leave.number_of_days,
            "requestDateFrom": leave.request_date_from.isoformat() if leave.request_date_from else False,
            "requestDateTo": leave.request_date_to.isoformat() if leave.request_date_to else False,
            "state": leave.state,
        } for leave in leaves]

        # Acknowledged/Pending Business Trips (xeno_attendance's
        # hr.business.trip, which this module already depends on) merged in
        # as "leave-like" rows tagged "Business Trip . BT" -- reuses the
        # exact same leaveTypeName -> abbreviation -> color pipeline
        # (leaveTypeFontColor's "BT" entry) instead of a second styling
        # path. state is remapped acknowledged->"validate" / confirm->
        # "confirm" so the client's existing `state === "validate"` approved
        # check needs no changes. String id ("bt-<id>") to avoid colliding
        # with an hr.leave row's numeric id in the merged list's t-key.
        trips = self.env["hr.business.trip"].sudo().search([
            ("state", "in", ["confirm", "acknowledged"]),
            ("date_from", "<=", date_str),
            ("date_to", ">=", date_str),
        ])
        rows += [{
            "id": "bt-%d" % trip.id,
            "employeeName": trip.employee_id.name,
            "position": trip.employee_id.job_title or "-",
            "department": trip.employee_id.department_id.name or "-",
            "leaveTypeName": "Business Trip . BT",
            "requestUnit": "full_day",
            "requestHourFrom": False,
            "requestHourTo": False,
            "numberOfDays": trip.number_of_days,
            "requestDateFrom": trip.date_from.isoformat() if trip.date_from else False,
            "requestDateTo": trip.date_to.isoformat() if trip.date_to else False,
            "state": "validate" if trip.state == "acknowledged" else "confirm",
        } for trip in trips]
        return rows
