import calendar
import datetime
import html as _html
import io
import logging

from pytz import timezone, utc

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

_logger = logging.getLogger(__name__)

MONTHS = ["January", "February", "March", "April", "May", "June", "July",
          "August", "September", "October", "November", "December"]

# Leave-type chip colors (background, ink), per hr.leave.type's real color
# as configured/shown in Odoo (susu, 2026-07-22; SL background updated
# 2026-07-22 to its own distinct pink, separate from ML) -- ink is a
# readable darker shade of the same hue as each background, since only the
# background was specified. Any leave code not listed here (e.g. a future
# type) falls back to a neutral grey rather than rendering unstyled.
LEAVE_TYPE_COLORS = {
    "AL": ("#ddd6fe", "#5b21b6"),
    "PL": ("#fed7aa", "#9a3412"),
    "SL": ("#fecdd3", "#9f1239"),
    "ML": ("#fbcfe8", "#9d174d"),
    "UL": ("#e9e9e9", "#57534e"),
}
LEAVE_TYPE_COLOR_DEFAULT = ("#e9e9e9", "#57534e")

# Odoo's own attendance_state has no per-day boundary at all: an open
# check-in (no check_out yet) reads as "checked_in" forever, even across
# days, until someone presses Check Out. An open check-in older than this
# is treated as a forgotten checkout rather than a still-active shift (this
# deployment has occasional shifts running to 1-2am, so the window is
# comfortably wider than a normal day but still catches a day+-old record).
STALE_CHECKIN_HOURS = 18


