"""
Business logic for certifications: apply, pay, proctor unlock, exam, certificate.
"""
import random
from decimal import Decimal

from django.db import transaction
from django.urls import reverse
from django.utils import timezone

from apps.certifications.models import (
    APP_AWAITING_PROCTOR,
    APP_CERTIFIED,
    APP_FAILED,
    APP_IN_EXAM,
    APP_PAID,
    APP_PASSED,
    APP_PENDING_PAYMENT,
    APP_PENDING_REATTEMPT,
    EXAM_MODE_CENTER,
    EXAM_MODE_REMOTE,
    Certificate,
    CertificationApplication,
    ExamAnswer,
    ExamAttempt,
    ExamChoice,
    ExamQuestion,
    ProctorSession,
    TestScheduleSlot,
)


def apply_for_certification(user, program):
    """Create or resume application. Free programs auto-mark paid."""
    app, created = CertificationApplication.objects.get_or_create(
        candidate=user,
        program=program,
        defaults={'status': APP_PENDING_PAYMENT, 'payment_kind': 'initial'},
    )
    if app.status == APP_CERTIFIED:
        raise ValueError('You already hold a certificate for this program.')

    if program.fee <= 0 and app.status == APP_PENDING_PAYMENT:
        mark_application_paid(app, method='free', kind='initial')
    return app


def mark_application_paid(application, method='manual', approved_by=None, kind=None):
    """Approve initial certification fee or a reattempt fee."""
    kind = kind or application.payment_kind or 'initial'
    application.payment_method = method
    application.payment_kind = kind
    application.status = APP_PAID
    application.paid_at = timezone.now()
    application.approved_by = approved_by
    application.proctor_unlocked_at = None
    # After any payment, candidate must schedule again
    application.exam_mode = ''
    application.testing_center = None
    application.schedule_slot = None
    application.scheduled_at = None
    application.save()

    if application.promo_code_id and application.discount_amount:
        from apps.promotions.models import APPLIES_CERTS
        from apps.promotions.services import record_promo_redemption, cert_payable
        record_promo_redemption(
            promo=application.promo_code,
            user=application.candidate,
            scope=APPLIES_CERTS,
            original=application.original_amount or application.current_fee_due(),
            discount=application.discount_amount,
            final=cert_payable(application),
            certification_application=application,
        )
    return application


def reject_application_payment(application, rejected_by, reason=''):
    """
    Reject a manual payment proof. Keeps pending status so the candidate can re-upload.
    Stripe payments are never rejected here — they auto-approve on successful charge.
    """
    if application.status not in (APP_PENDING_PAYMENT, APP_PENDING_REATTEMPT):
        raise ValueError('This application is not awaiting payment review.')
    if (application.payment_method or '').lower() == 'stripe':
        raise ValueError('Stripe payments are approved automatically and cannot be rejected here.')
    if not reason or not reason.strip():
        raise ValueError('Please provide a rejection reason.')

    if application.payment_proof:
        application.payment_proof.delete(save=False)
        application.payment_proof = None
    application.payment_note = ''
    application.payment_rejection_reason = reason.strip()
    application.approved_by = None
    application.save()
    return application


def book_center_slot(application, slot):
    """Book an in-person testing center schedule slot."""
    if application.status != APP_PAID:
        raise ValueError('Complete payment before booking a test slot.')
    if not application.program.allow_center_exam:
        raise ValueError('In-person center exams are not enabled for this certification.')
    if not slot.is_bookable and application.schedule_slot_id != slot.pk:
        raise ValueError('This schedule slot is full or no longer available.')
    if slot.program_id and slot.program_id != application.program_id:
        raise ValueError('This slot is not available for your certification program.')

    with transaction.atomic():
        locked = TestScheduleSlot.objects.select_for_update().get(pk=slot.pk)
        taken = locked.bookings.exclude(pk=application.pk).count()
        if taken >= locked.capacity:
            raise ValueError('This schedule slot is full. Please choose another.')
        application.exam_mode = EXAM_MODE_CENTER
        application.testing_center = locked.center
        application.schedule_slot = locked
        application.scheduled_at = timezone.now()
        application.save()
    return application


def select_remote_exam(application):
    """Choose online remote proctored exam (no center booking)."""
    if application.status != APP_PAID:
        raise ValueError('Complete payment before scheduling.')
    if not application.program.allow_remote_exam:
        raise ValueError('Remote online exams are not enabled for this certification.')

    application.exam_mode = EXAM_MODE_REMOTE
    application.testing_center = None
    application.schedule_slot = None
    application.scheduled_at = timezone.now()
    application.save()
    return application


