# -*- coding: utf-8 -*-
import logging
import threading
import time
import traceback

from odoo import SUPERUSER_ID, api, fields, models
# CompletionStatus is core's per-run outcome enum (values: 'fully done',
# 'partially done', 'failed'); cf. odoo/addons/base/models/ir_cron.py:61
from odoo.addons.base.models.ir_cron import CompletionStatus

_logger = logging.getLogger(__name__)

# Per-thread scratch shared between the _callback and _run_job overrides.
# threading.local isolates concurrent cron worker threads automatically, so a
# traceback captured in one job can never leak into another job's log row.
_dch = threading.local()


class IrCron(models.Model):
    _inherit = 'ir.cron'

    # ------------------------------------------------------------------
    # Health fields (all computed from the log table; NEVER stored on the
    # ir.cron row, which the scheduler holds locked FOR NO KEY UPDATE for the
    # whole run -- writing it from our side-cursor would deadlock).
    # ------------------------------------------------------------------
    do_health_log_ids = fields.One2many(
        'do.cron.health.log', 'cron_id', string='Health Logs')
    do_health_log_count = fields.Integer(
        string='Recorded Runs', compute='_compute_do_health')
    do_last_run = fields.Datetime(string='Last Run', compute='_compute_do_health')
    do_last_state = fields.Selection(
        selection=[
            ('running', 'Running'), ('success', 'Success'),
            ('partial', 'Partially Done'), ('failed', 'Failed'), ('stale', 'Stale'),
        ],
        string='Last Outcome', compute='_compute_do_health')
    do_last_duration = fields.Float(
        string='Last Duration (s)', compute='_compute_do_health')
    do_avg_duration = fields.Float(
        string='Avg Duration (s)', compute='_compute_do_health')
    do_recent_failure_count = fields.Integer(
        string='Failed/Stale Runs', compute='_compute_do_health')
    do_health_status = fields.Selection(
        selection=[
            ('good', 'Good'), ('warning', 'Warning'),
            ('critical', 'Critical'), ('unknown', 'Unknown'),
        ],
        string='Health', compute='_compute_do_health',
        help="Roll-up health from the latest run and the recent failure count.")
    do_is_overrunning = fields.Boolean(
        string='Overrunning', compute='_compute_do_health',
        help="The latest run is still 'running' well past the average duration.")

    @api.depends('do_health_log_ids.state', 'do_health_log_ids.start_datetime',
                 'do_health_log_ids.duration')
    def _compute_do_health(self):
        # Defaults first (covers new/never-run crons and the empty recordset).
        for cron in self:
            cron.do_health_log_count = 0
            cron.do_last_run = False
            cron.do_last_state = False
            cron.do_last_duration = 0.0
            cron.do_avg_duration = 0.0
            cron.do_recent_failure_count = 0
            cron.do_health_status = 'unknown'
            cron.do_is_overrunning = False
        ids = [c.id for c in self if c.id]
        if not ids:
            return

        # Two batched queries for the whole recordset (no per-record search).
        self.env.cr.execute("""
            SELECT DISTINCT ON (cron_id) cron_id, start_datetime, state, duration
            FROM do_cron_health_log
            WHERE cron_id IN %s
            ORDER BY cron_id, start_datetime DESC NULLS LAST, id DESC
        """, [tuple(ids)])
        last = {row[0]: row for row in self.env.cr.fetchall()}

        self.env.cr.execute("""
            SELECT cron_id,
                   COUNT(*) AS cnt,
                   AVG(duration) FILTER (
                       WHERE duration IS NOT NULL AND state IN ('success', 'partial')
                   ) AS avg_dur,
                   COUNT(*) FILTER (WHERE state IN ('failed', 'stale')) AS fail_cnt
            FROM do_cron_health_log
            WHERE cron_id IN %s
            GROUP BY cron_id
        """, [tuple(ids)])
        agg = {row[0]: row for row in self.env.cr.fetchall()}

        icp = self.env['ir.config_parameter'].sudo()
        crit = int(icp.get_param('do_cron_health.critical_failures', '3') or 3)
        overrun_mult = float(icp.get_param('do_cron_health.overrun_multiplier', '3.0') or 3.0)
        now = fields.Datetime.now()

        for cron in self:
            lr = last.get(cron.id)
            ag = agg.get(cron.id)
            if lr:
                cron.do_last_run = lr[1]
                cron.do_last_state = lr[2]
                cron.do_last_duration = lr[3] or 0.0
            if ag:
                cron.do_health_log_count = ag[1] or 0
                cron.do_avg_duration = ag[2] or 0.0
                cron.do_recent_failure_count = ag[3] or 0
            overrunning = False
            if lr and lr[2] == 'running' and lr[1]:
                threshold = max((cron.do_avg_duration or 0.0) * overrun_mult, 60.0)
                overrunning = (now - lr[1]).total_seconds() > threshold
            cron.do_is_overrunning = overrunning
            cron.do_health_status = cron._dch_rollup(crit, overrunning)

    def _dch_rollup(self, crit, overrunning):
        self.ensure_one()
        if overrunning:
            return 'critical'
        state = self.do_last_state
        if not state:
            return 'unknown'
        fails = self.do_recent_failure_count
        if state in ('failed', 'stale'):
            return 'critical' if fails >= crit else 'warning'
        return 'warning' if fails >= crit else 'good'

    def action_view_health_logs(self):
        """Smart-button target: open this cron's run logs, domain-scoped."""
        self.ensure_one()
        return {
            'type': 'ir.actions.act_window',
            'name': "Runs: %s" % (self.cron_name or self.id),
            'res_model': 'do.cron.health.log',
            'view_mode': 'list,graph,pivot,form',
            'domain': [('cron_id', '=', self.id)],
            'context': {'create': False},
        }

    # ------------------------------------------------------------------
    # Execution wrapper
    # ------------------------------------------------------------------
    def method_direct_trigger(self):
        # Tag the upcoming run as manual. Safe because the whole manual path
        # (method_direct_trigger -> _process_job -> _run_job) runs synchronously
        # in this thread.
        _dch.manual = True
        try:
            return super().method_direct_trigger()
        finally:
            _dch.manual = False

    def _callback(self, cron_name, server_action_id):
        # Capture the exact traceback at the point it is raised, then re-raise so
        # core's handling is completely untouched.
        try:
            return super()._callback(cron_name, server_action_id)
        except Exception:
            if getattr(_dch, 'active', False):
                _dch.error = traceback.format_exc()
            raise

    @classmethod
    def _run_job(cls, job):
        _dch.error = None
        _dch.active = True
        _dch.skip = False
        log_id = cls._dch_log_start(job)
        start = time.monotonic()
        status = None
        try:
            status = super()._run_job(job)
            return status
        finally:
            if not getattr(_dch, 'skip', False):
                cls._dch_log_finish(
                    log_id, job, status, getattr(_dch, 'error', None),
                    time.monotonic() - start)
            _dch.active = False
            _dch.error = None

    # ------------------------------------------------------------------
    # Separate-cursor log writers (independent commit -> survive rollback/crash)
    # ------------------------------------------------------------------
    @classmethod
    def _dch_log_start(cls, job):
        """Write a 'running' row and commit it immediately, so a worker that
        dies mid-run still leaves a visible row (the watchdog later marks it
        stale). Returns the row id, or None if monitoring is off or the write
        failed (in which case ``_dch.skip`` suppresses the finish write too)."""
        try:
            with cls.pool.cursor() as log_cr:
                env = api.Environment(log_cr, SUPERUSER_ID, {})
                enabled = env['ir.config_parameter'].get_param(
                    'do_cron_health.enabled', 'True')
                if str(enabled).strip().lower() in ('false', '0', '', 'none'):
                    _dch.skip = True
                    return None
                log = env['do.cron.health.log'].create({
                    'cron_id': job['id'],
                    'name': job['cron_name'],
                    'start_datetime': fields.Datetime.now(),
                    'state': 'running',
                    'trigger': 'manual' if getattr(_dch, 'manual', False) else 'scheduled',
                    'db_name': log_cr.dbname,
                })
                log_id = log.id
                log_cr.commit()
                return log_id
        except Exception:
            _logger.exception("do_cron_health: could not write start row for cron %s",
                              job.get('id'))
            _dch.skip = True
            return None

    @staticmethod
    def _dch_state_for_status(status, error_text):
        """Map core's CompletionStatus to our log state (+ the stored error)."""
        if status == CompletionStatus.FULLY_DONE:
            return 'success', False
        if status == CompletionStatus.PARTIALLY_DONE:
            return 'partial', (error_text or False)
        if status == CompletionStatus.FAILED:
            return 'failed', (error_text or False)
        # super() raised before returning a status (unexpected).
        return 'failed', (error_text or 'Cron run failed without a completion status.')

    @classmethod
    def _dch_log_finish(cls, log_id, job, status, error_text, duration):
        try:
            state, error = cls._dch_state_for_status(status, error_text)
            vals = {
                'end_datetime': fields.Datetime.now(),
                'duration': duration,
                'state': state,
                'error_message': error,
            }
            with cls.pool.cursor() as log_cr:
                env = api.Environment(log_cr, SUPERUSER_ID, {})
                if log_id:
                    env['do.cron.health.log'].browse(log_id).write(vals)
                else:
                    # Start row never made it; record a complete row now.
                    vals.update({
                        'cron_id': job['id'],
                        'name': job['cron_name'],
                        'start_datetime': fields.Datetime.now(),
                        'trigger': 'manual' if getattr(_dch, 'manual', False) else 'scheduled',
                        'db_name': log_cr.dbname,
                    })
                    env['do.cron.health.log'].create(vals)
                log_cr.commit()
        except Exception:
            _logger.exception("do_cron_health: could not finalize log for cron %s",
                              job.get('id'))