class XenoAttendanceReport(models.AbstractModel):
    _name = "xeno.attendance.report"
    _description = "Monthly attendance report from HRSystem SQL Server"

    # ------------------------------------------------------------------ SQL
    def _conn(self):
        try:
            import pymssql
        except ImportError:
            raise UserError(_(
                "The 'pymssql' Python package is not installed in the Odoo "
                "environment. Install it in the Odoo container to enable the "
                "attendance report."
            ))
        p = self.env["ir.config_parameter"].sudo()
        host = p.get_param("hrsys.host")
        if not host:
            raise UserError(_(
                "HRSystem connection is not configured. Set hrsys.host / "
                "hrsys.db / hrsys.user / hrsys.password in system parameters."
            ))
        return pymssql.connect(
            server=host,
            port=int(p.get_param("hrsys.port", "1433")),
            user=p.get_param("hrsys.user"),
            password=p.get_param("hrsys.password"),
            database=p.get_param("hrsys.db", "HRSystem"),
            timeout=90, login_timeout=15,
        )

    @api.model
    def xeno_codes_for_filters(self, department_id=None, category_id=None, job_id=None):
        """xeno_employee_code set for hr.employee matching ALL of the given
        filters (Department/Group/Position on the Attendance report page) --
        None when no filter is given at all, meaning "don't restrict",
        matching restrict_codes' own None-means-unrestricted convention
        everywhere else in this file."""
        domain = []
        if department_id:
            domain.append(("department_id", "=", int(department_id)))
        if category_id:
            domain.append(("category_ids", "in", [int(category_id)]))
        if job_id:
            domain.append(("job_id", "=", int(job_id)))
        if not domain:
            return None
        employees = self.env["hr.employee"].sudo().search(domain)
        return {c for c in employees.mapped("xeno_employee_code") if c}

    @api.model
    def get_month_report(self, year, month, employee_code=None, restrict_codes=None):
        days = calendar.monthrange(year, month)[1]
        date_from = "%04d-%02d-01" % (year, month)
        date_to = "%04d-%02d-%02d" % (year, month, days)
        conn = self._conn()
        try:
            cur = conn.cursor(as_dict=True)
            cur.execute("EXEC [sp_get_attendance_month_V3] %s, %s",
                        (date_from, date_to))
            rows = cur.fetchall()
            cur.execute(
                "SELECT la.employee_id, la.start_date, la.end_date, lt.code "
                "FROM leave_applications la "
                "JOIN leave_types lt ON lt.id = la.leave_type_id "
                "WHERE la.status='approved' AND la.start_date<=%s "
                "AND la.end_date>=%s", (date_to, date_from))
            leaves = [{
                "employee_id": r["employee_id"],
                "start": r["start_date"].strftime("%Y-%m-%d"),
                "end": r["end_date"].strftime("%Y-%m-%d"),
                "code": r["code"] or "",
            } for r in cur.fetchall()]

            cur.execute(
                "SELECT DISTINCT employee_code FROM vw_attendance_combined "
                "WHERE [date]>=%s AND [date]<=%s", (date_from, date_to))
            raw_codes = {str(r["employee_code"]) for r in cur.fetchall()}
            cur.execute("SELECT id, employee_code, first_name, last_name FROM employees")
            employee_directory = {
                str(r["employee_code"]): {
                    "id": r["id"],
                    "name": " ".join(p for p in (r["first_name"], r["last_name"]) if p),
                }
                for r in cur.fetchall()
            }
            known_codes = set(employee_directory)
        finally:
            conn.close()

        # Holidays: sourced from Odoo's own resource.calendar.leaves
        # (xeno_holidays), not HRSystem's own `holidays` table -- confirmed
        # 2026-07-15 that HRSystem's copy is stale (only 4 rows for
        # Jul-Dec 2026, missing Constitution Day/Chulalongkorn Memorial
        # Day/etc.) while Odoo's is the one actively kept current (pulled
        # from XENHR's live API, see plan #29). Company-wide holidays only
        # (resource_id unset) -- per-employee calendar leaves aren't
        # relevant to this company-wide report.
        holidays = {}
        for h in self.env["resource.calendar.leaves"].sudo().search([
            ("resource_id", "=", False),
            ("date_from", "<=", "%s 23:59:59" % date_to),
            ("date_to", ">=", "%s 00:00:00" % date_from),
        ]):
            d = h.date_from.date()
            end = h.date_to.date()
            while d <= end:
                holidays[d.strftime("%Y-%m-%d")] = h.name
                d += datetime.timedelta(days=1)

        mapped_sources = set(
            self.env["xeno.attendance.code.map"].sudo()
            .search([]).mapped("source_code")
        )
        unmapped_codes = sorted(raw_codes - known_codes - mapped_sources)

        if restrict_codes is not None:
            allowed = {str(c) for c in restrict_codes}
            rows = [r for r in rows if str(r.get("employee_code")) in allowed]
        if employee_code:
            rows = [r for r in rows
                    if str(r.get("employee_code")) == str(employee_code)]

        self._xeno_merge_native_attendance(
            rows, year, month, days, employee_directory,
            restrict_codes=restrict_codes, employee_code=employee_code)

        # HR-granted days off (e.g. OT/extra-day comp off) -- per-employee,
        # unlike company-wide holidays, so keyed the same way as overrides.
        # A day-off cell reads as "Off" and is excluded from the late total,
        # same treatment as a company holiday but scoped to one employee.
        # Loaded before the auto-fill block below, since an employee whose
        # ONLY event this month is a granted day off (no scans at all) needs
        # the same "synthesize a blank row" treatment auto-fill gets, or
        # their day off would never render (the SP only returns employees
        # with at least one punch).
        dayoffs = {}
        for do in self.env["xeno.attendance.dayoff"].sudo().search(
            [("date", ">=", date_from), ("date", "<=", date_to)]
        ):
            dayoffs[(do.employee_code, do.date.strftime("%Y-%m-%d"))] = do.reason or ""

        # Acknowledged Business Trips -- same per-employee, per-day
        # treatment as dayoffs above (a "BT" cell, no check-in/out expected,
        # excluded from the late total), sourced from hr.business.trip's
        # date_from..date_to range instead of a single date. Only
        # "acknowledged" requests suppress check-in here; a still-Pending
        # request has no effect on the Monthly Attendance report (it only
        # shows up early on the Employee Attendance Report / daily email,
        # via xeno_get_attendance_report / get_leave_attendance_rows, which
        # -- like a pending leave -- display it with a "Pending" status).
        business_trips = {}
        for bt in self.env["hr.business.trip"].sudo().search(
            [("state", "=", "acknowledged"),
             ("date_from", "<=", date_to), ("date_to", ">=", date_from)]
        ):
            label = bt.trip_type_other if bt.trip_type == "others" else dict(
                self.env["hr.business.trip"]._fields["trip_type"].selection).get(bt.trip_type)
            d = max(bt.date_from, datetime.date(year, month, 1))
            end = min(bt.date_to, datetime.date(year, month, days))
            while d <= end:
                business_trips[(bt.employee_code, d.strftime("%Y-%m-%d"))] = label or ""
                d += datetime.timedelta(days=1)

        # Auto-fill (ported from XENHR's AttendanceAutoRule): an employee
        # exempt from daily scanning may have NO row at all this month (the
        # SP only returns employees with at least one punch) -- synthesize
        # a blank row for them so they still show up, all working days
        # rendering as "Present" via the auto_filled branch below, instead
        # of silently not appearing in the report at all. Same treatment for
        # any employee with a day off or business trip this month but
        # otherwise no scans.
        auto_fill_codes = self.env["xeno.attendance.auto.rule"].sudo().get_auto_fill_codes()
        dayoff_codes = {c for (c, _d) in dayoffs}
        bt_codes = {c for (c, _d) in business_trips}
        scope = ({str(c) for c in restrict_codes} if restrict_codes is not None
                 else ({str(employee_code)} if employee_code else None))
        present_codes = {str(r.get("employee_code")) for r in rows}
        missing = (auto_fill_codes | dayoff_codes | bt_codes) - present_codes
        if scope is not None:
            missing &= scope
        for code in missing:
            emp = employee_directory.get(code)
            if not emp:
                continue
            rows.append({
                "employee_id": emp["id"], "employee_code": code,
                "employee_name": emp["name"], "late": 0,
            })

        overrides = {}
        for ov in self.env["xeno.attendance.override"].sudo().search(
            [("date", ">=", date_from), ("date", "<=", date_to)]
        ):
            overrides[(ov.employee_code, ov.date.strftime("%Y-%m-%d"))] = {
                "time_in": ov.time_in or None,
                "time_out": ov.time_out or None,
                "note": ov.note or "",
                "editor": ov.write_uid.name,
                "edited_at": fields.Datetime.to_string(ov.write_date),
            }

        # Flexible-duration leaves (part-time / first-half / second-half /
        # full-day) live in Odoo (hr.leave), not HRSystem's frozen
        # leave_applications table -- so the SP can't see them. Pull them
        # (with their unit + hour range) plus each employee's calendar
        # sessions, so the report can colour the day's pills and pick the
        # correct on-time threshold per leave kind.
        odoo_leaves = self._xeno_odoo_leaves(
            date_from, date_to, restrict_codes, employee_code)
        sessions = self._xeno_calendar_sessions(
            [r.get("employee_code") for r in rows])

        # sp_get_attendance_month_V3's own `late` total was found to be
        # unreliable (e.g. Nyein Su Su showed 49 min for a month whose
        # actual daily lateness sums to 200+), and it can't factor in Odoo
        # leaves anyway. Recompute it here from the real scans vs. the
        # leave-aware expected start, so the column matches the day cells.
        self._xeno_recompute_late(
            rows, days, year, month, holidays, leaves, overrides,
            odoo_leaves, sessions, dayoffs, business_trips)

        return {"rows": rows, "days": days, "holidays": holidays,
                "leaves": leaves, "year": year, "month": month,
                "unmapped_codes": unmapped_codes, "overrides": overrides,
                "odoo_leaves": odoo_leaves, "sessions": sessions,
                "dayoffs": dayoffs, "business_trips": business_trips}

    @staticmethod
    def _xeno_leave_abbr(type_name):
        """'Personal Leave . PL' -> 'PL'. Leave types in this deployment are
        named "<Full Name> . <CODE>", so the abbreviation is the trailing
        dotted segment; anything without one falls back to the full name."""
        if type_name and "." in type_name:
            return type_name.rsplit(".", 1)[-1].strip()
        return (type_name or "").strip()

    def _xeno_odoo_leaves(self, date_from, date_to, restrict_codes=None,
                          employee_code=None):
        """(employee_code, 'YYYY-MM-DD') -> {abbr, unit, hf, ht} for every
        approved Odoo hr.leave overlapping the month. Unlike HRSystem's
        frozen leave_applications (full-day only), these carry the
        flexible-duration unit (part_time/first_half/second_half/full_day)
        and, for part-time, the requested hour range -- which the report
        needs to colour the day's pills and pick the right on-time
        threshold."""
        Leave = self.env["hr.leave"].sudo()
        domain = [
            ("state", "=", "validate"),
            ("request_date_from", "<=", date_to),
            ("request_date_to", ">=", date_from),
        ]
        if restrict_codes is not None:
            domain.append(("employee_id.xeno_employee_code", "in",
                           [str(c) for c in restrict_codes]))
        elif employee_code:
            domain.append(("employee_id.xeno_employee_code", "=", str(employee_code)))
        odoo_leaves = {}
        for lv in Leave.search(domain):
            code = lv.employee_id.xeno_employee_code
            if not code:
                continue
            info = {
                "abbr": self._xeno_leave_abbr(lv.holiday_status_id.name),
                "unit": lv.xeno_request_unit or "full_day",
                "hf": lv.request_hour_from,
                "ht": lv.request_hour_to,
            }
            d, end = lv.request_date_from, lv.request_date_to
            while d <= end:
                ds = d.strftime("%Y-%m-%d")
                if date_from <= ds <= date_to:
                    odoo_leaves[(str(code), ds)] = info
                d += datetime.timedelta(days=1)
        return odoo_leaves

    def _xeno_calendar_sessions(self, codes):
        """employee_code -> {weekday '0'..'6': sorted [(hour_from, hour_to)]}
        from each employee's resource.calendar, so the report can tell where
        the morning session ends / the afternoon session starts (needed for
        first-half / second-half leave on-time thresholds)."""
        Employee = self.env["hr.employee"].sudo()
        employees = Employee.search([("xeno_employee_code", "in", [str(c) for c in codes])])
        sessions = {}
        for emp in employees:
            cal = emp.resource_calendar_id
            by_dow = {}
            for att in cal.attendance_ids:
                by_dow.setdefault(att.dayofweek, []).append((att.hour_from, att.hour_to))
            for dow in by_dow:
                by_dow[dow].sort()
            sessions[str(emp.xeno_employee_code)] = by_dow
        return sessions

    def _xeno_leave_info(self, emp_id, code, datestr, leaves, odoo_leaves):
        """Unified leave lookup for one (employee, day): the richer Odoo
        record if present (carries unit + hours), else HRSystem's full-day
        leave code. Returns {abbr, unit, hf, ht} or None."""
        info = odoo_leaves.get((code, datestr))
        if info:
            return info
        for lv in leaves:
            if lv["employee_id"] == emp_id and lv["start"] <= datestr <= lv["end"]:
                return {"abbr": lv["code"], "unit": "full_day", "hf": None, "ht": None}
        return None

    def _xeno_recompute_late(self, rows, days, year, month, holidays, leaves,
                             overrides, odoo_leaves, sessions, dayoffs=None,
                             business_trips=None):
        """Set each row's `late` to the sum of per-working-day lateness:
        max(0, first-scan - expected start). Expected start is leave-aware
        (part-time end / afternoon start for first-half / etc). A full-day
        leave day is skipped (no scan expected); weekends, holidays,
        HR-granted days off, and acknowledged business trips too."""
        dayoffs = dayoffs or {}
        business_trips = business_trips or {}

        def is_weekend(d):
            return datetime.date(year, month, d).weekday() >= 5

        for r in rows:
            emp_id = r.get("employee_id")
            code = str(r.get("employee_code") or "")
            sstart = (r.get("shift_start") or "09:00")[:5]
            send = (r.get("shift_end") or "18:00")[:5]
            emp_sessions = sessions.get(code, {})
            total = 0
            for d in range(1, days + 1):
                datestr = "%04d-%02d-%02d" % (year, month, d)
                if (is_weekend(d) or datestr in holidays
                        or (code, datestr) in dayoffs or (code, datestr) in business_trips):
                    continue
                ov = overrides.get((code, datestr))
                tin = (ov["time_in"] if (ov and ov["time_in"])
                       else self._fmt(r.get("%s.In" % datestr)))
                if not tin:
                    continue
                leave = self._xeno_leave_info(emp_id, code, datestr, leaves, odoo_leaves)
                dow = str(datetime.date(year, month, d).weekday())
                exp_start, _exp_end = self._xeno_expected_window(
                    sstart, send, emp_sessions.get(dow), leave)
                if exp_start is None:  # full-day leave -> no late
                    continue
                tin_min, exp_min = self._to_minutes(tin), self._to_minutes(exp_start)
                if tin_min is not None and exp_min is not None and tin_min > exp_min:
                    total += tin_min - exp_min
            r["late"] = total

    def _xeno_local_tz(self):
        return timezone(
            self.env.context.get("tz") or self.env.user.tz
            or self.env.company.resource_calendar_id.tz or "UTC")

    @api.model
    def get_today_status(self, employee_code):
        """Today's merged first-in/last-out for one employee, from either
        source (the biometric scanner via HRSystem, or Odoo's own
        hr.attendance -- see _xeno_merge_native_attendance, which already
        combines both into the same "%s.In"/"%s.Out" cells). Used by the
        "My Attendance" widget to detect a fingerprint check-in that Odoo's
        own hr_attendance module has no record of yet, so it can still show
        Present/Check Out instead of Absent/Check In.
        """
        if not employee_code:
            return {"in": None, "out": None}
        tz = self._xeno_local_tz()
        today = datetime.datetime.now(tz).date()
        data = self.get_month_report(
            today.year, today.month, employee_code=employee_code,
            restrict_codes=[employee_code])
        datestr = today.strftime("%Y-%m-%d")
        row = next(
            (r for r in data["rows"] if str(r.get("employee_code")) == str(employee_code)),
            None)
        if not row:
            return {"in": None, "out": None}
        in_v = self._fmt(row.get("%s.In" % datestr))
        out_v = self._fmt(row.get("%s.Out" % datestr))
        # In = first scan, Out = last scan. With only one scan so far today
        # (the common "checked in this morning, not out yet" case), both
        # collapse to the same time -- that's a check-in with no check-out,
        # so report out as empty. A real check-out is a later, distinct scan.
        if in_v and out_v and in_v == out_v:
            out_v = None
        return {"in": in_v, "out": out_v}

    def get_month_present_count(self, employee_code, year, month):
        """Distinct calendar days this month with at least one recorded
        check-in for one employee, from the SAME merged source (biometric
        scanner via HRSystem's vw_attendance_combined + Odoo's own native
        hr.attendance, see _xeno_merge_native_attendance) as the My
        Attendance report -- NOT a raw hr.attendance count. An employee who
        only ever scans the physical biometric machine has zero
        hr.attendance rows in Odoo, so counting hr.attendance alone (the
        bug this method replaces, in xeno_mobile's own "This Month / Days
        Present" card) silently showed 0 for them even with a full month of
        real attendance on the desktop report.
        """
        if not employee_code:
            return 0
        data = self.get_month_report(
            year, month, employee_code=employee_code,
            restrict_codes=[employee_code])
        row = next(
            (r for r in data["rows"] if str(r.get("employee_code")) == str(employee_code)),
            None)
        if not row:
            return 0
        suffix = ".In"
        return sum(1 for k, v in row.items() if k.endswith(suffix) and v)

    @api.model
    def close_fingerprint_day(self, employee_id):
        """Create the Odoo-native hr.attendance record that closes out a day
        that started with a fingerprint-only check-in (no hr.attendance row
        yet): check_in = the fingerprint's first scan time today, check_out
        = now. Everything is re-verified server-side (no open native
        attendance already exists; a fingerprint "in" with no "out" really
        is there today) rather than trusting the client's cached state.
        """
        employee = self.env["hr.employee"].sudo().browse(employee_id)
        if not employee.exists() or not employee.xeno_employee_code:
            raise UserError(_("No linked employee/HRSystem code found."))
        code = employee.xeno_employee_code

        open_native = self._xeno_open_attendance(employee.id)
        if open_native:
            if self._xeno_attendance_age_hours(open_native) > STALE_CHECKIN_HOURS:
                # A forgotten checkout from an earlier day, not a real
                # conflict -- clean it up the same way close_stale_checkin
                # does, then proceed.
                self._xeno_close_stale(open_native)
            else:
                raise UserError(_(
                    "An attendance check-in already exists in Odoo -- use the "
                    "normal Check Out button instead."))

        status = self.get_today_status(code)
        if not status["in"] or status["out"]:
            raise UserError(_("No open fingerprint check-in found for today."))

        tz = self._xeno_local_tz()
        today = datetime.datetime.now(tz).date()
        hour, minute = (int(x) for x in status["in"].split(":"))
        local_in = tz.localize(datetime.datetime.combine(today, datetime.time(hour, minute)))
        check_in_utc = local_in.astimezone(utc).replace(tzinfo=None)
        check_out_utc = datetime.datetime.utcnow()
        if check_out_utc <= check_in_utc:
            raise UserError(_(
                "Check-out time must be after the fingerprint check-in time."))

        attendance = self.env["hr.attendance"].sudo().create({
            "employee_id": employee.id,
            "check_in": check_in_utc,
            "check_out": check_out_utc,
        })
        return {
            "attendance_state": "checked_out",
            "last_check_in": fields.Datetime.to_string(attendance.check_in),
            "last_check_out": fields.Datetime.to_string(attendance.check_out),
        }

    def _xeno_open_attendance(self, employee_id):
        return self.env["hr.attendance"].sudo().search(
            [("employee_id", "=", employee_id), ("check_out", "=", False)],
            limit=1, order="check_in desc")

    @staticmethod
    def _xeno_attendance_age_hours(attendance):
        return (datetime.datetime.utcnow() - attendance.check_in).total_seconds() / 3600.0

    def _xeno_close_stale(self, attendance):
        """Close a forgotten open check-in at 23:59:59 on its own calendar
        day (local tz) -- a conservative marker that doesn't bleed hours
        into the following day."""
        tz = self._xeno_local_tz()
        local_in = utc.localize(attendance.check_in).astimezone(tz)
        local_end_of_day = tz.localize(
            datetime.datetime.combine(local_in.date(), datetime.time(23, 59, 59)))
        attendance.write({"check_out": local_end_of_day.astimezone(utc).replace(tzinfo=None)})

    @api.model
    def get_native_checkin_status(self, employee_id):
        """Whether the employee currently has an open (no check_out) native
        hr.attendance record, and whether it's stale (see STALE_CHECKIN_HOURS)
        -- Odoo's own attendance_state has no per-day boundary at all, so
        this lets the My Attendance widget tell a genuinely still-open shift
        from one that was simply never checked out."""
        att = self._xeno_open_attendance(employee_id)
        if not att:
            return {"open": False, "stale": False}
        return {"open": True, "stale": self._xeno_attendance_age_hours(att) > STALE_CHECKIN_HOURS}

    @api.model
    def close_stale_checkin(self, employee_id):
        """Auto-close a forgotten open check-in (re-verified stale
        server-side) so the employee can check in fresh today."""
        att = self._xeno_open_attendance(employee_id)
        if not att:
            return {"closed": False}
        if self._xeno_attendance_age_hours(att) <= STALE_CHECKIN_HOURS:
            raise UserError(_(
                "This check-in isn't stale yet -- use the normal Check Out button."))
        self._xeno_close_stale(att)
        return {"closed": True}

    def _xeno_merge_native_attendance(self, rows, year, month, days,
                                       employee_directory, restrict_codes=None,
                                       employee_code=None):
        """Now that XENHR is frozen (employees can no longer reach it),
        check-ins happen two ways: the biometric scanners (still writing
        straight to HRSystem's vw_attendance_combined, untouched) and
        Odoo's own GPS/systray widget (hr.attendance, which HRSystem has
        no knowledge of at all). Without this, any native Odoo check-in
        silently never appears on Monthly/My/My Team Attendance -- mutates
        `rows` in place: fills a blank HRSystem cell from the native scan,
        or appends a whole new row for an employee HRSystem has nothing
        for this month (mirrors the auto-fill append pattern below).
        """
        Employee = self.env["hr.employee"].sudo()
        domain = [("xeno_employee_code", "!=", False)]
        if restrict_codes is not None:
            domain.append(("xeno_employee_code", "in", [str(c) for c in restrict_codes]))
        if employee_code:
            domain.append(("xeno_employee_code", "=", str(employee_code)))
        employees = Employee.search(domain)
        if not employees:
            return
        emp_by_code = {e.xeno_employee_code: e for e in employees}

        tz = self._xeno_local_tz()
        # Padded a day either side in UTC to safely catch local-day
        # boundary punches for a positive-offset timezone (e.g. Bangkok).
        range_from = datetime.datetime(year, month, 1) - datetime.timedelta(days=1)
        range_to = datetime.datetime(year, month, days) + datetime.timedelta(days=2)
        atts = self.env["hr.attendance"].sudo().search([
            ("employee_id", "in", employees.ids),
            ("check_in", ">=", range_from),
            ("check_in", "<", range_to),
        ])

        # (employee_code, "YYYY-MM-DD") -> {"in": [...], "out": [...]}
        by_day = {}
        for att in atts:
            emp = att.employee_id
            code = emp.xeno_employee_code
            local_in = utc.localize(att.check_in).astimezone(tz)
            datestr = local_in.strftime("%Y-%m-%d")
            key = (code, datestr)
            by_day.setdefault(key, {"in": [], "out": []})
            by_day[key]["in"].append(local_in.time())
            if att.check_out:
                local_out = utc.localize(att.check_out).astimezone(tz)
                by_day[key]["out"].append(local_out.time())

        if not by_day:
            return

        rows_by_code = {}
        for r in rows:
            rows_by_code.setdefault(str(r.get("employee_code")), r)

        for (code, datestr), scans in by_day.items():
            first_in = min(scans["in"]) if scans["in"] else None
            last_out = max(scans["out"]) if scans["out"] else None
            row = rows_by_code.get(code)
            if row is None:
                emp = emp_by_code.get(code)
                if not emp:
                    continue
                row = {"employee_id": emp.id, "employee_code": code,
                       "employee_name": emp.name, "late": 0}
                rows.append(row)
                rows_by_code[code] = row
            in_key, out_key = "%s.In" % datestr, "%s.Out" % datestr
            if not row.get(in_key) and first_in:
                row[in_key] = first_in
            if not row.get(out_key) and last_out:
                row[out_key] = last_out

    # -------------------------------------------------------------- helpers
    @api.model
    def _esc(self, s):
        return _html.escape("" if s is None else str(s))

    @staticmethod
    def _leave_chip_style(code):
        bg, ink = LEAVE_TYPE_COLORS.get(code, LEAVE_TYPE_COLOR_DEFAULT)
        return "background:%s;color:%s" % (bg, ink)

    @staticmethod
    def _pill_style(bg, color):
        """Inline style attr for an attendance pill: an optional leave-colour
        background and/or an explicit font colour (red for late/early, the
        leave ink otherwise). Empty when neither applies, so plain scans
        keep the stylesheet's default look."""
        parts = []
        if bg:
            parts.append("background:%s" % bg)
        if color:
            parts.append("color:%s" % color)
        return ' style="%s"' % ";".join(parts) if parts else ""

    @staticmethod
    def _fmt(t):
        """datetime.time / str / None -> 'HH:MM' or None."""
        if t is None:
            return None
        if isinstance(t, str):
            return t[:5]
        if isinstance(t, (datetime.time, datetime.datetime)):
            return t.strftime("%H:%M")
        return str(t)[:5]

    @staticmethod
    def _to_minutes(hhmm):
        """'HH:MM' -> minutes since midnight, or None."""
        if not hhmm or len(hhmm) < 5:
            return None
        try:
            return int(hhmm[:2]) * 60 + int(hhmm[3:5])
        except ValueError:
            return None

    @staticmethod
    def _hour_float_to_hhmm(hour_float):
        """9.0 -> '09:00', 14.5 -> '14:30' (part-time leave hours are floats)."""
        h = int(hour_float)
        m = int(round((hour_float - h) * 60))
        if m == 60:
            h, m = h + 1, 0
        return "%02d:%02d" % (h, m)

    def _xeno_expected_window(self, sstart, send, day_sessions, leave):
        """The (expected_start, expected_end) 'HH:MM' pair used to decide
        late-in / early-out for one day, adjusted for any leave:

        - no leave                -> (shift start, shift end)
        - full_day                -> (None, None)   (no scan expected)
        - part_time (off hf..ht)  -> due in at ht, out at shift end
        - first_half (morning off)-> due in at the afternoon session start
        - second_half (arvo off)  -> in at shift start, out at morning end

        day_sessions is the sorted [(hour_from, hour_to), ...] for that
        weekday; when it's missing we fall back to plain shift start/end.
        """
        if not leave:
            return sstart, send
        unit = leave.get("unit") or "full_day"
        if unit == "full_day":
            return None, None
        if not day_sessions:
            if unit == "part_time" and leave.get("ht") is not None:
                return self._hour_float_to_hhmm(leave["ht"]), send
            return sstart, send
        morning_end = day_sessions[0][1]
        afternoon_start = day_sessions[1][0] if len(day_sessions) > 1 else day_sessions[0][0]
        if unit == "part_time":
            es = (self._hour_float_to_hhmm(leave["ht"])
                  if leave.get("ht") is not None else sstart)
            return es, send
        if unit == "first_half":
            return self._hour_float_to_hhmm(afternoon_start), send
        if unit == "second_half":
            return sstart, self._hour_float_to_hhmm(morning_end)
        return sstart, send

    # ------------------------------------------------------------- rendering
    @api.model
    def render_html(self, year, month, employee_code=None, restrict_codes=None,
                     base_url="/odoo/attendances/bymonth", team_mode=False,
                     my_mode=False, can_edit=False,
                     department_id=None, category_id=None, job_id=None):
        data = self.get_month_report(year, month, employee_code, restrict_codes)
        rows, days = data["rows"], data["days"]
        holidays, leaves = data["holidays"], data["leaves"]
        unmapped_codes = data.get("unmapped_codes") or []
        overrides = data.get("overrides") or {}
        odoo_leaves = data.get("odoo_leaves") or {}
        sessions = data.get("sessions") or {}
        dayoffs = data.get("dayoffs") or {}
        business_trips = data.get("business_trips") or {}
        E = self._esc
        RED = "#ff2323"

        def is_weekend(d):
            return datetime.date(year, month, d).weekday() >= 5

        # ----- header rows
        h1, h2 = [], []
        for d in range(1, days + 1):
            datestr = "%04d-%02d-%02d" % (year, month, d)
            wknd, hol = is_weekend(d), datestr in holidays
            dow = datetime.date(year, month, d).strftime("%a")
            klass = "hol" if hol else ("wknd" if wknd else "")
            title = ' title="%s"' % E(holidays[datestr]) if hol else ""
            h1.append('<th colspan="2" class="day %s"%s><span class="dnum">%d</span>'
                      '<span class="dow">%s</span></th>' % (klass, title, d, dow))
            h2.append('<th class="io %s">In</th><th class="io io-out %s">Out</th>'
                      % (klass, klass))

        # ----- body rows
        auto_fill_codes = self.env["xeno.attendance.auto.rule"].sudo().get_auto_fill_codes()
        body = []
        for i, r in enumerate(rows):
            rc = "even" if i % 2 == 0 else "odd"
            emp_id = r.get("employee_id")
            emp_code = str(r.get("employee_code") or "")
            sstart = (r.get("shift_start") or "09:00")[:5]
            send = (r.get("shift_end") or "18:00")[:5]
            emp_sessions = sessions.get(emp_code, {})
            cells = []
            for d in range(1, days + 1):
                datestr = "%04d-%02d-%02d" % (year, month, d)
                wknd, hol = is_weekend(d), datestr in holidays
                off = (emp_code, datestr) in dayoffs
                bt = (emp_code, datestr) in business_trips
                klass = "bt" if bt else ("off" if off else ("hol" if hol else ("wknd" if wknd else "")))
                raw_tin = self._fmt(r.get("%s.In" % datestr))
                raw_tout = self._fmt(r.get("%s.Out" % datestr))
                ov = overrides.get((emp_code, datestr))
                tin = (ov["time_in"] if ov and ov["time_in"] else raw_tin)
                tout = (ov["time_out"] if ov and ov["time_out"] else raw_tout)
                in_edited = bool(ov and ov["time_in"])
                out_edited = bool(ov and ov["time_out"])

                leave = (self._xeno_leave_info(emp_id, emp_code, datestr, leaves, odoo_leaves)
                         if not wknd and not hol and not off and not bt else None)
                is_full_leave = bool(leave) and leave.get("unit") == "full_day"
                lv_bg = lv_ink = None
                if leave:
                    lv_bg, lv_ink = LEAVE_TYPE_COLORS.get(
                        leave["abbr"], LEAVE_TYPE_COLOR_DEFAULT)

                # Late-in / early-out against the leave-aware expected window
                # (a part-time / half-day leave shifts the on-time threshold).
                in_late = out_early = False
                if not wknd and not hol and not off and not bt and not is_full_leave:
                    dow = str(datetime.date(year, month, d).weekday())
                    exp_start, exp_end = self._xeno_expected_window(
                        sstart, send, emp_sessions.get(dow), leave)
                    if tin and exp_start and self._to_minutes(tin) > self._to_minutes(exp_start):
                        in_late = True
                    if tout and exp_end and self._to_minutes(tout) < self._to_minutes(exp_end):
                        out_early = True

                # Auto-fill (ported from XENHR's AttendanceAutoRule/
                # auto_fill): a working day with no scan at all for an
                # exempted employee/department reads as Present, not blank
                # (implicitly Absent) -- never overrides a real scan.
                auto_filled = (not tin and not wknd and not hol and not off and not bt
                               and not leave and emp_code in auto_fill_codes)

                in_title = out_title = ""
                if bt:
                    bt_label = business_trips.get((emp_code, datestr))
                    in_title = out_title = ' title="Business Trip%s"' % (
                        (": " + E(bt_label)) if bt_label else "")
                elif off and dayoffs.get((emp_code, datestr)):
                    in_title = out_title = ' title="Day off: %s"' % E(dayoffs[(emp_code, datestr)])
                if in_edited:
                    in_title = ' title="Edited by %s on %s — source: %s%s"' % (
                        E(ov["editor"]), E(ov["edited_at"]), E(raw_tin or "—"),
                        (" — " + E(ov["note"])) if ov["note"] else "")
                if out_edited:
                    out_title = ' title="Edited by %s on %s — source: %s%s"' % (
                        E(ov["editor"]), E(ov["edited_at"]), E(raw_tout or "—"),
                        (" — " + E(ov["note"])) if ov["note"] else "")
                edit_attrs = ' data-ec="%s" data-date="%s"' % (E(emp_code), datestr) \
                    if can_edit else ""

                # Cell contents + pill styling:
                #  - full-day leave: the leave abbreviation in BOTH columns,
                #    pill background in the leave colour.
                #  - part-time / half-day leave: the real scan times, pill
                #    background still in the leave colour.
                #  - late-in / early-out: red font, no background of its own.
                if bt:
                    in_text = out_text = "BT"
                elif off:
                    in_text = out_text = "Off"
                elif is_full_leave:
                    in_text = out_text = E(leave["abbr"])
                elif auto_filled:
                    in_text, out_text = "Present", (tout or "&ndash;")
                else:
                    in_text = tin or "&ndash;"
                    out_text = tout or "&ndash;"

                # Times keep the default (normal) font unless late/early
                # (then red); only a full-day leave's abbreviation is drawn
                # in the leave's own ink. The leave background applies either
                # way.
                in_color = RED if in_late else (lv_ink if is_full_leave else None)
                out_color = RED if out_early else (lv_ink if is_full_leave else None)
                in_cls = "edited" if in_edited else ("autofill" if auto_filled else "")
                out_cls = "edited" if out_edited else ""

                cells.append(
                    '<td class="io %s"><span class="pill %s"%s%s%s data-field="in">%s</span></td>'
                    % (klass, in_cls, self._pill_style(lv_bg, in_color),
                       in_title, edit_attrs, in_text))
                cells.append(
                    '<td class="io io-out %s"><span class="pill %s"%s%s%s data-field="out">%s</span></td>'
                    % (klass, out_cls, self._pill_style(lv_bg, out_color),
                       out_title, edit_attrs, out_text))
            late = r.get("late") or 0
            late_cls = "has" if late else ""
            body.append(
                '<tr class="%s"><td class="c-emp"><span class="en">%s</span>'
                '<span class="ec">%s</span></td>'
                '<td class="c-late"><span class="late-val %s">%s</span></td>%s</tr>'
                % (rc, E(r.get("employee_name")), E(r.get("employee_code")),
                   late_cls, late, "".join(cells)))

        if not rows:
            body.append('<tr><td class="c-emp">&mdash;</td><td class="c-late"></td>'
                        '<td colspan="%d" style="padding:2rem;color:var(--ink-400)">'
                        'No attendance records for this month.</td></tr>' % (days * 2))

        # ----- toolbar controls
        emp_opts = ['<option value="">&mdash; All employees &mdash;</option>']
        seen = set()
        for r in sorted(rows, key=lambda x: str(x.get("employee_name") or "")):
            code = str(r.get("employee_code") or "")
            if code in seen:
                continue
            seen.add(code)
            sel = " selected" if employee_code and str(employee_code) == code else ""
            emp_opts.append('<option value="%s"%s>%s (%s)</option>'
                            % (E(code), sel, E(r.get("employee_name")), E(code)))
        month_opts = "".join(
            '<option value="%d"%s>%s</option>'
            % (m, " selected" if m == month else "", MONTHS[m - 1])
            for m in range(1, 13))
        this_year = datetime.date.today().year
        year_opts = "".join(
            '<option value="%d"%s>%d</option>'
            % (y, " selected" if y == year else "", y)
            for y in range(this_year, this_year - 5, -1))

        leg = "".join('<span class="lv leg" style="%s">%s</span>' % (self._leave_chip_style(c), c)
                      for c in ["AL", "PL", "SL", "ML", "UL"])

        # Department / Group / Position filters -- full company-wide lists
        # (not narrowed to whoever's already in `rows`, since rows are
        # already filtered by whatever was picked), same reload-on-change
        # pattern as the existing Employee/Month/Year selects.
        dept_opts = ['<option value="">&mdash; All departments &mdash;</option>']
        for dept in self.env["hr.department"].sudo().search([], order="name"):
            sel = " selected" if department_id and str(department_id) == str(dept.id) else ""
            dept_opts.append('<option value="%s"%s>%s</option>' % (dept.id, sel, E(dept.name)))

        group_opts = ['<option value="">&mdash; All groups &mdash;</option>']
        for cat in self.env["hr.employee.category"].sudo().search([], order="name"):
            sel = " selected" if category_id and str(category_id) == str(cat.id) else ""
            group_opts.append('<option value="%s"%s>%s</option>' % (cat.id, sel, E(cat.name)))

        job_opts = ['<option value="">&mdash; All positions &mdash;</option>']
        for job in self.env["hr.job"].sudo().search([], order="name"):
            sel = " selected" if job_id and str(job_id) == str(job.id) else ""
            job_opts.append('<option value="%s"%s>%s</option>' % (job.id, sel, E(job.name)))

        if unmapped_codes:
            banner = (
                '<div class="warn-banner"><strong>%d unrecognized employee '
                'code(s)</strong> seen in this period\'s raw scans that '
                'don\'t match any employee or known remap: <code>%s</code>. '
                'Their attendance is not shown above. Add a remap under '
                'Attendances &rarr; Configuration &rarr; Attendance Code '
                'Remaps if these are a known old/duplicate code, or check '
                'the device/employee record if not.</div>'
                % (len(unmapped_codes), E(", ".join(unmapped_codes)))
            )
        else:
            banner = ""

        edit_script = _EDIT_SCRIPT % {"base_url": base_url} if can_edit else ""

        # My Attendance is a single-employee self-view -- picking a
        # different employee, or filtering by department/group/position,
        # is meaningless there, so those four selects are suppressed for
        # my_mode only (Monthly Attendance and My Team keep all of them).
        if my_mode:
            extra_filters_pre = ""
            extra_filters_post = ""
        else:
            extra_filters_pre = (
                '<div class="field"><label for="employee">Employee</label>'
                '<select class="control" id="employee" name="employee" '
                'onchange="this.form.submit()">%s</select></div>'
            ) % "".join(emp_opts)
            extra_filters_post = (
                '<div class="field"><label for="department">Department</label>'
                '<select class="control" id="department" name="department" '
                'onchange="this.form.submit()">%s</select></div>'
                '<div class="field"><label for="group">Group</label>'
                '<select class="control" id="group" name="group" '
                'onchange="this.form.submit()">%s</select></div>'
                '<div class="field"><label for="position">Position</label>'
                '<select class="control" id="position" name="position" '
                'onchange="this.form.submit()">%s</select></div>'
            ) % ("".join(dept_opts), "".join(group_opts), "".join(job_opts))

        if my_mode:
            kind, kind_html = "My Attendance", "My Attendance"
        elif team_mode:
            kind, kind_html = "My Team", "My Team"
        else:
            kind, kind_html = "Monthly Attendance", "Monthly Attendance"
        eyebrow = "Xenoptics HR · My Team Attendance" if team_mode \
            else ("Xenoptics HR · My Attendance" if my_mode
                  else "Xenoptics HR · Attendance")
        title_plain = "%s - %s %d" % (kind, MONTHS[month - 1], year)
        title = "%s &mdash; %s %d" % (kind_html, MONTHS[month - 1], year)

        return _PAGE % {
            "css": _CSS,
            "eyebrow": eyebrow,
            "title": title,
            "title_plain": title_plain,
            "base_url": base_url,
            "extra_filters_pre": extra_filters_pre,
            "month_opts": month_opts,
            "year_opts": year_opts,
            "extra_filters_post": extra_filters_post,
            "legend": leg,
            "head1": "".join(h1),
            "head2": "".join(h2),
            "body": "".join(body),
            "nrows": len(rows),
            "ndays": days,
            "export_qs": "year=%d&month=%d" % (year, month)
                         + ("&employee=%s" % employee_code if employee_code else "")
                         + ("&department=%s" % department_id if department_id else "")
                         + ("&group=%s" % category_id if category_id else "")
                         + ("&position=%s" % job_id if job_id else ""),
            "banner": banner,
            "edit_script": edit_script,
        }

    # -------------------------------------------------------------- export
    @api.model
    def export_xlsx(self, year, month, employee_code=None, restrict_codes=None):
        import xlsxwriter

        data = self.get_month_report(year, month, employee_code, restrict_codes)
        rows, days = data["rows"], data["days"]
        holidays, leaves = data["holidays"], data["leaves"]
        overrides = data.get("overrides") or {}
        odoo_leaves = data.get("odoo_leaves") or {}
        sessions = data.get("sessions") or {}
        dayoffs = data.get("dayoffs") or {}
        business_trips = data.get("business_trips") or {}

        def is_weekend(d):
            return datetime.date(year, month, d).weekday() >= 5

        def leave_for(emp_id, emp_code, datestr):
            info = self._xeno_leave_info(emp_id, str(emp_code), datestr, leaves, odoo_leaves)
            return info["abbr"] if info else None

        buf = io.BytesIO()
        wb = xlsxwriter.Workbook(buf, {"in_memory": True})
        ws = wb.add_worksheet("Attendance")

        navy = "#142933"
        RED = "#ff2323"
        f_title = wb.add_format({"bold": True, "font_size": 14, "font_color": navy})
        f_head = wb.add_format({"bold": True, "bg_color": "#e7edef", "border": 1,
                                 "align": "center", "valign": "vcenter"})
        f_head_wknd = wb.add_format({"bold": True, "bg_color": "#e3e9eb", "border": 1,
                                      "align": "center", "valign": "vcenter"})
        f_head_hol = wb.add_format({"bold": True, "bg_color": "#dff2e6", "border": 1,
                                     "align": "center", "valign": "vcenter"})
        f_emp = wb.add_format({"border": 1, "valign": "vcenter"})
        f_code = wb.add_format({"border": 1, "valign": "vcenter", "font_color": "#6d8791"})
        f_late = wb.add_format({"border": 1, "align": "center"})
        f_late_has = wb.add_format({"border": 1, "align": "center",
                                     "font_color": RED, "bold": True})

        # Cell formats are built on demand and cached -- background/font
        # combinations depend on leave type (5 colors, see LEAVE_TYPE_COLORS)
        # crossed with late/early/weekend/holiday/edited, so a fixed set of
        # named formats would combinatorially explode.
        _fmt_cache = {}

        def get_fmt(bg=None, font_color=None, bold=False):
            key = (bg, font_color, bold)
            if key not in _fmt_cache:
                props = {"border": 1, "align": "center"}
                if bg:
                    props["bg_color"] = bg
                if font_color:
                    props["font_color"] = font_color
                if bold:
                    props["bold"] = True
                _fmt_cache[key] = wb.add_format(props)
            return _fmt_cache[key]

        ws.merge_range(0, 0, 0, 3, "Monthly Attendance -- %s %d" % (MONTHS[month - 1], year), f_title)
        ws.write(1, 0, "Merged source: app/GPS punches + biometric machine "
                       "(vw_attendance_combined -> sp_get_attendance_month_V3)")
        ws.write(2, 0, "* = HR-corrected value — source scan overridden, see Attendance Overrides for detail")

        # ----- leave-type color legend (same colors as the leave-colored
        # cell fills below and the on-screen report's own legend), plus a
        # late/early reference swatch since that's a font color, not a fill.
        LEGEND_ROW = 3
        ws.write(LEGEND_ROW, 0, "Legend:", wb.add_format({"bold": True}))
        legend_col = 1
        for code, (bg, ink) in LEAVE_TYPE_COLORS.items():
            ws.write(LEGEND_ROW, legend_col, code, get_fmt(bg, ink, True))
            legend_col += 1
        ws.write(LEGEND_ROW, legend_col, "Late / Early", get_fmt(None, RED, True))

        HEADER_ROW = LEGEND_ROW + 1
        ws.write(HEADER_ROW, 0, "Employee Code", f_head)
        ws.write(HEADER_ROW, 1, "Employee Name", f_head)
        ws.write(HEADER_ROW, 2, "Late (min)", f_head)
        ws.set_row(HEADER_ROW, None)
        col = 3
        day_first_col = {}
        for d in range(1, days + 1):
            datestr = "%04d-%02d-%02d" % (year, month, d)
            hol = datestr in holidays
            wknd = is_weekend(d)
            fmt = f_head_hol if hol else (f_head_wknd if wknd else f_head)
            ws.merge_range(HEADER_ROW, col, HEADER_ROW, col + 1,
                            "%d (%s)" % (d, datetime.date(year, month, d).strftime("%a")), fmt)
            ws.write(HEADER_ROW + 1, col, "In", fmt)
            ws.write(HEADER_ROW + 1, col + 1, "Out", fmt)
            day_first_col[d] = col
            col += 2

        ws.set_column(0, 0, 14)
        ws.set_column(1, 1, 22)
        ws.set_column(2, 2, 10)
        ws.set_column(3, col - 1, 8)
        ws.freeze_panes(HEADER_ROW + 2, 3)

        r = HEADER_ROW + 2
        for row in rows:
            emp_id = row.get("employee_id")
            emp_code = str(row.get("employee_code") or "")
            sstart = (row.get("shift_start") or "09:00")[:5]
            send = (row.get("shift_end") or "18:00")[:5]
            emp_sessions = sessions.get(emp_code, {})
            ws.write(r, 0, row.get("employee_code"), f_code)
            ws.write(r, 1, row.get("employee_name"), f_emp)
            late = row.get("late") or 0
            ws.write(r, 2, late, f_late_has if late else f_late)
            for d in range(1, days + 1):
                datestr = "%04d-%02d-%02d" % (year, month, d)
                wknd, hol = is_weekend(d), datestr in holidays
                off = (emp_code, datestr) in dayoffs
                bt = (emp_code, datestr) in business_trips
                ov = overrides.get((emp_code, datestr))
                in_edited = bool(ov and ov["time_in"])
                out_edited = bool(ov and ov["time_out"])
                tin = (ov["time_in"] if in_edited else self._fmt(row.get("%s.In" % datestr)))
                tout = (ov["time_out"] if out_edited else self._fmt(row.get("%s.Out" % datestr)))
                leave = (self._xeno_leave_info(emp_id, emp_code, datestr, leaves, odoo_leaves)
                         if not wknd and not hol and not off and not bt else None)
                is_full_leave = bool(leave) and leave.get("unit") == "full_day"

                # Background: leave color wins over weekend/holiday tint,
                # which wins over plain white -- same priority as the
                # on-screen report. Font: red for late-in/early-out (this
                # request), else the leave's own ink for a full-day leave's
                # abbreviation, else the weekend/holiday tint's ink.
                lv_bg = lv_ink = None
                if leave:
                    lv_bg, lv_ink = LEAVE_TYPE_COLORS.get(leave["abbr"], LEAVE_TYPE_COLOR_DEFAULT)
                if bt:
                    base_bg = "#cffafe"
                elif off:
                    base_bg = "#dbeafe"
                elif leave:
                    base_bg = lv_bg
                elif hol:
                    base_bg = "#dff2e6"
                elif wknd:
                    base_bg = "#e3e9eb"
                else:
                    base_bg = None
                base_font = None
                if bt:
                    base_font = "#0e7490"
                elif off:
                    base_font = "#1d4ed8"
                elif not leave:
                    if hol:
                        base_font = "#1f7a4d"
                    elif wknd:
                        base_font = "#7d939b"

                in_late = out_early = False
                if not wknd and not hol and not off and not bt and not is_full_leave:
                    dow = str(datetime.date(year, month, d).weekday())
                    exp_start, exp_end = self._xeno_expected_window(
                        sstart, send, emp_sessions.get(dow), leave)
                    if tin and exp_start and self._to_minutes(tin) > self._to_minutes(exp_start):
                        in_late = True
                    if tout and exp_end and self._to_minutes(tout) < self._to_minutes(exp_end):
                        out_early = True

                in_font = RED if in_late else (lv_ink if is_full_leave else base_font)
                out_font = RED if out_early else (lv_ink if is_full_leave else base_font)
                # HR-corrected (edited) cells no longer get their own blue
                # override -- they keep the same late/early/leave-type
                # background+font as any other cell; the trailing "*" below
                # is the only thing marking a value as HR-corrected.
                in_fmt = get_fmt(base_bg, in_font, bool(in_late or is_full_leave or off or bt))
                out_fmt = get_fmt(base_bg, out_font, bool(out_early or is_full_leave or off or bt))
                lv = leave["abbr"] if leave else None
                if bt:
                    in_text = out_text = "BT"
                elif off:
                    in_text = out_text = "Off"
                elif is_full_leave:
                    # Full-day leave: the abbreviation stands in for the time
                    # in both columns (mirrors the on-screen page).
                    in_text = out_text = lv
                else:
                    in_text = tin or ""
                    out_text = tout or ""
                    if lv:
                        in_text = ("%s [%s]" % (in_text, lv)) if in_text else "[%s]" % lv
                        out_text = ("%s [%s]" % (out_text, lv)) if out_text else "[%s]" % lv
                if in_edited:
                    in_text = "%s *" % in_text if in_text else "*"
                if out_edited:
                    out_text = "%s *" % out_text if out_text else "*"
                c = day_first_col[d]
                ws.write(r, c, in_text, in_fmt)
                ws.write(r, c + 1, out_text, out_fmt)
            r += 1

        if not rows:
            ws.write(r, 0, "No attendance records for this month.")

        wb.close()
        buf.seek(0)
        return buf.read()


