"""Planification opérationnelle des relevés de compteurs (Checkpoint C2)."""

from calendar import monthrange
from datetime import date, datetime, time, timedelta

from sqlalchemy import and_, or_, select
from sqlalchemy.orm import joinedload

from ...extensions import db
from ..models.planning import (
    MeterReadingOccurrence, MeterReadingRound, MeterReadingRoundMember,
    MeterReadingRoundOccurrence, MeterReadingSchedule,
)
from .meter_service import MeterDomainError, record_meter_reading


FREQUENCIES = {"WEEKLY", "MONTHLY", "SEMI_ANNUAL", "ANNUAL", "FIXED_ANNUAL_DATE"}
NO_READING_REASONS = {
    "INACCESSIBLE": "Compteur inaccessible",
    "OCCUPANT_ABSENT": "Occupant absent",
    "OCCUPANT_REFUSED": "Occupant refuse de communiquer le relevé",
    "READING_UNKNOWN": "Relevé non connu",
    "OTHER": "Autre",
}
TODO = "À FAIRE"
TREATED_READING = "TRAITÉE AVEC RELEVÉ"
TREATED_WITHOUT_READING = "TRAITÉE SANS RELEVÉ"
CANCELLED = "ANNULÉE"


def cancel_open_occurrences_for_meter(meter, *, commit=False):
    """Clôture explicitement les échéances ouvertes d'un compteur remplacé."""
    schedules = MeterReadingSchedule.query.filter_by(meter_id=meter.id).all()
    for schedule in schedules:
        schedule.is_active = False
        for occurrence in MeterReadingOccurrence.query.filter_by(schedule_id=schedule.id, status=TODO).all():
            occurrence.status = CANCELLED
            occurrence.no_reading_reason = "METER_REPLACED"
            occurrence.comment = "Occurrence annulée automatiquement : compteur remplacé ou inactif."
    if commit:
        db.session.commit()


def _add_months(value, months):
    index = value.year * 12 + value.month - 1 + months
    year, month = divmod(index, 12)
    month += 1
    return date(year, month, min(value.day, monthrange(year, month)[1]))


