"""Services métier C4 : contrats photocopieurs et consommables partagés."""
from calendar import monthrange
from datetime import date, datetime, timedelta, timezone
from statistics import mean

from sqlalchemy import and_, or_

from ..models import (
    Alert, Company, Consumable, ConsumableOrder, ConsumableReceipt,
    ConsumableUsage, ContractEquipmentAssignment, Equipment, EquipmentConsumable,
    Meter, MeterReading, PhotocopierContract, PhotocopierContractPeriod,
)
from ..models.planning import MeterReadingOccurrence, MeterReadingSchedule
from ...extensions import db


class C4DomainError(ValueError):
    pass


def _anniversary(year, month, day):
    return date(year, month, min(day, monthrange(year, month)[1]))


def create_contract(*, supplier_id, name, start_date, end_date, anniversary_month,
                    anniversary_day, quota_start_date=None, reference=None, notes=None, commit=True):
    if end_date < start_date:
        raise C4DomainError("La fin du contrat doit être postérieure à son début.")
    if not 1 <= int(anniversary_month) <= 12:
        raise C4DomainError("Le mois anniversaire est invalide.")
    quota_start_date = quota_start_date or start_date
    if quota_start_date < start_date or quota_start_date > end_date:
        raise C4DomainError("La date de début des quotas doit être comprise dans le contrat.")
    contract = PhotocopierContract(
        supplier_id=supplier_id, name=name, reference=reference,
        start_date=start_date, end_date=end_date,
        quota_start_date=quota_start_date,
        anniversary_month=int(anniversary_month), anniversary_day=int(anniversary_day),
        notes=notes, status="ACTIVE",
    )
    db.session.add(contract)
    db.session.flush()
    generate_contract_periods(contract, commit=False)
    if commit:
        db.session.commit()
    return contract


def generate_contract_periods(contract, *, commit=True):
    """Crée les périodes annuelles sans doublon, bornées au contrat."""
    cursor = contract.quota_start_date or contract.start_date
    while cursor <= contract.end_date:
        next_anniversary = _anniversary(cursor.year + 1, contract.anniversary_month, contract.anniversary_day)
        period_end = min(next_anniversary - timedelta(days=1), contract.end_date)
        if period_end < cursor:
            break
        existing = PhotocopierContractPeriod.query.filter_by(contract_id=contract.id, start_date=cursor).first()
        if not existing:
            db.session.add(PhotocopierContractPeriod(contract=contract, start_date=cursor, end_date=period_end))
        cursor = period_end + timedelta(days=1)
    if commit:
        db.session.commit()
    return contract.periods


def _overlaps(start, end, other_start, other_end):
    return start <= (other_end or date.max) and (end or date.max) >= other_start


def assign_equipment_to_contract(*, contract, equipment, entry_date, exit_date=None,
                                  replacement_reason=None, replacement_comment=None, commit=True):
    if contract.status != "ACTIVE":
        raise C4DomainError("Un contrat clos ne peut plus recevoir de machine.")
    if entry_date < contract.start_date or entry_date > contract.end_date:
        raise C4DomainError("La date d'entrée doit être comprise dans le contrat.")
    if exit_date and exit_date > contract.end_date:
        raise C4DomainError("La date de sortie dépasse la fin du contrat.")
    for row in ContractEquipmentAssignment.query.filter_by(equipment_id=equipment.id).all():
        if _overlaps(entry_date, exit_date, row.entry_date, row.exit_date):
            raise C4DomainError("Cette machine est déjà rattachée à un contrat sur cette période.")
    assignment = ContractEquipmentAssignment(
        contract=contract, equipment=equipment, entry_date=entry_date, exit_date=exit_date,
        replacement_reason=replacement_reason, replacement_comment=replacement_comment,
    )
    db.session.add(assignment)
    if commit:
        db.session.commit()
    return assignment


def replace_contract_equipment(*, old_assignment, new_equipment, replacement_date,
                               reason="AUTRE", comment=None, commit=True):
    if reason not in {"PANNE", "RENOUVELLEMENT_FOURNISSEUR", "CHANGEMENT_MODELE", "FIN_DE_VIE", "AUTRE"}:
        raise C4DomainError("Motif de remplacement invalide.")
    if replacement_date < old_assignment.entry_date:
        raise C4DomainError("La date de remplacement est invalide.")
    old_assignment.exit_date = replacement_date - timedelta(days=1)
    new_assignment = assign_equipment_to_contract(
        contract=old_assignment.contract, equipment=new_equipment,
        entry_date=replacement_date, replacement_reason=reason,
        replacement_comment=comment, commit=False,
    )
    if commit:
        db.session.commit()
    return new_assignment