_CSS = """
@font-face{font-family:'Oswald Var';src:url(/xeno_attendance/static/fonts/oswald.woff2) format('woff2');font-weight:200 700;font-display:swap}
@font-face{font-family:'Plex Var';src:url(/xeno_attendance/static/fonts/plexsans.woff2) format('woff2');font-weight:100 700;font-display:swap}
:root{--navy-900:#142933;--ink-900:#142933;--ink-600:#3f5964;--ink-400:#6d8791;
--surface-0:#eef2f4;--surface-1:#fff;--surface-2:#f4f7f8;--surface-head:#e7edef;
--border:#d8e1e4;--border-strong:#b7c8cd;--accent:#ec1d23;--accent-ink:#c81119;--focus:#1d6fb3;
--wknd-bg:#e3e9eb;--wknd-ink:#7d939b;--hol-bg:#dff2e6;--hol-ink:#1f7a4d;
--off-bg:#dbeafe;--off-ink:#1d4ed8;--bt-bg:#cffafe;--bt-ink:#0e7490;
--late-bg:#fbe6c8;--late-ink:#9a5a10;--early-bg:#fbdad9;--early-ink:#b21f22}
*{box-sizing:border-box}html,body{margin:0;padding:0}
body{background:#f8f9fa;color:var(--ink-900);font-family:'Plex Var','IBM Plex Sans',system-ui,sans-serif;-webkit-font-smoothing:antialiased;font-size:14px}
.wrap{max-width:1360px;margin:0 auto;padding:1.5rem 1.25rem 3rem}
.head{display:flex;align-items:flex-end;justify-content:space-between;gap:1rem;flex-wrap:wrap;margin-bottom:1rem}
.head .eyebrow{font-weight:600;font-size:.7rem;letter-spacing:.13em;text-transform:uppercase;color:var(--accent-ink)}
.head h1{font-family:'Oswald Var','Oswald',sans-serif;font-weight:600;font-size:clamp(1.35rem,2.5vw,1.9rem);margin:.2rem 0 0;color:var(--ink-900)}
.head .src{font-size:.78rem;color:var(--ink-400);text-align:right;line-height:1.5}
.head .src code{font-size:.72rem;color:var(--ink-600)}
.toolbar{display:flex;align-items:center;gap:.7rem;flex-wrap:wrap;background:var(--surface-1);border:1px solid var(--border);border-radius:9px 9px 0 0;border-bottom:none;padding:.7rem .85rem}
.field{display:flex;align-items:center;gap:.4rem}.field label{font-size:.78rem;color:var(--ink-400);font-weight:500}
.control{font:inherit;font-size:.83rem;color:var(--ink-900);background:var(--surface-2);border:1px solid var(--border-strong);border-radius:6px;padding:.32rem .5rem;cursor:pointer}
.control:focus-visible{outline:2px solid var(--focus);outline-offset:1px}
.btn{display:inline-flex;align-items:center;gap:.35rem;font-size:.82rem;font-weight:600;background:var(--navy-900);color:#fff;border:none;border-radius:6px;padding:.4rem .75rem;text-decoration:none;white-space:nowrap}
.btn:hover{opacity:.9}
.warn-banner{background:var(--late-bg);border:1px solid var(--late-ink);border-left-width:4px;border-radius:6px;padding:.65rem .9rem;margin-bottom:.85rem;font-size:.83rem;color:var(--late-ink)}
.warn-banner code{background:rgba(0,0,0,.06);padding:.05rem .3rem;border-radius:4px}
.spacer{flex:1 1 auto}.legend{display:flex;gap:.4rem;flex-wrap:wrap}
.lv{display:inline-flex;align-items:center;font-size:.68rem;font-weight:600;padding:.12rem .45rem;border-radius:100px;white-space:nowrap}
.lv.leg{font-size:.72rem}
.grid-scroll{overflow:auto;max-height:76vh;border:1px solid var(--border);border-radius:0 0 9px 9px;background:var(--surface-1)}
table{border-collapse:separate;border-spacing:0;font-variant-numeric:tabular-nums}
thead th{position:sticky;top:0;z-index:20;background:var(--surface-head);color:var(--ink-600);font-weight:600;font-size:.68rem;border-bottom:1px solid var(--border);border-right:1px solid var(--border);padding:.3rem .4rem;text-align:center;white-space:nowrap}
thead tr:nth-child(2) th{top:40px;font-size:.63rem;padding:.2rem .4rem}
th.day .dnum{font-family:'Oswald Var','Oswald',sans-serif;font-size:.92rem;font-weight:600;color:var(--ink-900);display:block;line-height:1.1}
th.day .dow{font-size:.58rem;letter-spacing:.05em;text-transform:uppercase;color:var(--ink-400);display:block}
th.io{min-width:52px}th.io-out{border-right:1px solid var(--border)}
th.c-emp-h{left:0;z-index:40;text-align:left;min-width:190px;padding-left:.85rem}
th.c-late-h{left:190px;z-index:40;min-width:70px}
thead tr:first-child th.c-emp-h,thead tr:first-child th.c-late-h{z-index:41}
td{border-bottom:1px solid var(--border);border-right:1px solid var(--border);padding:.28rem .35rem;text-align:center;white-space:nowrap}
tbody tr.even td{background:var(--surface-1)}tbody tr.odd td{background:var(--surface-2)}
tbody tr:hover td{background:color-mix(in srgb,var(--focus) 9%,var(--surface-1))}
td.c-emp{position:sticky;left:0;z-index:10;text-align:left;min-width:190px;max-width:190px;padding:.35rem .85rem;border-right:1px solid var(--border-strong)}
td.c-emp .en{display:block;font-weight:600;font-size:.82rem;color:var(--ink-900);overflow:hidden;text-overflow:ellipsis}
td.c-emp .ec{display:block;font-size:.7rem;color:var(--ink-400)}
td.c-late{position:sticky;left:190px;z-index:10;min-width:70px;border-right:1px solid var(--border-strong)}
.late-val{font-weight:600;color:var(--ink-400);font-size:.82rem}.late-val.has{color:var(--late-ink)}
.pill{display:inline-block;font-size:.74rem;padding:.08rem .34rem;border-radius:5px;color:var(--ink-600);min-width:40px}
.pill.late,.pill.early{font-weight:600}
.pill.autofill{background:var(--hol-bg);color:var(--hol-ink);font-weight:600;font-size:.68rem}
.pill.edited::after{content:'';display:inline-block;width:5px;height:5px;border-radius:50%;background:var(--focus);margin-left:.3rem;vertical-align:middle}
.pill.editable{cursor:pointer}.pill.editable:hover{outline:1px dashed var(--focus);outline-offset:1px}
.pill-input{width:62px;font:inherit;font-size:.78rem;border:1px solid var(--focus);border-radius:4px;padding:0 .2rem;background:var(--surface-1);color:var(--ink-900)}
th.wknd,td.wknd{background:var(--wknd-bg)!important;color:var(--wknd-ink)}
th.hol,td.hol{background:var(--hol-bg)!important}th.hol .dnum{color:var(--hol-ink)}
td.hol .pill{color:var(--hol-ink);opacity:.6}td.wknd .pill{color:var(--wknd-ink);opacity:.55}
td.off{background:var(--off-bg)!important}td.off .pill{color:var(--off-ink);font-weight:600}
td.bt{background:var(--bt-bg)!important}td.bt .pill{color:var(--bt-ink);font-weight:600}
td.io .lv{margin-right:.2rem;vertical-align:middle}
.foot{margin-top:.9rem;font-size:.78rem;color:var(--ink-400);display:flex;justify-content:space-between;flex-wrap:wrap;gap:.5rem}
"""