def create_proctor_session(application, created_by, meeting_url='', notes='', hours_valid=2):
    """Teacher/consultant creates a live unlock code for the candidate."""
    if application.needs_schedule:
        raise ValueError('Candidate must schedule the test (center or remote) first.')
    if application.status not in (APP_PAID, APP_AWAITING_PROCTOR):
        raise ValueError('Candidate is not ready for a proctor session (payment required).')
    if application.attempts_remaining <= 0:
        raise ValueError('No exam attempts remaining for this candidate.')

    # Revoke any unused open sessions
    ProctorSession.objects.filter(
        application=application,
        unlocked_at__isnull=True,
        is_revoked=False,
    ).update(is_revoked=True)

    session = ProctorSession.objects.create(
        application=application,
        created_by=created_by,
        meeting_url=(meeting_url or '').strip(),
        notes=(notes or '').strip(),
        expires_at=timezone.now() + timezone.timedelta(hours=hours_valid),
    )
    return session


def unlock_with_proctor_code(application, code):
    """Student enters the code shared by the teacher during the live meeting."""
    code = (code or '').strip().upper()
    if not code:
        raise ValueError('Enter the exam unlock code from your teacher.')

    if application.status == APP_CERTIFIED:
        raise ValueError('Certificate already issued.')
    if application.attempts_remaining <= 0:
        raise ValueError('No exam attempts remaining.')
    if application.status not in (APP_PAID, APP_AWAITING_PROCTOR):
        raise ValueError('Complete payment before unlocking the exam.')
    if application.needs_schedule:
        raise ValueError('Schedule your test (choose a testing center or remote) before unlocking.')

    try:
        session = ProctorSession.objects.get(
            application=application,
            code__iexact=code,
        )
    except ProctorSession.DoesNotExist:
        raise ValueError('Invalid unlock code. Ask your teacher for the current code.')

    if session.is_revoked:
        raise ValueError('This unlock code was revoked. Ask your teacher for a new one.')
    if session.unlocked_at:
        # Already used — allow if still awaiting and same session recently unlocked
        if application.status == APP_AWAITING_PROCTOR and application.proctor_unlocked_at:
            return session
        raise ValueError('This unlock code was already used.')
    if timezone.now() > session.expires_at:
        raise ValueError('This unlock code has expired. Ask your teacher for a new one.')

    session.unlocked_at = timezone.now()
    session.save(update_fields=['unlocked_at'])

    application.status = APP_AWAITING_PROCTOR
    application.proctor_unlocked_at = timezone.now()
    application.save(update_fields=['status', 'proctor_unlocked_at', 'updated_at'])
    return session


def start_exam_attempt(application):
    """Start a new attempt with random MCQs (or resume an open one). Requires proctor unlock when enabled."""
    if application.status == APP_CERTIFIED:
        raise ValueError('Certificate already issued.')
    if application.status in (APP_PENDING_PAYMENT, APP_PENDING_REATTEMPT):
        raise ValueError('Payment required before taking the exam.')

    open_attempt = ExamAttempt.objects.filter(
        application=application, submitted_at__isnull=True
    ).first()
    if open_attempt:
        # Practice phase has no timer — always resume
        if open_attempt.is_practice_phase:
            return open_attempt
        if open_attempt.time_expired():
            submit_exam_attempt(open_attempt, {})
            application.refresh_from_db()
        else:
            return open_attempt

    if application.attempts_remaining <= 0:
        raise ValueError('No exam attempts remaining. Maximum is 1 attempt + 2 reattempts.')

    program = application.program
    if program.require_proctor:
        if application.status != APP_AWAITING_PROCTOR or not application.proctor_unlocked_at:
            raise ValueError(
                'Join the live meeting with your teacher and enter the unlock code before starting.'
            )

    pool = list(
        ExamQuestion.objects.filter(program=program, is_active=True)
        .prefetch_related('choices')
    )
    min_bank = program.min_question_bank or 800
    exam_size = program.questions_per_exam or 100
    if len(pool) < min_bank:
        raise ValueError(
            f'The question bank is not ready yet. '
            f'At least {min_bank} active MCQs are required '
            f'(currently {len(pool)}). Contact the academy.'
        )
    if len(pool) < exam_size:
        raise ValueError(
            f'Not enough questions to build an exam of {exam_size} MCQs.'
        )

    # Each candidate gets a unique random set of exam_size questions from the bank
    selected = random.sample(pool, k=exam_size)
    question_ids = [q.pk for q in selected]

    proctor = (
        ProctorSession.objects.filter(application=application, unlocked_at__isnull=False)
        .order_by('-unlocked_at')
        .first()
    )

    attempt = ExamAttempt.objects.create(
        application=application,
        question_ids=question_ids,
        total_asked=len(question_ids),
        proctor_session=proctor,
    )
    application.status = APP_IN_EXAM
    application.save(update_fields=['status', 'updated_at'])
    return attempt