def _meter_role(meter):
    return {"COPIER_BW": "bw", "COPIER_COLOR": "color"}.get(meter.contract_role)


def classify_legacy_copier_meter(meter, *, commit=True):
    """Classifie prudemment un ancien compteur photocopieur.

    Cette aide est volontairement explicite et ponctuelle : le calcul C4 ne
    l'appelle jamais comme repli implicite.  Les noms ambigus restent NONE.
    """
    if meter.contract_role != "NONE" or meter.meter_type != "photocopieur":
        return meter.contract_role
    text = f"{meter.name or ''} {meter.usage or ''}".casefold()
    import re
    if re.search(r"\b(noir\s*(et|&)\s*blanc|n\s*[&/]\s*b|\bnb\b)\b", text):
        meter.contract_role = "COPIER_BW"
    elif re.search(r"\b(couleur|color)\b", text):
        meter.contract_role = "COPIER_COLOR"
    if commit:
        db.session.commit()
    return meter.contract_role


def _c2_reference_ids(meter_id, target_date, reading_ids):
    """Retourne les relevés liés à l'échéance C2 de ``target_date``."""
    if not reading_ids:
        return set()
    rows = (db.session.query(MeterReadingOccurrence.meter_reading_id)
            .join(MeterReadingOccurrence.schedule)
            .filter(MeterReadingSchedule.meter_id == meter_id,
                    MeterReadingOccurrence.target_date == target_date,
                    MeterReadingOccurrence.meter_reading_id.in_(reading_ids))
            .all())
    return {row[0] for row in rows}


def _meter_consumption(meter, start_date, end_date):
    readings = (MeterReading.query.filter(
        MeterReading.meter_id == meter.id,
        MeterReading.reading_date <= datetime.combine(end_date, datetime.max.time()),
    ).order_by(MeterReading.reading_date, MeterReading.id).all())
    exact_ids = _c2_reference_ids(meter.id, start_date, [row.id for row in readings])
    return _meter_consumption_from_rows(readings, start_date, end_date, meter=meter,
                                        exact_reference_ids=exact_ids)


def _meter_consumption_from_rows(rows, start_date, end_date, meter=None, exact_reference_ids=None):
    """Même calcul que ``_meter_consumption`` à partir d'un lot déjà chargé."""
    start_dt = datetime.combine(start_date, datetime.min.time())
    end_dt = datetime.combine(end_date, datetime.max.time())
    relevant = [row for row in rows if row.reading_date <= end_dt]
    before = next((row for row in reversed(relevant) if row.reading_date < start_dt), None)
    inside = [row for row in relevant if start_dt <= row.reading_date <= end_dt]
    exact_reference_ids = exact_reference_ids or set()
    linked_reference = next((row for row in relevant
                             if row.id in exact_reference_ids and row.reading_date <= end_dt), None)
    exact_start = bool(linked_reference or (inside and inside[0].reading_date.date() == start_date))
    if linked_reference:
        ordered = [linked_reference] + [row for row in inside if row.id != linked_reference.id]
        ordered.sort(key=lambda row: (row.reading_date, row.id))
    else:
        ordered = inside
    total = 0.0
    previous = None
    for reading in ordered:
        if previous and not reading.is_reset and not previous.is_reset and reading.value >= previous.value:
            total += reading.value - previous.value
        previous = reading
    if exact_start:
        quality = "EXACT"
    else:
        first = inside[0] if inside else None
        prior = [row for row in rows if row.reading_date < (first.reading_date if first else start_dt)]
        prior.sort(key=lambda row: (row.reading_date, row.id))
        prior_rates = []
        for left, right in zip(prior, prior[1:]):
            if not left.is_reset and not right.is_reset and right.value >= left.value:
                left_date = left.reading_date.date()
                right_date = right.reading_date.date()
                if (meter is not None and meter.equipment is not None
                        and getattr(meter.equipment, "device_type", "") in {"photocopier", "photocopieur", "imprimante", "printer"}):
                    days = _school_days(left_date, right_date - timedelta(days=1))
                else:
                    days = max((right_date - left_date).days, 1)
                if days > 0:
                    prior_rates.append((right.value - left.value) / days)
        if first and prior_rates:
            missing_days = max((first.reading_date.date() - start_date).days, 0)
            if meter is not None and meter.equipment is not None and getattr(meter.equipment, "device_type", "") in {"photocopier", "photocopieur", "imprimante", "printer"}:
                comparable_days = _school_days(start_date, first.reading_date.date() - timedelta(days=1))
                missing_days = comparable_days or missing_days
            total += mean(prior_rates) * missing_days
            quality = "ESTIMATED"
        else:
            quality = "PARTIAL"
    return total, quality, len(ordered)