_PAGE = """<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>%(title_plain)s · Xenoptics HR</title><style>%(css)s</style></head><body>
<div class="wrap">
<div class="head">
<div><span class="eyebrow">%(eyebrow)s</span>
<h1>%(title)s</h1></div>
<div class="src">Merged source: app/GPS punches + biometric machine<br>
<code>vw_attendance_combined</code> &rarr; <code>sp_get_attendance_month_V3</code></div>
</div>
%(banner)s
<form method="get" action="%(base_url)s">
<div class="toolbar">
%(extra_filters_pre)s<div class="field"><label for="month">Month</label>
<select class="control" id="month" name="month" onchange="this.form.submit()">%(month_opts)s</select></div>
<div class="field"><label for="year">Year</label>
<select class="control" id="year" name="year" onchange="this.form.submit()">%(year_opts)s</select></div>
%(extra_filters_post)s<a class="btn" href="%(base_url)s/export?%(export_qs)s">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="14" height="14"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Export Excel</a>
<div class="spacer"></div>
<div class="legend">%(legend)s</div>
</div>
</form>
<div class="grid-scroll"><table>
<thead>
<tr><th class="c-emp-h" rowspan="2">Employee</th><th class="c-late-h" rowspan="2">Late<br>(min)</th>%(head1)s</tr>
<tr>%(head2)s</tr>
</thead>
<tbody>%(body)s</tbody>
</table></div>
<div class="foot">
<span>%(nrows)s employees · %(ndays)s days · In = first scan, Out = last scan (MIN/MAX per day)</span>
<span>Orange = late in · Red = early out · Green = holiday · Grey = weekend · Blue dot = HR-edited · "Present" = auto-filled (no scan required)</span>
</div>
</div>%(edit_script)s</body></html>"""