def begin_timed_exam(attempt):
    """End practice phase and start the countdown for the real exam."""
    if attempt.submitted_at:
        raise ValueError('This attempt was already submitted.')
    if attempt.timer_started_at:
        return attempt
    attempt.timer_started_at = timezone.now()
    attempt.save(update_fields=['timer_started_at'])
    return attempt


@transaction.atomic
def cancel_exam_attempt(attempt, reason='tab_leave', answers_map=None):
    """
    Cancel a timed exam because the candidate left the tab/browser.
    Consumes the attempt as a failed submission (partial answers scored if any).
    Practice phase is not cancelled — leaving then does not forfeit the attempt.
    """
    if attempt.submitted_at:
        return attempt
    if not attempt.timer_started_at:
        # Still on practice question — do not forfeit
        return attempt

    attempt.cancelled_at = timezone.now()
    attempt.cancel_reason = (reason or 'tab_leave')[:64]
    attempt.save(update_fields=['cancelled_at', 'cancel_reason'])
    return submit_exam_attempt(attempt, answers_map or {})


@transaction.atomic
def submit_exam_attempt(attempt, answers_map):
    """
    answers_map: {question_id: choice_id}
    Pass requires >= program.passing_score (default 70%).
    On fail with attempts left → pending reattempt fee.
    """
    if attempt.submitted_at:
        raise ValueError('This attempt was already submitted.')

    application = attempt.application
    program = application.program
    question_ids = attempt.question_ids or []
    questions = {
        q.pk: q
        for q in ExamQuestion.objects.filter(pk__in=question_ids).prefetch_related('choices')
    }

    correct = 0
    for qid in question_ids:
        q = questions.get(qid)
        if not q:
            continue
        choice_id = answers_map.get(str(qid)) or answers_map.get(qid)
        selected = None
        is_correct = False
        if choice_id:
            try:
                selected = ExamChoice.objects.get(pk=choice_id, question=q)
                is_correct = bool(selected.is_correct)
            except ExamChoice.DoesNotExist:
                selected = None
        if is_correct:
            correct += 1
        ExamAnswer.objects.update_or_create(
            attempt=attempt,
            question=q,
            defaults={'selected_choice': selected, 'is_correct': is_correct},
        )

    total = len(question_ids) or 1
    pass_mark = program.passing_score or 70
    score = Decimal(correct * 100) / Decimal(total)
    passed = score >= pass_mark

    attempt.correct_count = correct
    attempt.total_asked = total
    attempt.score_percent = score
    attempt.passed = passed
    attempt.submitted_at = timezone.now()
    attempt.save()

    # Clear unlock so next attempt needs a fresh teacher code
    application.proctor_unlocked_at = None

    if passed:
        application.status = APP_PASSED
        application.save(update_fields=['status', 'proctor_unlocked_at', 'updated_at'])
        issue_certificate(application, attempt)
    else:
        application.refresh_from_db()
        # attempts_used includes this just-submitted attempt
        remaining = application.attempts_remaining
        if remaining > 0:
            application.status = APP_PENDING_REATTEMPT
            application.payment_kind = 'reattempt'
            application.payment_proof = None
            application.payment_note = ''
            application.stripe_session_id = ''
            application.promo_code = None
            application.original_amount = None
            application.discount_amount = 0
            application.payable_amount = None
            application.save()
        else:
            application.status = APP_FAILED
            application.save(update_fields=['status', 'proctor_unlocked_at', 'updated_at'])

    return attempt


def issue_certificate(application, attempt):
    """Create Certificate row and mark application certified."""
    existing = Certificate.objects.filter(application=application).first()
    if existing and not existing.is_revoked:
        application.status = APP_CERTIFIED
        application.save(update_fields=['status', 'updated_at'])
        return existing

    user = application.candidate
    cert = Certificate.objects.create(
        application=application,
        recipient_name=user.get_display_name(),
        program_title=application.program.title,
        score_percent=attempt.score_percent or 0,
    )
    application.status = APP_CERTIFIED
    application.save(update_fields=['status', 'updated_at'])
    return cert


