import logging

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

_logger = logging.getLogger(__name__)

# Business-rule constants (Section D of the spec) -- clock times as
# fractional hours, matching this codebase's existing request_hour_from/to
# convention (see xeno_leave's part-time-hours check).
LUNCH_START, LUNCH_END = 12.5, 13.5          # 12:30 pm - 1:30 pm
EVENING_START, EVENING_END = 18.0, 18.5      # 6:00 pm - 6:30 pm
SUBMIT_WINDOW_DAYS = 3

STATES = [
    ("draft", "Draft"),
    ("confirm", "Pending"),
    ("manager_approved", "Approved (Manager)"),
    ("hod_approved", "Approved (HOD)"),
    ("rejected", "Rejected"),
]


class HrOvertimeRequest(models.Model):
    """A single day's OT authorization. Once Approved (HOD), the request
    itself is finished (per susu: "Overtime request is finished") -- the
    employee's actual worked hours for that day are then entered and
    submitted as part of a WEEKLY hr.overtime.timesheet (grouping every
    HOD-approved request that falls in that calendar week), not per
    individual request. See hr_overtime_timesheet.py for that flow."""

    _name = "hr.overtime.request"
    _description = "Overtime Request"
    _inherit = ["mail.thread"]
    _order = "date desc, id desc"

    name = fields.Char(compute="_compute_name", store=True)
    employee_id = fields.Many2one(
        "hr.employee", required=True, tracking=True,
        default=lambda self: self.env.user.employee_id.id)
    date = fields.Date(required=True, tracking=True, default=fields.Date.context_today)
    time_from = fields.Float(string="OT Start Time", required=True, tracking=True)
    time_to = fields.Float(string="OT End Time", required=True, tracking=True)
    supervisor_id = fields.Many2one(
        "hr.employee", string="Supervisor (Manager)", required=True, tracking=True)
    head_of_department_id = fields.Many2one(
        "hr.employee", string="Head of Department", required=True, tracking=True)
    working_place = fields.Char(string="Working Place", required=True)
    description = fields.Text()
    attachment = fields.Binary(string="Attach Work File", attachment=True)
    attachment_filename = fields.Char()

    state = fields.Selection(STATES, default="draft", required=True, tracking=True, copy=False)
    rejection_reason = fields.Char(copy=False)

    manager_decided_by = fields.Many2one("res.users", readonly=True, copy=False)
    manager_decided_at = fields.Datetime(readonly=True, copy=False)
    hod_decided_by = fields.Many2one("res.users", readonly=True, copy=False)
    hod_decided_at = fields.Datetime(readonly=True, copy=False)

    # ---------------------------------------------------------- timesheet
    # Actual worked hours, entered by the employee once Approved (HOD) --
    # this is the "line" data that a weekly hr.overtime.timesheet pulls in
    # and displays per-day (Date/In/Out/Break/Hrs Work/OT Hrs), matching
    # the reference system's weekly timesheet screen exactly.
    time_in = fields.Float(tracking=True)
    time_out = fields.Float(tracking=True)
    break_hours = fields.Float(tracking=True, help="Break time to deduct, entered by the employee.")
    timesheet_id = fields.Many2one(
        "hr.overtime.timesheet", string="Weekly Timesheet", ondelete="set null",
        copy=False, readonly=True, tracking=True,
        help="Set once this day is pulled into a weekly timesheet submission.")
    timesheet_state = fields.Selection(related="timesheet_id.state", string="Timesheet Status")

    is_weekend_or_holiday = fields.Boolean(compute="_compute_is_weekend_or_holiday")
    hrs_worked = fields.Float(
        string="Hrs Work", compute="_compute_hours", store=True,
        help="Raw clocked span (Out - In) minus Break Hours, with no "
             "weekend/holiday-specific deduction -- the plain hours worked.")
    computed_ot_hours = fields.Float(
        string="OT Hrs", compute="_compute_hours", store=True, tracking=True,
        help="Hours-only OT charge -- no monetary rate is modeled in this system. "
             "On a weekend/public holiday, the standard lunch (12:30-1:30) and "
             "evening (6:00-6:30) breaks are automatically deducted from the "
             "clocked span on top of any Break Hours entered; on a weekday, only "
             "the entered Break Hours are deducted (so Hrs Work == OT Hrs there).")

    can_submit_request = fields.Boolean(compute="_compute_can_flags")
    can_manager_decide = fields.Boolean(compute="_compute_can_flags")
    can_hod_decide = fields.Boolean(compute="_compute_can_flags")
    can_edit_timesheet_line = fields.Boolean(compute="_compute_can_flags")

    @api.depends_context("uid")
    @api.depends("supervisor_id", "head_of_department_id", "employee_id",
                 "state", "timesheet_id.state")
    def _compute_can_flags(self):
        is_hr = self._xeno_is_hr()
        for rec in self:
            is_owner = self.env.user.employee_id == rec.employee_id
            rec.can_submit_request = (is_hr or is_owner) and rec.state == "draft"
            rec.can_manager_decide = is_hr or rec._xeno_is_manager()
            rec.can_hod_decide = is_hr or rec._xeno_is_hod()
            not_locked = not rec.timesheet_id or rec.timesheet_id.state in ("draft", "rejected")
            rec.can_edit_timesheet_line = (
                (is_hr or is_owner) and rec.state == "hod_approved" and not_locked)

    @api.depends("employee_id", "date")
    def _compute_name(self):
        for rec in self:
            rec.name = _("%(employee)s - OT %(date)s", employee=rec.employee_id.name or "", date=rec.date or "")

    @api.depends("date")
    def _compute_is_weekend_or_holiday(self):
        for rec in self:
            if not rec.date:
                rec.is_weekend_or_holiday = False
                continue
            is_weekend = rec.date.weekday() >= 5
            is_holiday = bool(self.env["resource.calendar.leaves"].sudo().search([
                ("calendar_id", "=", False),
                ("date_from", "<=", "%s 23:59:59" % rec.date),
                ("date_to", ">=", "%s 00:00:00" % rec.date),
            ], limit=1))
            rec.is_weekend_or_holiday = is_weekend or is_holiday

    @api.depends("time_in", "time_out", "break_hours", "is_weekend_or_holiday")
    def _compute_hours(self):
        for rec in self:
            if not rec.time_out or rec.time_out <= rec.time_in:
                rec.hrs_worked = 0.0
                rec.computed_ot_hours = 0.0
                continue
            span = rec.time_out - rec.time_in
            rec.hrs_worked = max(0.0, span - (rec.break_hours or 0.0))
            if rec.is_weekend_or_holiday:
                deducted = (
                    rec._xeno_overlap(rec.time_in, rec.time_out, LUNCH_START, LUNCH_END)
                    + rec._xeno_overlap(rec.time_in, rec.time_out, EVENING_START, EVENING_END)
                )
            else:
                deducted = rec.break_hours or 0.0
            rec.computed_ot_hours = max(0.0, span - deducted)

    @staticmethod
    def _xeno_overlap(a_start, a_end, b_start, b_end):
        return max(0.0, min(a_end, b_end) - max(a_start, b_start))

    def _xeno_approvers_for_employee(self, employee):
        """Suggests the two approvers so the employee doesn't have to look
        them up by hand -- still freely overridable before submitting:
        - Supervisor (Manager): the employee's own reporting manager
          (hr.employee.parent_id), Odoo's standard "Manager" field.
        - Head of Department: whoever currently holds the "General
          Manager" job title company-wide (a fixed role, not derived per
          department) -- matches the real org (confirmed live: Sira
          Poneprasert, job_title "General Manager", is also the
          department manager on multiple departments in this company).
          A free-text match on job_title, so it silently finds nothing
          (leaves the field blank rather than erroring) if that title
          ever changes wording -- HR can always fill it in by hand either
          way.

        Shared by the onchange (fires when employee_id is visibly changed
        on the HR-facing form) and the onchange() override below (the
        employee self-service form's initial "New" load, where
        employee_id is invisible).
        """
        vals = {}
        if employee:
            vals["supervisor_id"] = employee.parent_id.id
            gm = self.env["hr.employee"].search(
                [("job_title", "=ilike", "General Manager")], limit=1)
            if gm:
                vals["head_of_department_id"] = gm.id
        return vals

    @api.onchange("employee_id")
    def _onchange_employee_id_xeno_fill_approvers(self):
        for field, value in self._xeno_approvers_for_employee(self.employee_id).items():
            setattr(self, field, value)

    def onchange(self, values, field_names, field_onchange):
        # The web client initializes a brand-new record with a single
        # onchange(values={}, field_names=[], ...) call. Odoo's own
        # onchange() (web/models/models.py) treats an EMPTY field_names as
        # "first_call" and, only in that branch, resolves every field's
        # default_get() value (state="draft", date=today, etc.) into the
        # result -- but it never dispatches any @api.onchange method during
        # that same branch, so _onchange_employee_id_xeno_fill_approvers()
        # (registered on employee_id) doesn't fire even once employee_id's
        # own default has been resolved, leaving Supervisor/HOD blank on a
        # brand-new form.
        #
        # A previous version of this override "fixed" that by forcing
        # field_names=["employee_id"] on this exact call before delegating
        # to super() -- but that makes first_call False, which skips the
        # default_get() resolution entirely: state, date and every other
        # plain `default=` field silently came back blank instead (confirmed
        # live: the statusbar showed no active stage and Save failed with
        # "Missing required fields" the moment a user picked a date, since
        # state never had a chance to become "draft" in the first place).
        #
        # Correct fix: let the very first call through to super() completely
        # unmodified, so first_call's default_get() resolution runs exactly
        # as Odoo intends. THEN, only if that was the first call and it
        # resolved a default employee_id, issue one genuine follow-up
        # onchange(field_names=["employee_id"]) call -- this goes through
        # Odoo's normal dispatch (so _onchange_employee_id_xeno_fill_
        # approvers actually runs) and its normal result-formatting (so
        # supervisor_id/head_of_department_id, and anything else that
        # depends on employee_id such as can_submit_request, come back in
        # whatever shape the client asked for) -- no hand-rolled formatting
        # needed. Merged on top of the first call's own result.
        first_call = not field_names
        result = super().onchange(values, field_names, field_onchange)

        if first_call:
            employee_value = result.get("value", {}).get("employee_id")
            employee_id = (
                employee_value.get("id") if isinstance(employee_value, dict)
                else employee_value or values.get("employee_id")
            )
            if employee_id and "supervisor_id" in field_onchange:
                follow_up_values = dict(values, **{
                    k: (v["id"] if isinstance(v, dict) else v)
                    for k, v in result.get("value", {}).items()
                    if k in self._fields and self._fields[k].type != "one2many"
                })
                follow_up_values["employee_id"] = employee_id
                try:
                    follow_up = super().onchange(follow_up_values, ["employee_id"], field_onchange)
                    result.setdefault("value", {}).update(follow_up.get("value", {}))
                except Exception:
                    # Best-effort only -- worst case Supervisor/HOD start
                    # blank until the employee picks them by hand, same as
                    # before this fix existed. Never worth taking down the
                    # whole "New" form over, but logged since a persistent
                    # failure here would be worth knowing about.
                    _logger.warning(
                        "xeno_overtime: could not resolve default approvers "
                        "on new-record onchange", exc_info=True)

        return result

    # ------------------------------------------------------------ create

    @api.model_create_multi
    def create(self, vals_list):
        # Requests start life as a Draft the employee can freely edit --
        # the +/-3-day submit window is about WHEN you're allowed to
        # actually submit, so it's checked in action_submit() instead,
        # not here at draft-creation time. The OT time-slot rules (end
        # after start, no weekday-hours overlap, no lunch/evening window)
        # are about whether the chosen time is valid at all, so those
        # still apply immediately.
        is_hr = self.env.su or self._xeno_is_hr()
        own_employee_id = self.env.user.employee_id.id
        for vals in vals_list:
            if not is_hr:
                target_employee_id = vals.get("employee_id", own_employee_id)
                if target_employee_id != own_employee_id:
                    raise UserError(_(
                        "You can only create an Overtime Request for yourself. "
                        "Ask HR/Payroll to submit one on your behalf if needed."))
            self._xeno_check_ot_time_rules(vals)
        return super().create(vals_list)

    _XENO_REQUEST_FIELDS = {
        "date", "time_from", "time_to", "supervisor_id", "head_of_department_id",
        "working_place", "description", "attachment", "attachment_filename",
    }
    _XENO_TIMESHEET_FIELDS = {"time_in", "time_out", "break_hours"}
    _XENO_TRANSITION_FIELDS = {
        "state", "manager_decided_by", "manager_decided_at", "hod_decided_by",
        "hod_decided_at", "rejection_reason", "timesheet_id",
    }

    def write(self, vals):
        if {"time_from", "time_to", "date"} & set(vals) and not self.env.su:
            for rec in self:
                merged = {
                    "date": rec.date, "time_from": rec.time_from, "time_to": rec.time_to,
                    "employee_id": rec.employee_id.id,
                }
                merged.update(vals)
                self._xeno_check_ot_time_rules(merged)

        if not self.env.su and not self._xeno_is_hr():
            touched = set(vals)
            # Only the vetted action_* methods (which sudo() internally
            # after their own checks) may move the state machine forward --
            # a direct write to these fields from anywhere else is rejected.
            if touched & self._XENO_TRANSITION_FIELDS:
                raise UserError(_(
                    "Use Submit/Approve/Reject actions to change an OT request's "
                    "status -- it can't be edited directly."))
            # A request can never be reassigned to a different employee by
            # anyone but HR/Payroll -- checked unconditionally (not per-
            # state) since the usual "only the owner can edit their own
            # draft" check below would otherwise still pass here (the
            # OWNER at write-time still matches env.user, right up until
            # this write silently hands the draft to someone else).
            if "employee_id" in touched:
                raise UserError(_(
                    "The employee on an Overtime Request can't be changed. "
                    "Ask HR/Payroll if this needs to be corrected."))
            for rec in self:
                if touched & self._XENO_REQUEST_FIELDS:
                    if rec.state != "draft":
                        raise UserError(_(
                            "The original OT request can no longer be edited once it's "
                            "been submitted. Ask HR/Payroll to update it."))
                    # State alone isn't enough: the configured Supervisor/HOD
                    # can see and act on this record too, but only the
                    # requesting employee may edit its own content (found in
                    # review -- the approver could otherwise silently alter
                    # what they're about to approve).
                    if rec.env.user.employee_id != rec.employee_id:
                        raise UserError(_(
                            "Only the requesting employee can edit this OT request."))
                if touched & self._XENO_TIMESHEET_FIELDS and not rec.can_edit_timesheet_line:
                    raise UserError(_(
                        "Timesheet times can only be entered by the requesting "
                        "employee, once the request is Approved (HOD) and before "
                        "its weekly timesheet has been submitted/approved."))
        return super().write(vals)

    def _xeno_check_submit_window(self, vals):
        # Bypassed for HR/Payroll creating/adjusting on an employee's behalf,
        # same on-behalf convention used throughout xeno_leave.
        if self.env.su or self.env.user.has_group("hr_holidays.group_hr_holidays_user"):
            return
        ot_date = vals.get("date")
        if not ot_date:
            return
        ot_date = fields.Date.to_date(ot_date)
        today = fields.Date.context_today(self)
        if abs((ot_date - today).days) > SUBMIT_WINDOW_DAYS:
            raise ValidationError(_(
                "OT requests can only be submitted between %(window)s days before "
                "and %(window)s days after the OT date.", window=SUBMIT_WINDOW_DAYS))

    def _xeno_check_ot_time_rules(self, vals):
        ot_date = fields.Date.to_date(vals["date"]) if vals.get("date") else None
        time_from = vals.get("time_from")
        time_to = vals.get("time_to")
        if not ot_date or time_from is None or time_to is None:
            return
        if time_to <= time_from:
            raise ValidationError(_("OT end time must be after start time."))

        is_weekend = ot_date.weekday() >= 5
        is_holiday = bool(self.env["resource.calendar.leaves"].sudo().search([
            ("calendar_id", "=", False),
            ("date_from", "<=", "%s 23:59:59" % ot_date),
            ("date_to", ">=", "%s 00:00:00" % ot_date),
        ], limit=1))

        # Rule 2: no OT during regular weekday working hours.
        if not is_weekend and not is_holiday:
            employee = self.env["hr.employee"].browse(vals.get("employee_id")) if vals.get("employee_id") else self.env.user.employee_id
            calendar = employee.resource_calendar_id if employee else False
            if calendar:
                dow = str(ot_date.weekday())
                attendances = calendar.attendance_ids.filtered(lambda a: a.dayofweek == dow)
                if attendances:
                    day_start = min(attendances.mapped("hour_from"))
                    day_end = max(attendances.mapped("hour_to"))
                    if time_from < day_end and time_to > day_start:
                        raise ValidationError(_(
                            "Employees cannot request overtime that overlaps regular "
                            "working hours (%(start)s-%(end)s) on a weekday.",
                            start=self._xeno_format_hour(day_start),
                            end=self._xeno_format_hour(day_end)))

        # Rule 3: lunch break (12:30-1:30) can't be chosen as a start/end
        # point on a weekend/public holiday.
        if is_weekend or is_holiday:
            if LUNCH_START <= time_from < LUNCH_END or LUNCH_START < time_to <= LUNCH_END:
                raise ValidationError(_(
                    "Employees cannot choose the lunch break time (12:30 pm - 1:30 pm) "
                    "as OT start/end time on weekends or public holidays."))

        # Rule 4: 6:00-6:30 pm (post-office hours) can't be chosen either,
        # any day.
        if EVENING_START <= time_from < EVENING_END or EVENING_START < time_to <= EVENING_END:
            raise ValidationError(_(
                "Employees cannot choose the 6:00 pm - 6:30 pm time slot as OT "
                "start/end time."))

    def unlink(self):
        if not self.env.su and not self._xeno_is_hr():
            for rec in self:
                if rec.state != "draft" or rec.env.user.employee_id != rec.employee_id:
                    raise UserError(_(
                        "Only your own still-Draft OT request can be deleted; "
                        "once submitted, ask HR/Payroll."))
        return super().unlink()

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

    # -------------------------------------------------------- can_* flags

    def _xeno_is_manager(self):
        self.ensure_one()
        return self.env.user.employee_id == self.supervisor_id

    def _xeno_is_hod(self):
        self.ensure_one()
        return self.env.user.employee_id == self.head_of_department_id

    def _xeno_is_hr(self):
        return self.env.is_superuser() or self.env.user.has_group("hr_holidays.group_hr_holidays_user")

    # ------------------------------------------------------------ actions

    def action_submit(self):
        """Moves a Draft into Pending and fires the Manager's approval
        email -- the employee can freely edit a Draft beforehand (see
        write()'s _XENO_REQUEST_FIELDS guard), but once submitted it's
        locked to everyone but HR/Payroll. The +/-3-day submit window is
        checked HERE (submission time), not at draft-creation time, since
        that's genuinely when "am I allowed to submit this now" matters."""
        for rec in self:
            if rec.state != "draft":
                raise UserError(_("Only a Draft request can be submitted."))
            if not rec.can_submit_request:
                raise UserError(_("Only the requesting employee can submit this request."))
            rec._xeno_check_submit_window({"date": rec.date})
            rec.sudo().write({"state": "confirm"})
            rec.message_post(body=_("Submitted for Manager approval."))
            rec._xeno_send_approver_email("manager")

    def action_manager_approve(self):
        for rec in self:
            if rec.state != "confirm":
                raise UserError(_("Only a Pending request can be approved by the Manager."))
            if not (rec._xeno_is_manager() or rec._xeno_is_hr()):
                raise UserError(_("Only the configured Supervisor can approve this step."))
            rec.sudo().write({
                "state": "manager_approved",
                "manager_decided_by": self.env.user.id,
                "manager_decided_at": fields.Datetime.now(),
            })
            rec.message_post(body=_("Approved by Manager: %s", self.env.user.name))
            rec._xeno_send_approver_email("hod")

    def action_manager_reject(self, reason=False):
        for rec in self:
            if rec.state != "confirm":
                raise UserError(_("Only a Pending request can be rejected by the Manager."))
            if not (rec._xeno_is_manager() or rec._xeno_is_hr()):
                raise UserError(_("Only the configured Supervisor can reject this step."))
            rec.sudo().write({"state": "rejected", "rejection_reason": reason or False})
            rec.message_post(body=_("Rejected by Manager: %s", self.env.user.name))
            rec._xeno_notify_employee(_("Your OT request for %s was rejected by your Manager.", rec.date))

    def action_hod_approve(self):
        for rec in self:
            if rec.state != "manager_approved":
                raise UserError(_("Only a Manager-approved request can be approved by the Head of Department."))
            if not (rec._xeno_is_hod() or rec._xeno_is_hr()):
                raise UserError(_("Only the configured Head of Department can approve this step."))
            rec.sudo().write({
                "state": "hod_approved",
                "hod_decided_by": self.env.user.id,
                "hod_decided_at": fields.Datetime.now(),
            })
            rec.message_post(body=_("Approved by Head of Department: %s", self.env.user.name))
            rec._xeno_notify_employee(_(
                "Your OT request for %s is fully approved. It will appear on your "
                "weekly Overtime Timesheet for that week.", rec.date))

    def action_hod_reject(self, reason=False):
        for rec in self:
            if rec.state != "manager_approved":
                raise UserError(_("Only a Manager-approved request can be rejected by the Head of Department."))
            if not (rec._xeno_is_hod() or rec._xeno_is_hr()):
                raise UserError(_("Only the configured Head of Department can reject this step."))
            rec.sudo().write({"state": "rejected", "rejection_reason": reason or False})
            rec.message_post(body=_("Rejected by Head of Department: %s", self.env.user.name))
            rec._xeno_notify_employee(_("Your OT request for %s was rejected by the Head of Department.", rec.date))

    # _xeno_notify_employee itself now lives in overtime_email.py (same
    # branded HTML shell as the approver's action email, read-only) --
    # loaded after this file, so it replaces this method entirely rather
    # than extending it.