def calculate_contract_usage(period):
    result = {"bw": 0.0, "color": 0.0, "quality": "EXACT", "machines": 0}
    assignments = ContractEquipmentAssignment.query.filter(
        ContractEquipmentAssignment.contract_id == period.contract_id,
        ContractEquipmentAssignment.entry_date <= period.end_date,
        or_(ContractEquipmentAssignment.exit_date.is_(None), ContractEquipmentAssignment.exit_date >= period.start_date),
    ).all()
    equipment_ids = {assignment.equipment_id for assignment in assignments}
    meters = Meter.query.filter(Meter.equipment_id.in_(equipment_ids)).all() if equipment_ids else []
    meter_by_equipment = {}
    for meter in meters:
        meter_by_equipment.setdefault(meter.equipment_id, []).append(meter)
    meter_ids = [meter.id for meter in meters]
    reading_rows = (MeterReading.query.filter(MeterReading.meter_id.in_(meter_ids))
                    .order_by(MeterReading.meter_id, MeterReading.reading_date, MeterReading.id).all()) if meter_ids else []
    readings_by_meter = {}
    for reading in reading_rows:
        readings_by_meter.setdefault(reading.meter_id, []).append(reading)
    for assignment in assignments:
        result["machines"] += 1
        start = max(period.start_date, assignment.entry_date)
        end = min(period.end_date, assignment.exit_date or period.end_date)
        for meter in meter_by_equipment.get(assignment.equipment_id, []):
            role = _meter_role(meter)
            if role is None:
                continue
            meter_rows = readings_by_meter.get(meter.id, [])
            exact_ids = _c2_reference_ids(meter.id, start, [row.id for row in meter_rows])
            amount, quality, _ = _meter_consumption_from_rows(
                meter_rows, start, end, meter=meter, exact_reference_ids=exact_ids
            )
            result[role] += amount
            rank = {"EXACT": 0, "ESTIMATED": 1, "PARTIAL": 2}
            if rank[quality] > rank[result["quality"]]:
                result["quality"] = quality
    return result


def _school_days(start, end):
    """Approximation explicite : jours ouvrés, hors fermetures complètes."""
    from ..models.planning import CollegeClosure, ClosureWorkDay
    closures = CollegeClosure.query.filter(CollegeClosure.start_date <= end, CollegeClosure.end_date >= start).all()
    work_days = {row.work_date for closure in closures for row in ClosureWorkDay.query.filter_by(closure_id=closure.id).all()}
    closed = {day for closure in closures if closure.work_hours_type == "none"
              for day in (start + timedelta(days=i) for i in range((end - start).days + 1))
              if closure.start_date <= day <= closure.end_date}
    return sum(1 for i in range((end - start).days + 1)
               if (day := start + timedelta(days=i)).weekday() < 5 and day not in closed or day in work_days)


def project_contract_usage(period):
    usage = calculate_contract_usage(period)
    elapsed = max((date.today() - period.start_date).days + 1, 0)
    remaining = max((period.end_date - max(date.today(), period.start_date)).days, 0)
    school_elapsed = _school_days(period.start_date, min(date.today(), period.end_date)) if elapsed else 0
    school_remaining = _school_days(max(date.today() + timedelta(days=1), period.start_date), period.end_date) if remaining else 0
    result = {"usage": usage, "projection": {}, "remaining": {}, "confidence": "LOW"}
    for role, quota_key in (("bw", "quota_bw"), ("color", "quota_color")):
        quota = getattr(period, quota_key)
        result["remaining"][role] = max(quota - usage[role], 0) if quota is not None else None
        if quota is None or school_elapsed <= 0:
            result["projection"][role] = None
        else:
            daily = usage[role] / school_elapsed
            result["projection"][role] = usage[role] + daily * school_remaining
            result["confidence"] = "NORMAL" if school_elapsed >= 10 else "LOW"
    return result