def _amount_to_stripe_cents(amount, currency_code):
    zero_decimal = {
        'bif', 'clp', 'djf', 'gnf', 'jpy', 'kmf', 'krw', 'mga',
        'pyg', 'rwf', 'ugx', 'vnd', 'vuv', 'xaf', 'xof', 'xpf',
    }
    value = Decimal(amount or 0)
    if currency_code.lower() in zero_decimal:
        return int(value)
    return int(value * 100)


def create_cert_stripe_checkout(application, request, kind=None):
    import stripe
    from apps.dashboard.models import SiteSettings

    site = SiteSettings.load()
    if not site.stripe_ready:
        raise ValueError('Stripe is not configured.')

    kind = kind or application.payment_kind or 'initial'
    from apps.promotions.services import cert_payable
    if kind == 'reattempt':
        base_amount = application.program.effective_reattempt_fee
        label = f'Reattempt fee: {application.program.title}'
        expected_status = APP_PENDING_REATTEMPT
    else:
        base_amount = application.program.fee
        label = f'Certification: {application.program.title}'
        expected_status = APP_PENDING_PAYMENT

    if application.status != expected_status:
        raise ValueError('This application is not awaiting that payment.')

    amount = cert_payable(application)
    # If promo was applied against wrong base (e.g. fee changed), prefer stored payable
    if application.payable_amount is None:
        amount = base_amount

    stripe.api_key = site.stripe_secret_key
    currency = (site.currency_code or 'PKR').lower()
    if amount <= 0:
        return mark_application_paid(application, method='free', kind=kind)

    if application.promo_code_id:
        label = f'{label} (promo {application.promo_code.code})'

    success_url = request.build_absolute_uri(
        reverse('certifications:stripe_success', kwargs={'pk': application.pk})
    ) + '?session_id={CHECKOUT_SESSION_ID}'
    cancel_url = request.build_absolute_uri(
        reverse('certifications:pay', kwargs={'pk': application.pk})
    )

    session = stripe.checkout.Session.create(
        mode='payment',
        customer_email=application.candidate.email or None,
        line_items=[{
            'quantity': 1,
            'price_data': {
                'currency': currency,
                'unit_amount': _amount_to_stripe_cents(amount, currency),
                'product_data': {
                    'name': label,
                    'description': f'{site.institute_name} — certification {kind} fee',
                },
            },
        }],
        metadata={
            'cert_application_id': str(application.pk),
            'type': 'certification',
            'payment_kind': kind,
        },
        success_url=success_url,
        cancel_url=cancel_url,
    )
    application.payment_method = 'stripe'
    application.payment_kind = kind
    application.stripe_session_id = session.id
    application.save(update_fields=['payment_method', 'payment_kind', 'stripe_session_id', 'updated_at'])
    return session


def finalize_cert_stripe_payment(application, session=None):
    meta = {}
    if session:
        application.stripe_session_id = session.get('id') or application.stripe_session_id
        pi = session.get('payment_intent')
        if pi:
            application.stripe_payment_intent = pi if isinstance(pi, str) else getattr(pi, 'id', '')
        meta = session.get('metadata') or {}
    kind = meta.get('payment_kind') or application.payment_kind or 'initial'
    if application.status not in (APP_PENDING_PAYMENT, APP_PENDING_REATTEMPT):
        return application
    return mark_application_paid(application, method='stripe', kind=kind)


def verify_certificate_code(code: str):
    code = (code or '').strip().upper()
    if not code:
        return None
    try:
        return Certificate.objects.select_related(
            'application__candidate',
            'application__program',
        ).get(verification_code__iexact=code)
    except Certificate.DoesNotExist:
        return None


# ── MCQ CSV bulk import ──────────────────────────────────────────────────────

MCQ_CSV_HEADERS = (
    'question',
    'choice_a',
    'choice_b',
    'choice_c',
    'choice_d',
    'correct',
    'order',
    'is_active',
)

MCQ_CSV_SAMPLE_ROWS = [
    {
        'question': 'What is the output of print(2 ** 3) in Python?',
        'choice_a': '6',
        'choice_b': '8',
        'choice_c': '9',
        'choice_d': '5',
        'correct': 'B',
        'order': '1',
        'is_active': '1',
    },
    {
        'question': 'Which keyword defines a function in Python?',
        'choice_a': 'func',
        'choice_b': 'def',
        'choice_c': 'function',
        'choice_d': 'define',
        'correct': 'B',
        'order': '2',
        'is_active': 'yes',
    },
    {
        'question': 'HTTP status 404 means?',
        'choice_a': 'OK',
        'choice_b': 'Created',
        'choice_c': 'Not Found',
        'choice_d': 'Server Error',
        'correct': 'C',
        'order': '3',
        'is_active': 'true',
    },
]