_EDIT_SCRIPT = """<script>
(function(){
  function rpc(url, params){
    return fetch(url, {
      method: 'POST',
      headers: {'Content-Type': 'application/json'},
      credentials: 'same-origin',
      body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: params})
    }).then(function(r){ return r.json(); }).then(function(j){
      if (j.error) {
        throw new Error((j.error.data && j.error.data.message) || 'Save failed');
      }
      return j.result;
    });
  }
  document.querySelectorAll('.pill[data-ec]').forEach(function(pill){
    pill.classList.add('editable');
    pill.addEventListener('click', function(){
      if (pill.querySelector('input')) return;
      var current = pill.firstChild ? pill.firstChild.textContent.trim() : '';
      if (current === '\\u2013') current = '';
      var input = document.createElement('input');
      input.type = 'time';
      input.value = current;
      input.className = 'pill-input';
      pill.textContent = '';
      pill.appendChild(input);
      input.focus();
      var settled = false;
      function save(){
        if (settled) return;
        settled = true;
        rpc('%(base_url)s/edit', {
          employee_code: pill.dataset.ec,
          date: pill.dataset.date,
          field: pill.dataset.field,
          value: input.value || ''
        }).then(function(){
          window.location.reload();
        }).catch(function(e){
          alert(e.message);
          window.location.reload();
        });
      }
      input.addEventListener('blur', save);
      input.addEventListener('keydown', function(ev){
        if (ev.key === 'Enter') { input.blur(); }
        if (ev.key === 'Escape') { settled = true; window.location.reload(); }
      });
    });
  });
})();
</script>"""