def evaluate_quota_alert(period, *, level="WARNING", commit=True):
    from ..models.company import Alert
    projection = project_contract_usage(period)
    for role, quota_key in (("bw", "quota_bw"), ("color", "quota_color")):
        quota = getattr(period, quota_key)
        projected = projection["projection"].get(role)
        related_key = f"{period.id}:{role}"
        alert = Alert.query.filter_by(alert_type="QUOTA_OVERAGE_PROJECTED", related_type=related_key, is_resolved=False).first()
        if quota is None or projected is None or projected <= quota:
            if alert:
                alert.is_resolved = True
                alert.resolved_at = datetime.now(timezone.utc)
                alert.message = "Projection redevenue compatible avec le quota."
            continue
        message = f"Projection {role} : {projected:.0f} copies pour un quota de {quota}."
        if alert:
            alert.message = message
        else:
            db.session.add(Alert(
                alert_type="QUOTA_OVERAGE_PROJECTED", title="Dépassement quota photocopieur",
                message=message, priority="haute" if level == "CRITICAL" else "normale",
                related_id=period.id, related_type=related_key,
            ))
    if commit:
        db.session.commit()
    return projection


def close_photocopier_contract(*, contract, closed_at=None, commit=True):
    closed_at = closed_at or date.today()
    if closed_at < contract.start_date:
        raise C4DomainError("La date de clôture ne peut pas précéder le début du contrat.")
    closed_at = min(closed_at, contract.end_date)
    contract.status = "CLOSED"
    contract.closed_at = closed_at
    contract.end_date = closed_at
    for period in contract.periods:
        if period.start_date > closed_at:
            period.status = "CANCELLED"
        elif period.start_date <= closed_at <= period.end_date:
            period.end_date = closed_at
            period.status = "CLOSED"
    for assignment in contract.assignments:
        if assignment.entry_date <= closed_at and (assignment.exit_date is None or assignment.exit_date > closed_at):
            assignment.exit_date = closed_at
    if commit:
        db.session.commit()
    return contract


def create_consumable_order(*, consumable, quantity, supplier_id=None, user_id=None, commit=True):
    quantity = int(quantity)
    if quantity <= 0:
        raise C4DomainError("La quantité commandée doit être positive.")
    order = ConsumableOrder(consumable=consumable, quantity_ordered=quantity, supplier_id=supplier_id, created_by_id=user_id, status="ORDERED", ordered_at=datetime.now(timezone.utc))
    db.session.add(order)
    refresh_consumable_stock_alert(consumable, commit=False)
    if commit:
        db.session.commit()
    return order


def receive_consumable_order(*, order, quantity, user_id=None, comment=None, commit=True):
    quantity = int(quantity)
    if quantity <= 0 or quantity > order.remaining_quantity:
        raise C4DomainError("La quantité reçue dépasse le restant à recevoir.")
    receipt = ConsumableReceipt(order=order, quantity=quantity, received_by_id=user_id, comment=comment)
    db.session.add(receipt)
    order.quantity_received += quantity
    order.consumable.quantity = (order.consumable.quantity or 0) + quantity
    order.status = "RECEIVED" if order.remaining_quantity == 0 else "PARTIALLY_RECEIVED"
    if order.status == "RECEIVED":
        order.received_at = receipt.received_at
    refresh_consumable_stock_alert(order.consumable, commit=False)
    if commit:
        db.session.commit()
    return receipt


def cancel_consumable_order(*, order, user_id=None, reason=None, commit=True):
    if order.status == "RECEIVED":
        raise C4DomainError("Une commande entièrement reçue ne peut pas être annulée.")
    if order.status not in {"ORDERED", "PARTIALLY_RECEIVED"}:
        raise C4DomainError("Cette commande ne peut pas être annulée dans son état actuel.")
    order.status = "CANCELLED"
    order.cancelled_at = datetime.now(timezone.utc)
    order.cancelled_by_id = user_id
    order.cancellation_reason = reason
    refresh_consumable_stock_alert(order.consumable, commit=False)
    if commit:
        db.session.commit()
    return order