def _target_dates(schedule, start_date, end_date):
    reference = schedule.reference_date
    if schedule.frequency == "FIXED_ANNUAL_DATE":
        month = schedule.fixed_month or reference.month
        day = schedule.fixed_day or reference.day
        for year in range(start_date.year - 1, end_date.year + 2):
            candidate = date(year, month, min(day, monthrange(year, month)[1]))
            if start_date <= candidate <= end_date:
                yield candidate
        return

    step = {"WEEKLY": 7, "MONTHLY": 1, "SEMI_ANNUAL": 6, "ANNUAL": 12}[schedule.frequency]
    cursor = reference
    if schedule.frequency == "WEEKLY":
        if cursor < start_date:
            cursor += timedelta(days=((start_date - cursor).days // 7) * 7)
    else:
        while cursor < start_date:
            cursor = _add_months(cursor, step)
    while cursor <= end_date:
        if cursor >= start_date:
            yield cursor
        cursor = cursor + timedelta(days=step) if schedule.frequency == "WEEKLY" else _add_months(cursor, step)


def operational_date_for(schedule, target_date, *, calendar_cache=None):
    """Conserve la date cible et anticipe uniquement pour un calendrier scolaire."""
    if schedule.calendar_scope != "school" or schedule.closed_day_policy != "PREVIOUS_WORKING_DAY":
        return target_date
    from .planning_service import PlanningService
    calendar_cache = calendar_cache if calendar_cache is not None else {}
    # Sans horaires explicites pour le responsable, le moteur ne peut pas
    # déduire qu'un jour est travaillé. On conserve donc la date cible ; une
    # configuration d'horaires ultérieure permettra l'anticipation précise.
    from ..models.planning import WorkSchedule
    user_key = schedule.assigned_to_id
    schedule_key = ("explicit_schedule", user_key)
    if schedule_key not in calendar_cache:
        schedules = WorkSchedule.query.filter_by(is_active=True)
        if user_key is not None:
            schedules = schedules.filter_by(user_id=user_key)
        calendar_cache[schedule_key] = schedules.first() is not None
    if not calendar_cache[schedule_key]:
        return target_date

    def working_hours(day):
        key = ("working_hours", user_key, day)
        if key not in calendar_cache:
            calendar_cache[key] = PlanningService.get_working_hours(day, user_id=user_key)
        return calendar_cache[key]

    if working_hours(target_date):
        return target_date
    candidate = target_date - timedelta(days=1)
    for _ in range(366):
        if working_hours(candidate):
            return candidate
        candidate -= timedelta(days=1)
    raise MeterDomainError("Aucun jour opérationnel trouvé avant l'échéance.")


def create_or_update_schedule(*, meter, frequency, reference_date, target_time=None,
                              duration_minutes=5, assigned_to_id=None,
                              closed_day_policy="PREVIOUS_WORKING_DAY",
                              calendar_scope=None, fixed_month=None, fixed_day=None,
                              is_active=True, commit=True):
    if frequency not in FREQUENCIES:
        raise MeterDomainError("Fréquence de relevé inconnue.")
    if not reference_date:
        raise MeterDomainError("Une date de référence est obligatoire.")
    if int(duration_minutes or 0) <= 0:
        raise MeterDomainError("La durée estimée doit être positive.")
    if meter.status == "replaced" or not meter.is_active:
        raise MeterDomainError("Un compteur remplacé ou inactif ne peut plus recevoir de règle de relevé.")
    if calendar_scope is None:
        calendar_scope = "housing" if meter.housing_unit_id else "school"
    schedule = meter.reading_schedule
    if schedule is None:
        schedule = MeterReadingSchedule(meter=meter)
        db.session.add(schedule)
    schedule.frequency = frequency
    schedule.reference_date = reference_date
    schedule.target_time = target_time
    schedule.duration_minutes = int(duration_minutes)
    schedule.assigned_to_id = assigned_to_id
    schedule.closed_day_policy = closed_day_policy
    schedule.calendar_scope = calendar_scope
    schedule.fixed_month = fixed_month
    schedule.fixed_day = fixed_day
    schedule.is_active = bool(is_active)
    if commit:
        db.session.commit()
    return schedule


def _round_members_for_schedules(schedule_ids):
    if not schedule_ids:
        return {}
    members = MeterReadingRoundMember.query.filter(
        MeterReadingRoundMember.schedule_id.in_(schedule_ids),
    ).options(joinedload(MeterReadingRoundMember.round)).all()
    return {member.schedule_id: member for member in members if member.round.is_active}


def _round_occurrence_for(schedule, operational_date, member=None):
    member = member or MeterReadingRoundMember.query.filter_by(schedule_id=schedule.id).first()
    if not member or not member.round.is_active:
        return None
    occurrence = MeterReadingRoundOccurrence.query.filter_by(
        round_id=member.round_id, operational_date=operational_date,
    ).first()
    if occurrence is None:
        occurrence = MeterReadingRoundOccurrence(round_id=member.round_id, operational_date=operational_date)
        db.session.add(occurrence)
        db.session.flush()
    return occurrence


def _refresh_round_status(round_occurrence):
    if not round_occurrence:
        return
    statuses = [item.status for item in round_occurrence.occurrences]
    if statuses and all(status != TODO for status in statuses):
        round_occurrence.status = "TERMINÉE"
    elif any(status != TODO for status in statuses):
        round_occurrence.status = "EN COURS"
    else:
        round_occurrence.status = TODO


def generate_occurrences(*, start_date, end_date, schedule_ids=None, operational_date=None, commit=True):
    """Génère un horizon borné, avec unicité logique par règle/date."""
    query = MeterReadingSchedule.query.options(joinedload(MeterReadingSchedule.meter)).filter_by(is_active=True)
    if schedule_ids is not None:
        query = query.filter(MeterReadingSchedule.id.in_(schedule_ids))
    schedules = query.all()
    target_dates_by_schedule = {
        schedule.id: list(_target_dates(schedule, start_date, end_date))
        for schedule in schedules
    }
    existing = MeterReadingOccurrence.query.filter(
        MeterReadingOccurrence.schedule_id.in_([schedule.id for schedule in schedules]),
        MeterReadingOccurrence.target_date >= start_date,
        MeterReadingOccurrence.target_date <= end_date,
    ).all() if schedules else []
    existing_by_key = {(item.schedule_id, item.target_date): item for item in existing}
    members_by_schedule = _round_members_for_schedules([schedule.id for schedule in schedules])
    calendar_cache = {}
    created = []
    for schedule in schedules:
        if schedule.meter.status == "replaced" or not schedule.meter.is_active:
            cancel_open_occurrences_for_meter(schedule.meter)
            continue
        for target_date in target_dates_by_schedule[schedule.id]:
            occurrence = existing_by_key.get((schedule.id, target_date))
            if occurrence is not None:
                member = members_by_schedule.get(schedule.id)
                if occurrence.status == TODO and occurrence.assigned_to_id is None and not schedule.assigned_to_id and member:
                    occurrence.assigned_to_id = member.round.default_assigned_to_id
                continue
            computed_operational_date = operational_date_for(
                schedule, target_date, calendar_cache=calendar_cache,
            )
            if operational_date is not None and computed_operational_date != operational_date:
                continue
            occurrence = MeterReadingOccurrence(
                schedule=schedule, target_date=target_date, operational_date=computed_operational_date,
                assigned_to_id=schedule.assigned_to_id or (
                    members_by_schedule[schedule.id].round.default_assigned_to_id
                    if schedule.id in members_by_schedule else None
                ),
            )
            db.session.add(occurrence)
            db.session.flush()
            occurrence.round_occurrence = _round_occurrence_for(
                schedule, computed_operational_date, members_by_schedule.get(schedule.id),
            )
            created.append(occurrence)
    if commit:
        db.session.commit()
    return created


def ensure_occurrences_for_day(day, user_id=None, lookback_days=365, horizon_days=31):
    schedules = MeterReadingSchedule.query.filter_by(is_active=True).outerjoin(
        MeterReadingRoundMember, MeterReadingRoundMember.schedule_id == MeterReadingSchedule.id,
).outerjoin(
        MeterReadingRound, MeterReadingRound.id == MeterReadingRoundMember.round_id,
)
    if user_id is not None:
        schedules = schedules.filter(or_(
            MeterReadingSchedule.assigned_to_id == user_id,
            and_(
                MeterReadingSchedule.assigned_to_id.is_(None),
                MeterReadingRound.default_assigned_to_id == user_id,
            ),
        ))
    ids = [item.id for item in schedules.distinct().all()]
    return generate_occurrences(
        start_date=day - timedelta(days=lookback_days),
        end_date=day + timedelta(days=horizon_days),
        schedule_ids=ids,
    ) if ids else []


def ensure_occurrences_for_operational_date(day, *, round_id=None, schedule_ids=None):
    """Génère uniquement les échéances d'une tournée réalisables ce jour."""
    if schedule_ids is None:
        query = MeterReadingRoundMember.query.filter_by(round_id=round_id)
        schedule_ids = [member.schedule_id for member in query.all()]
    if not schedule_ids:
        return []
    return generate_occurrences(
        start_date=day,
        end_date=day + timedelta(days=366),
        schedule_ids=schedule_ids,
        operational_date=day,
    )


def list_open_occurrences(day, user_id=None):
    query = MeterReadingOccurrence.query.filter(
        MeterReadingOccurrence.status == TODO,
        MeterReadingOccurrence.operational_date <= day,
    ).options(
        joinedload(MeterReadingOccurrence.schedule).joinedload(MeterReadingSchedule.meter),
        joinedload(MeterReadingOccurrence.assigned_to),
    )
    if user_id is not None:
        query = query.filter(MeterReadingOccurrence.assigned_to_id == user_id)
    return query.order_by(MeterReadingOccurrence.operational_date, MeterReadingOccurrence.id).all()


def _locked_occurrence(occurrence_id):
    occurrence = db.session.execute(
        select(MeterReadingOccurrence).where(MeterReadingOccurrence.id == occurrence_id).with_for_update()
    ).scalar_one()
    if occurrence.status != TODO:
        raise MeterDomainError("Cette échéance est déjà traitée ou annulée.")
    return occurrence


def record_occurrence_reading(*, occurrence_id, value, user_id, notes=None,
                              is_reset=False, reset_reason=None,
                              photo_filename=None, photo_path=None, reading_date=None):
    occurrence = _locked_occurrence(occurrence_id)
    if occurrence.assigned_to_id not in (None, user_id):
        raise MeterDomainError("Cette échéance n'est pas attribuée à cet utilisateur.")
    reading = record_meter_reading(
        meter=occurrence.schedule.meter, value=value, user_id=user_id,
        notes=notes, is_reset=is_reset, reset_reason=reset_reason,
        photo_filename=photo_filename, photo_path=photo_path,
        reading_date=reading_date, commit=False,
    )
    occurrence.status = TREATED_READING
    occurrence.meter_reading = reading
    occurrence.processed_by_id = user_id
    occurrence.processed_at = datetime.now()
    _refresh_round_status(occurrence.round_occurrence)
    db.session.commit()
    return occurrence


def record_occurrence_without_reading(*, occurrence_id, reason, user_id, comment=None):
    if reason not in NO_READING_REASONS:
        raise MeterDomainError("Motif de relevé non obtenu inconnu.")
    occurrence = _locked_occurrence(occurrence_id)
    if occurrence.assigned_to_id not in (None, user_id):
        raise MeterDomainError("Cette échéance n'est pas attribuée à cet utilisateur.")
    occurrence.status = TREATED_WITHOUT_READING
    occurrence.no_reading_reason = reason
    occurrence.comment = (comment or "").strip() or None
    occurrence.processed_by_id = user_id
    occurrence.processed_at = datetime.now()
    _refresh_round_status(occurrence.round_occurrence)
    db.session.commit()
    return occurrence


def create_round(*, name, default_assigned_to_id=None, estimated_duration_minutes=None, commit=True):
    name = (name or "").strip()
    if not name:
        raise MeterDomainError("Le nom de la tournée est obligatoire.")
    round_ = MeterReadingRound(
        name=name, default_assigned_to_id=default_assigned_to_id,
        estimated_duration_minutes=estimated_duration_minutes,
    )
    db.session.add(round_)
    if commit:
        db.session.commit()
    return round_


def add_round_member(*, round_, schedule, position, commit=True):
    if position is None or int(position) < 1:
        raise MeterDomainError("La position de tournée doit être positive.")
    member = MeterReadingRoundMember(round=round_, schedule=schedule, position=int(position))
    db.session.add(member)
    if commit:
        db.session.commit()
    return member