def build_mcq_csv_sample() -> str:
    """Return CSV text for the downloadable sample template."""
    import csv
    import io

    buf = io.StringIO()
    writer = csv.DictWriter(buf, fieldnames=MCQ_CSV_HEADERS, lineterminator='\n')
    writer.writeheader()
    for row in MCQ_CSV_SAMPLE_ROWS:
        writer.writerow(row)
    return buf.getvalue()


def _normalize_correct(value):
    raw = (value or '').strip().lower()
    mapping = {
        'a': 'a', '1': 'a',
        'b': 'b', '2': 'b',
        'c': 'c', '3': 'c',
        'd': 'd', '4': 'd',
    }
    return mapping.get(raw)


def _parse_bool(value, default=True):
    raw = (value or '').strip().lower()
    if raw == '':
        return default
    if raw in ('1', 'true', 'yes', 'y', 'active'):
        return True
    if raw in ('0', 'false', 'no', 'n', 'inactive'):
        return False
    return default


@transaction.atomic
def import_mcq_csv(program, file_obj):
    """
    Import MCQs from a CSV file into a certification program.
    Required columns: question, choice_a, choice_b, correct
    Optional: choice_c, choice_d, order, is_active

    Returns dict: created, skipped, errors (list of {row, message})
    """
    import csv
    import io

    raw = file_obj.read()
    if isinstance(raw, bytes):
        text = raw.decode('utf-8-sig')
    else:
        text = raw

    reader = csv.DictReader(io.StringIO(text))
    if not reader.fieldnames:
        raise ValueError('CSV file is empty or missing a header row.')

    headers = {h.strip().lower(): h for h in reader.fieldnames if h}
    required = ('question', 'choice_a', 'choice_b', 'correct')
    missing = [h for h in required if h not in headers]
    if missing:
        raise ValueError(
            'CSV header must include: question, choice_a, choice_b, correct. '
            f'Missing: {", ".join(missing)}. Download the sample CSV for the exact format.'
        )

    def cell(row, key, default=''):
        src = headers.get(key)
        if not src:
            return default
        return (row.get(src) or default).strip()

    created = 0
    skipped = 0
    errors = []
    max_order = (
        ExamQuestion.objects.filter(program=program)
        .order_by('-order')
        .values_list('order', flat=True)
        .first()
        or 0
    )

    for idx, row in enumerate(reader, start=2):  # row 1 = header
        question_text = cell(row, 'question')
        if not question_text:
            skipped += 1
            continue

        choice_a = cell(row, 'choice_a')
        choice_b = cell(row, 'choice_b')
        choice_c = cell(row, 'choice_c')
        choice_d = cell(row, 'choice_d')
        correct = _normalize_correct(cell(row, 'correct'))

        if not choice_a or not choice_b:
            errors.append({'row': idx, 'message': 'choice_a and choice_b are required.'})
            continue
        if not correct:
            errors.append({
                'row': idx,
                'message': 'correct must be A, B, C, or D (or 1–4).',
            })
            continue

        choices = {
            'a': choice_a,
            'b': choice_b,
            'c': choice_c,
            'd': choice_d,
        }
        if not choices.get(correct):
            errors.append({
                'row': idx,
                'message': f'correct={correct.upper()} but that choice is empty.',
            })
            continue

        order_raw = cell(row, 'order')
        if order_raw:
            try:
                order = int(order_raw)
            except ValueError:
                errors.append({'row': idx, 'message': f'Invalid order value: {order_raw}'})
                continue
        else:
            max_order += 1
            order = max_order

        is_active = _parse_bool(cell(row, 'is_active'), default=True)

        question = ExamQuestion.objects.create(
            program=program,
            text=question_text,
            order=order,
            is_active=is_active,
        )
        for key, text in choices.items():
            if not text:
                continue
            ExamChoice.objects.create(
                question=question,
                text=text[:500],
                is_correct=(key == correct),
            )
        created += 1
        if order > max_order:
            max_order = order

    return {
        'created': created,
        'skipped': skipped,
        'errors': errors[:50],
        'error_count': len(errors),
    }