def record_consumable_issue(*, consumable, equipment, quantity=1, user_id=None, intervention_id=None, notes=None, commit=True):
    quantity = int(quantity)
    if quantity <= 0:
        raise C4DomainError("La sortie doit être positive.")
    if (consumable.quantity or 0) < quantity:
        raise C4DomainError("Stock insuffisant.")
    if not EquipmentConsumable.query.filter_by(equipment_id=equipment.id, consumable_id=consumable.id).first():
        raise C4DomainError("Cette référence n'est pas compatible avec la machine.")
    usage = ConsumableUsage(consumable=consumable, equipment=equipment, quantity_used=quantity, used_by_id=user_id, intervention_id=intervention_id, notes=notes)
    consumable.quantity -= quantity
    db.session.add(usage)
    refresh_consumable_stock_alert(consumable, commit=False)
    if commit:
        db.session.commit()
    return usage


def refresh_consumable_stock_alert(consumable, *, commit=True):
    """Maintient une seule alerte de stock bas, sans convertir une commande en stock."""
    pending_orders = ConsumableOrder.query.filter(
        ConsumableOrder.consumable_id == consumable.id,
        ConsumableOrder.status.in_(("ORDERED", "PARTIALLY_RECEIVED")),
    ).all()
    pending_quantity = sum(order.remaining_quantity for order in pending_orders)
    alert = Alert.query.filter_by(alert_type="CONSUMABLE_STOCK_LOW", related_type="consumable", related_id=consumable.id, is_resolved=False).first()
    if (consumable.quantity or 0) <= (consumable.min_quantity or 0):
        message = f"Stock bas : {consumable.quantity or 0} / minimum {consumable.min_quantity or 0}."
        if pending_quantity:
            message += f" Commande en cours — {pending_quantity} à recevoir."
        if alert:
            alert.message = message
        else:
            db.session.add(Alert(alert_type="CONSUMABLE_STOCK_LOW", title="Stock consommable bas", message=message, related_type="consumable", related_id=consumable.id))
    elif alert:
        alert.is_resolved = True
        alert.resolved_at = datetime.now(timezone.utc)
    if commit:
        db.session.commit()
    return alert


def estimate_consumable_stock_coverage(*, consumable):
    links = EquipmentConsumable.query.filter_by(consumable_id=consumable.id).all()
    active = [link.equipment for link in links if link.equipment and not link.equipment.is_deleted and
              (link.equipment.lifecycle_status or "").lower() not in {"retired", "retire", "removed", "hors_service"}]
    rates = []
    for equipment in active:
        lifetime = estimate_consumable_lifetime(consumable=consumable, equipment=equipment)
        if lifetime["average_days"] and lifetime["average_days"] > 0:
            rates.append(1.0 / lifetime["average_days"])
    total_rate = sum(rates)
    if not active or not rates:
        quality = "NON_DISPONIBLE"
        coverage = None
    else:
        quality = "ESTIMATED" if len(rates) == len(active) else "PARTIAL"
        coverage = (consumable.quantity or 0) / total_rate if total_rate else None
    return {"coverage_days": coverage, "quality": quality, "machines_total": len(active),
            "machines_with_history": len(rates), "daily_usage_estimate": total_rate}


def visible_open_c4_alerts(user):
    """Alertes C4 informatives autorisées, sans les transformer en tâches."""
    from ..authorization import has_permission
    rows = []
    if has_permission("contract.view", user):
        rows.extend(Alert.query.filter_by(alert_type="QUOTA_OVERAGE_PROJECTED", is_resolved=False).all())
    if has_permission("stock.view", user):
        rows.extend(Alert.query.filter_by(alert_type="CONSUMABLE_STOCK_LOW", is_resolved=False).all())
    return sorted(rows, key=lambda row: row.created_at or datetime.min.replace(tzinfo=timezone.utc), reverse=True)


def estimate_consumable_lifetime(*, consumable, equipment):
    usages = (ConsumableUsage.query.filter_by(consumable_id=consumable.id, equipment_id=equipment.id)
              .order_by(ConsumableUsage.used_at).all())
    durations = [(b.used_at - a.used_at).days for a, b in zip(usages, usages[1:]) if (b.used_at - a.used_at).days > 0]
    current_days = None
    if usages:
        last = usages[-1].used_at
        if last.tzinfo is None:
            last = last.replace(tzinfo=timezone.utc)
        current_days = (datetime.now(timezone.utc) - last).days
    return {"average_days": mean(durations) if durations else None, "observations": len(durations), "current_days": current_days}
