"""Passenger portal endpoints - JWT (SimpleJWT) based, mirrors the pattern in
views_operator.py / views_auth.py. Reuses all existing business logic from
testing_app (password checks, lockout, OTP, seat locking, eSewa, refund
tiers, ratings) so the passenger REST API behaves identically to the
original session-based passenger website - just returns JSON instead of
rendering templates, so the static client/passenger portal can consume it.

Nothing in testing_app, views_operator.py, views_auth.py or views_admin.py
is modified by this file - it only imports from them.
"""
import secrets
from datetime import timedelta
from decimal import Decimal

from django.conf import settings
from django.contrib.auth.hashers import make_password
from django.core import signing
from django.core.mail import send_mail
from django.db import transaction
from django.db.models import Avg, Count, Q, Sum
from django.http import HttpResponse, HttpResponseRedirect
from django.utils import timezone
from rest_framework.decorators import api_view

from testing_app.models import (ActivityLog, Booking, BookingSeat, Bus,
                                 BusDocument, BusSeat, City, Operator,
                                 Payment, PaymentWebhookLog, Rating, Role,
                                 Schedule, SeatLock, User)
from testing_app.payments import (build_esewa_form, check_status,
                                  decode_callback, esewa_form_url,
                                  verify_callback_signature)
from testing_app.realtime import broadcast_seat_update
from testing_app.views import (ACTIVE_BOOKING_STATES, COMMISSION_RATE,
                               LOCKOUT_MINUTES, MAX_LOGIN_ATTEMPTS,
                               OTP_MAX_TRIES, _generate_and_send_otp,
                               cancellation_quote, log_activity, log_login,
                               verify_password)
from testing_app.views_payments import (PAYMENT_WINDOW_MINUTES,
                                        _confirm_payment,
                                        _next_transaction_uuid,
                                        expire_stale_bookings)

from .auth import body, err, jwt_required, ok
from .jwt_auth import issue_tokens, user_public_dict
from .serializers import (city_dict, passenger_booking_dict, seats_taken,
                          trip_dict)

# Where the static passenger client is served from (client/README.md: `py -m
# http.server 8090` from inside client/). Only used to build eSewa redirect
# links - change if you serve the client from a different port.
PASSENGER_CLIENT_BASE = 'http://localhost:8090/passenger/pages'

MIN_BOOKING_LEAD_MINUTES = 30  # FR-8
PASSWORD_RESET_MAX_AGE = 60 * 60  # 1 hour, FR-5


def _maybe_user(request):
    """Best-effort identity for public-but-personalized endpoints (e.g. so
    the seat map can mark a passenger's own held seats as LOCKED_BY_ME).
    Returns None for anonymous/no-token requests - never raises."""
    token = getattr(request, 'auth', None)
    user = getattr(request, 'user', None)
    if token and user and getattr(user, 'id', None):
        return user
    return None


def _client_redirect(page, **params):
    from urllib.parse import urlencode
    return HttpResponseRedirect(f'{PASSENGER_CLIENT_BASE}/{page}?{urlencode(params)}')


# ---------------------------------------------------------------------------
# Auth: register / login / verify-email / forgot-password
# (GET /auth/me, POST /auth/logout, /auth/logout-all, /auth/refresh already
# work unchanged for any role, including PASSENGER - see views_auth.py.)
# ---------------------------------------------------------------------------

@api_view(['POST'])
def passenger_register(request):
    data = body(request)
    full_name = str(data.get('full_name', '')).strip()
    email = str(data.get('email', '')).strip()
    phone = str(data.get('phone', '') or '').strip()
    password = str(data.get('password', ''))

    if not full_name or not email or not password:
        return err('Please fill in your name, email and password.')
    if '@' not in email or '.' not in email.split('@')[-1]:
        return err('Please enter a valid email address.')
    if phone and not (phone.isdigit() and len(phone) == 10 and phone[:2] in ('97', '98')):
        return err('Phone number must be a 10-digit Nepali mobile number starting 97 or 98.')
    if len(password) < 8 or not any(c.isdigit() for c in password):
        return err('Password must be at least 8 characters and include a number.')
    if User.objects.filter(email__iexact=email).exists():
        return err('An account with this email already exists. Please sign in instead.', status=409)
    if phone and User.objects.filter(phone=phone).exists():
        return err('This phone number is already registered.', status=409)

    role_obj, _ = Role.objects.get_or_create(name='PASSENGER')
    user = User.objects.create(
        role=role_obj, full_name=full_name, email=email, phone=phone or None,
        password_hash=make_password(password),
        email_verified=False, status='ACTIVE',
    )

    sent, otp_error = _generate_and_send_otp(request, user, purpose='signup')
    log_login(request, 'SUCCESS', user=user, message='API passenger registration', role_name='PASSENGER')

    tokens = issue_tokens(user, 'PASSENGER')
    u = user_public_dict(user, 'PASSENGER')
    u['email_verified'] = False
    return ok({**tokens, 'user': u, 'verification_email_sent': sent,
              'verification_error': None if sent else otp_error})


@api_view(['POST'])
def passenger_login(request):
    data = body(request)
    identifier = str(data.get('identifier', '') or data.get('email', '')).strip()
    password = str(data.get('password', ''))

    if not identifier or not password:
        return err('Please enter your email/phone and password.')

    user = User.objects.select_related('role').filter(
        Q(email__iexact=identifier) | Q(phone=identifier)
    ).first()
    if user is None:
        log_login(request, 'FAILED', email=identifier, message='API passenger: unknown identifier')
        return err('Invalid email/phone or password.', status=401)

    now = timezone.now()
    if user.account_locked_until and user.account_locked_until > now:
        remaining = int((user.account_locked_until - now).total_seconds() // 60) + 1
        log_login(request, 'LOCKED', user=user, message='API passenger: attempt while locked')
        return err(f'Account locked. Try again in {remaining} minute(s).', status=423)

    if user.status != 'ACTIVE':
        log_login(request, 'FAILED', user=user, message=f'API passenger: account {user.status}')
        return err(f'Your account is {user.status}. Contact support.', status=403)

    if (user.password_hash or '').startswith('!'):
        log_login(request, 'FAILED', user=user, message='API passenger: Google-only account')
        return err('This account was created with Google and has no password.', status=403)

    if not verify_password(user, password):
        user.failed_login_attempts += 1
        if user.failed_login_attempts >= MAX_LOGIN_ATTEMPTS:
            user.account_locked_until = now + timedelta(minutes=LOCKOUT_MINUTES)
            user.save(update_fields=['failed_login_attempts', 'account_locked_until'])
            log_login(request, 'LOCKED', user=user, message='API passenger: locked after failed attempts')
            return err(f'Too many failed attempts. Account locked for {LOCKOUT_MINUTES} minutes.', status=423)
        user.save(update_fields=['failed_login_attempts'])
        log_login(request, 'FAILED', user=user, message='API passenger: wrong password')
        return err(f'Invalid email/phone or password. Attempt {user.failed_login_attempts} of {MAX_LOGIN_ATTEMPTS}.', status=401)

    if user.role.name != 'PASSENGER':
        log_login(request, 'FAILED', user=user, message='API passenger portal: not a passenger account')
        return err('This account does not have passenger access.', status=403)

    user.failed_login_attempts = 0
    user.account_locked_until = None
    user.last_login_at = now
    user.save(update_fields=['failed_login_attempts', 'account_locked_until', 'last_login_at'])
    log_login(request, 'SUCCESS', user=user, message='API login (passenger portal)', role_name='PASSENGER')

    tokens = issue_tokens(user, 'PASSENGER')
    u = user_public_dict(user, 'PASSENGER')
    u['email_verified'] = user.email_verified
    if user.avatar:
        u['avatar_url'] = request.build_absolute_uri(user.avatar.url)
    return ok({**tokens, 'user': u})


@api_view(['POST'])
@jwt_required('PASSENGER')
def verify_email(request):
    from django.utils.crypto import constant_time_compare
    from testing_app.models import LoginOTP

    user = request.api_user
    code = str(body(request).get('code', '')).strip()
    otp = LoginOTP.objects.filter(user=user, used=False).order_by('-created_at').first()

    if otp is None or otp.expires_at < timezone.now():
        return err('That code has expired. Use "Resend code" to get a new one.', status=410)
    if otp.attempts >= OTP_MAX_TRIES:
        otp.used = True
        otp.save(update_fields=['used'])
        return err('Too many wrong tries. Use "Resend code" to get a new one.', status=410)
    if not constant_time_compare(otp.code, code):
        otp.attempts += 1
        otp.save(update_fields=['attempts'])
        return err(f'Wrong code. Try {otp.attempts} of {OTP_MAX_TRIES}.', status=401)

    otp.used = True
    otp.save(update_fields=['used'])
    user.email_verified = True
    user.save(update_fields=['email_verified'])
    return ok({'email_verified': True})


@api_view(['POST'])
@jwt_required('PASSENGER')
def resend_verification(request):
    sent, error = _generate_and_send_otp(request, request.api_user, purpose='resend')
    if not sent:
        return err(error, status=429)
    return ok({'resent': True})


@api_view(['POST'])
def forgot_password(request):
    email = str(body(request).get('email', '')).strip()
    user = User.objects.filter(email__iexact=email, role__name='PASSENGER').first() if email else None
    if user is not None:
        token = signing.dumps({'uid': user.id, 'p': 'pwreset'})
        link = f'{PASSENGER_CLIENT_BASE}/reset-password.html?token={token}'
        try:
            send_mail(
                subject='Reset your Yatra Booking password',
                message=(f'Hi {user.full_name},\n\nUse the link below to reset your password. '
                         f'It expires in 1 hour and can only be used once.\n\n{link}\n\n'
                         f'If you did not request this, you can ignore this email.'),
                from_email=None, recipient_list=[user.email], fail_silently=True,
            )
        except Exception:
            pass
        log_login(request, 'OTP_SENT', user=user, message='API passenger password reset link emailed')
    # Always return ok (don't reveal whether the email exists).
    return ok({'sent': True})


@api_view(['POST'])
def reset_password(request):
    data = body(request)
    token = str(data.get('token', ''))
    new_password = str(data.get('new_password', ''))

    if len(new_password) < 8 or not any(c.isdigit() for c in new_password):
        return err('Password must be at least 8 characters and include a number.')
    try:
        payload = signing.loads(token, max_age=PASSWORD_RESET_MAX_AGE)
        assert payload.get('p') == 'pwreset'
    except Exception:
        return err('This reset link has expired or is invalid. Request a new one.', status=401)

    user = User.objects.filter(id=payload.get('uid')).first()
    if user is None:
        return err('This reset link is no longer valid.', status=401)

    user.password_hash = make_password(new_password)
    user.token_version += 1  # FR-5: invalidate every existing session
    user.failed_login_attempts = 0
    user.account_locked_until = None
    user.save(update_fields=['password_hash', 'token_version', 'failed_login_attempts', 'account_locked_until'])
    log_login(request, 'SUCCESS', user=user, message='API passenger password reset')
    return ok({'reset': True})


# ---------------------------------------------------------------------------
# Cities & trip search (FR-8, FR-9) - public
# ---------------------------------------------------------------------------

@api_view(['GET'])
def cities(request):
    return ok([city_dict(c) for c in City.objects.order_by('city_name')])


def _bookable_schedules():
    """Same gate book_ticket() uses: approved bus, active, fully doc-verified."""
    required_docs = len(BusDocument.REQUIRED_DOC_TYPES)
    return Schedule.objects.select_related(
        'bus', 'bus__operator', 'route__source_city', 'route__destination_city'
    ).filter(
        status='ACTIVE', bus__approval_status='APPROVED', bus__status='ACTIVE',
    ).annotate(
        verified_docs=Count('bus__documents', filter=Q(bus__documents__status='VERIFIED'), distinct=True)
    ).filter(verified_docs__gte=required_docs)


@api_view(['GET'])
def trip_search(request):
    q = request.query_params
    origin = str(q.get('origin', '') or '').strip()
    destination = str(q.get('destination', '') or '').strip()
    date_str = str(q.get('date', '') or '').strip()

    if origin and destination and origin.lower() == destination.lower():
        return err('Departure and destination cities cannot be the same.')

    qs = _bookable_schedules().filter(
        departure_time__gte=timezone.now() + timedelta(minutes=MIN_BOOKING_LEAD_MINUTES)
    )
    if origin:
        qs = qs.filter(route__source_city__city_name__iexact=origin)
    if destination:
        qs = qs.filter(route__destination_city__city_name__iexact=destination)
    if date_str:
        qs = qs.filter(departure_time__date=date_str)

    bus_type = str(q.get('bus_type', '') or '').strip().upper()
    if bus_type:
        qs = qs.filter(bus__bus_type=bus_type)
    if q.get('min_fare'):
        try:
            qs = qs.filter(fare__gte=Decimal(q['min_fare']))
        except Exception:
            pass
    if q.get('max_fare'):
        try:
            qs = qs.filter(fare__lte=Decimal(q['max_fare']))
        except Exception:
            pass

    ordering = str(q.get('ordering', '') or 'departure_time')
    order_map = {
        'fare': 'fare', '-fare': '-fare',
        'departure_time': 'departure_time', '-departure_time': '-departure_time',
    }
    qs = qs.order_by(order_map.get(ordering, 'departure_time'))[:100]
    schedules = list(qs)

    # One aggregate query for every operator's rating instead of N+1.
    op_ids = {s.bus.operator_id for s in schedules}
    rating_stats = {
        row['operator']: (row['avg'], row['n'])
        for row in Rating.objects.filter(operator_id__in=op_ids)
                                 .values('operator').annotate(avg=Avg('stars'), n=Count('id'))
    }

    results = []
    for s in schedules:
        avg, n = rating_stats.get(s.bus.operator_id, (None, 0))
        results.append(trip_dict(s, rating_avg=round(avg, 1) if avg else None, rating_count=n))

    return ok(results, count=len(results))


@api_view(['GET'])
def trip_detail(request, schedule_id):
    s = _bookable_schedules().filter(id=schedule_id).first()
    if s is None:
        return err('Trip not found or not currently bookable.', status=404)
    avg = Rating.objects.filter(operator=s.bus.operator).aggregate(a=Avg('stars'), n=Count('id'))
    return ok(trip_dict(s, rating_avg=round(avg['a'], 1) if avg['a'] else None, rating_count=avg['n']))


# ---------------------------------------------------------------------------
# Seat map (FR-10/FR-11) - status is public+personalized, lock/release need auth
# ---------------------------------------------------------------------------

@api_view(['GET'])
def seat_map(request, schedule_id):
    schedule = Schedule.objects.select_related('bus').filter(id=schedule_id).first()
    if schedule is None:
        return err('Trip not found.', status=404)
    bus = schedule.bus
    if not bus.has_seat_map:
        return ok({'has_seat_map': False, 'fare': float(schedule.fare),
                  'total_seats': bus.total_seats,
                  'seats_left': max(0, bus.total_seats - seats_taken(schedule))})

    user = _maybe_user(request)
    now = timezone.now()
    SeatLock.objects.filter(schedule=schedule, expires_at__lte=now).delete()

    booked_ids = set(BookingSeat.objects.filter(
        schedule=schedule, booking__booking_status__in=ACTIVE_BOOKING_STATES
    ).values_list('seat_id', flat=True))
    locks = {l.seat_id: l for l in SeatLock.objects.filter(schedule=schedule)}

    seats = []
    for seat in bus.seats.all():
        if not seat.is_active:
            status = 'BLOCKED'
        elif seat.id in booked_ids:
            status = 'BOOKED'
        elif seat.id in locks:
            status = 'LOCKED_BY_ME' if user and locks[seat.id].locked_by_id == user.id else 'LOCKED'
        else:
            status = 'AVAILABLE'
        seats.append({'seat_number': seat.seat_number, 'deck': seat.deck, 'row': seat.row_number,
                      'col': seat.col_number, 'seat_type': seat.seat_type, 'status': status})

    return ok({
        'has_seat_map': True, 'rows': bus.seat_rows, 'cols': bus.seat_cols,
        'deck_count': bus.deck_count, 'fare': float(schedule.fare), 'seats': seats,
        'lock_minutes': settings.SEAT_LOCK_MINUTES,
    })


@api_view(['POST'])
@jwt_required('PASSENGER')
def seat_map_lock(request, schedule_id):
    schedule = Schedule.objects.select_related('bus').filter(id=schedule_id, status='ACTIVE').first()
    if schedule is None:
        return err('Trip not available.', status=404)
    if schedule.departure_time <= timezone.now():
        return err('This trip has already departed.', status=409)

    bus = schedule.bus
    if not bus.has_seat_map:
        return err('This trip does not use seat-level selection.', status=409)

    wanted = [str(x) for x in (body(request).get('seat_numbers') or [])][:6]  # BR-4: max 6/booking
    user = request.api_user
    now = timezone.now()
    expires_at = now + timedelta(minutes=settings.SEAT_LOCK_MINUTES)

    dropped_qs = SeatLock.objects.filter(schedule=schedule, locked_by=user).exclude(seat__seat_number__in=wanted)
    dropped = list(dropped_qs.values_list('seat__seat_number', flat=True))
    dropped_qs.delete()
    if dropped:
        broadcast_seat_update(schedule.id, dropped, 'AVAILABLE')

    granted, rejected = [], []
    with transaction.atomic():
        for seat_number in wanted:
            seat = BusSeat.objects.select_for_update().filter(bus=bus, seat_number=seat_number).first()
            if seat is None or not seat.is_active:
                rejected.append({'seat_number': seat_number, 'reason': 'Not a valid seat.'})
                continue
            SeatLock.objects.filter(schedule=schedule, seat=seat, expires_at__lte=now).delete()
            already_booked = BookingSeat.objects.filter(
                schedule=schedule, seat=seat, booking__booking_status__in=ACTIVE_BOOKING_STATES
            ).exists()
            if already_booked:
                rejected.append({'seat_number': seat_number, 'reason': 'Already booked.'})
                continue
            existing = SeatLock.objects.filter(schedule=schedule, seat=seat).first()
            if existing and existing.locked_by_id != user.id:
                rejected.append({'seat_number': seat_number, 'reason': 'Currently held by another passenger.'})
                continue
            SeatLock.objects.update_or_create(
                schedule=schedule, seat=seat, defaults={'locked_by': user, 'expires_at': expires_at},
            )
            granted.append(seat_number)

    if granted:
        broadcast_seat_update(schedule.id, granted, 'LOCKED')
    return ok({'granted': granted, 'rejected': rejected,
              'expires_at': expires_at.isoformat()})


@api_view(['POST'])
@jwt_required('PASSENGER')
def seat_map_release(request, schedule_id):
    mine = SeatLock.objects.filter(schedule_id=schedule_id, locked_by=request.api_user)
    released = list(mine.values_list('seat__seat_number', flat=True))
    n, _ = mine.delete()
    if released:
        broadcast_seat_update(schedule_id, released, 'AVAILABLE')
    return ok({'released': n})


# ---------------------------------------------------------------------------
# Bookings (FR-12/FR-13/FR-16) + checkout (FR-14)
# ---------------------------------------------------------------------------

@api_view(['POST'])
@jwt_required('PASSENGER')
def create_booking(request):
    expire_stale_bookings()
    user = request.api_user
    data = body(request)
    schedule = Schedule.objects.select_related('bus', 'route').filter(
        id=data.get('schedule_id'), status='ACTIVE'
    ).first()
    if schedule is None:
        return err('That trip is not available.', status=404)
    if schedule.bus.approval_status != 'APPROVED':
        return err('That bus is not approved yet.', status=409)

    while True:
        code = f"YB{timezone.now():%y%m%d}{secrets.randbelow(1000000):06d}"
        if not Booking.objects.filter(booking_code=code).exists():
            break

    if schedule.bus.has_seat_map:
        seat_numbers = [str(x) for x in (data.get('seat_numbers') or [])]
        if not seat_numbers:
            return err('Please select at least 1 seat.')
        now = timezone.now()
        held_locks = list(SeatLock.objects.select_related('seat').filter(
            schedule=schedule, locked_by=user, expires_at__gt=now,
            seat__seat_number__in=seat_numbers,
        ))
        if len(held_locks) != len(set(seat_numbers)):
            return err('Your seat selection expired - please select your seats again.', status=409)

        seats = len(held_locks)
        total = (schedule.fare * seats).quantize(Decimal('0.01'))
        commission = (total * COMMISSION_RATE / 100).quantize(Decimal('0.01'))
        passengers = {str(p.get('seat_number')): p for p in (data.get('passengers') or [])}

        with transaction.atomic():
            booking = Booking.objects.create(
                booking_code=code, user=user, schedule=schedule, seats_count=seats,
                total_amount=total, commission_rate=COMMISSION_RATE,
                commission_amount=commission, operator_earning=total - commission,
                booking_status='PENDING_PAYMENT',
                payment_deadline=timezone.now() + timedelta(minutes=PAYMENT_WINDOW_MINUTES),
            )
            rows = []
            for lock in held_locks:
                p = passengers.get(lock.seat.seat_number, {})
                rows.append(BookingSeat(
                    booking=booking, schedule=schedule, seat=lock.seat,
                    passenger_name=str(p.get('name', ''))[:150],
                    passenger_age=p.get('age') if str(p.get('age', '')).isdigit() else None,
                    passenger_gender=p.get('gender') if p.get('gender') in ('M', 'F', 'O') else None,
                    boarding_point=str(p.get('boarding_point', ''))[:150],
                    dropping_point=str(p.get('dropping_point', ''))[:150],
                ))
            BookingSeat.objects.bulk_create(rows)
            SeatLock.objects.filter(id__in=[l.id for l in held_locks]).delete()

        broadcast_seat_update(schedule.id, seat_numbers, 'BOOKED')
        log_activity(user, 'PASSENGER', 'BOOKING_CREATED',
                    f'{code} - {seats} seat(s) ({", ".join(sorted(seat_numbers))}) on '
                    f'{schedule.route.source_city.city_name} → {schedule.route.destination_city.city_name}',
                    amount=total)
    else:
        seats = int(data.get('seats', 1) or 1)
        if seats < 1:
            return err('Please choose at least 1 seat.')
        taken = seats_taken(schedule)
        available = schedule.bus.total_seats - taken
        if seats > available:
            return err(f'Only {available} seat(s) left on this trip.', status=409)
        total = (schedule.fare * seats).quantize(Decimal('0.01'))
        commission = (total * COMMISSION_RATE / 100).quantize(Decimal('0.01'))
        booking = Booking.objects.create(
            booking_code=code, user=user, schedule=schedule, seats_count=seats,
            total_amount=total, commission_rate=COMMISSION_RATE,
            commission_amount=commission, operator_earning=total - commission,
            booking_status='PENDING_PAYMENT',
            payment_deadline=timezone.now() + timedelta(minutes=PAYMENT_WINDOW_MINUTES),
        )
        log_activity(user, 'PASSENGER', 'BOOKING_CREATED',
                    f'{code} - {seats} seat(s) on '
                    f'{schedule.route.source_city.city_name} → {schedule.route.destination_city.city_name}',
                    amount=total)

    return ok(passenger_booking_dict(booking), status=201)


@api_view(['GET'])
@jwt_required('PASSENGER')
def my_bookings(request):
    expire_stale_bookings()
    qs = Booking.objects.select_related(
        'schedule__bus__operator', 'schedule__route__source_city', 'schedule__route__destination_city'
    ).filter(user=request.api_user).prefetch_related('booking_seats__seat').order_by('-booked_at')[:100]
    return ok([passenger_booking_dict(b) for b in qs])


@api_view(['GET'])
@jwt_required('PASSENGER')
def booking_detail(request, booking_id):
    b = Booking.objects.select_related(
        'schedule__bus__operator', 'schedule__route__source_city', 'schedule__route__destination_city'
    ).filter(id=booking_id, user=request.api_user).first()
    if b is None:
        return err('Booking not found.', status=404)
    return ok(passenger_booking_dict(b))


@api_view(['POST'])
@jwt_required('PASSENGER')
def checkout(request, booking_id):
    expire_stale_bookings()
    booking = Booking.objects.select_related('schedule__bus').filter(
        id=booking_id, user=request.api_user
    ).first()
    if booking is None:
        return err('Booking not found.', status=404)
    if booking.booking_status == 'CONFIRMED':
        return err(f'Booking {booking.booking_code} is already paid and confirmed.', status=409)
    if booking.booking_status != 'PENDING_PAYMENT':
        return err(f'This booking is {booking.booking_status} and cannot be paid.', status=409)

    remaining = int((booking.payment_deadline - timezone.now()).total_seconds())
    payment = Payment.objects.create(
        booking=booking, gateway='ESEWA', transaction_uuid=_next_transaction_uuid(booking),
        amount=booking.total_amount, status='INITIATED',
    )
    form_fields = build_esewa_form(
        payment,
        success_url=request.build_absolute_uri('/api/payments/passenger/esewa/success'),
        failure_url=request.build_absolute_uri('/api/payments/passenger/esewa/failure'),
    )
    return ok({'form_fields': form_fields, 'esewa_url': esewa_form_url(),
              'remaining_seconds': max(remaining, 0), 'booking': passenger_booking_dict(booking)})


def esewa_success(request):
    """eSewa redirects the browser here (GET, no auth header possible) -
    verify, confirm the booking, then bounce to the passenger client."""
    data_b64 = request.GET.get('data', '')
    decoded = decode_callback(data_b64)
    payment = None
    if decoded:
        payment = Payment.objects.select_related('booking__schedule__bus').filter(
            transaction_uuid=decoded.get('transaction_uuid')).first()

    sig_ok = bool(decoded) and verify_callback_signature(decoded)
    PaymentWebhookLog.objects.create(
        payment=payment, transaction_uuid=(decoded or {}).get('transaction_uuid', ''),
        raw_payload=data_b64 or '(empty)', signature_valid=sig_ok,
    )

    if payment is None:
        return _client_redirect('payment-result.html', success='false', reason='not_matched')

    if payment.status == 'COMPLETE':
        return _client_redirect('payment-result.html', success='true', booking_id=payment.booking_id)

    status = (decoded or {}).get('status', '')
    if sig_ok and status == 'COMPLETE':
        _confirm_payment(payment, (decoded or {}).get('transaction_code'), data_b64)
        return _client_redirect('payment-result.html', success='true', booking_id=payment.booking_id)

    enquiry = check_status(payment)
    if enquiry:
        PaymentWebhookLog.objects.create(
            payment=payment, transaction_uuid=payment.transaction_uuid,
            raw_payload=f'status-check: {enquiry}', signature_valid=True,
        )
    if enquiry and enquiry.get('status') == 'COMPLETE':
        _confirm_payment(payment, enquiry.get('ref_id'), str(enquiry))
        return _client_redirect('payment-result.html', success='true', booking_id=payment.booking_id)

    payment.status = 'AMBIGUOUS' if enquiry is None else enquiry.get('status', 'FAILED')
    if payment.status not in dict(Payment.STATUS_CHOICES):
        payment.status = 'FAILED'
    payment.raw_response = data_b64
    payment.save(update_fields=['status', 'raw_response'])
    return _client_redirect('payment-result.html', success='false', reason='unverified',
                            booking_id=payment.booking_id)


def esewa_failure(request):
    data_b64 = request.GET.get('data', '')
    decoded = decode_callback(data_b64)
    payment = None
    if decoded and decoded.get('transaction_uuid'):
        payment = Payment.objects.filter(transaction_uuid=decoded['transaction_uuid']).first()
    PaymentWebhookLog.objects.create(
        payment=payment, transaction_uuid=(decoded or {}).get('transaction_uuid', ''),
        raw_payload=data_b64 or '(no data param)',
        signature_valid=bool(decoded) and verify_callback_signature(decoded),
    )
    if payment and payment.status in ('INITIATED', 'PENDING'):
        payment.status = 'CANCELED'
        payment.raw_response = data_b64
        payment.save(update_fields=['status', 'raw_response'])
    return _client_redirect('payment-result.html', success='false', reason='cancelled',
                            booking_id=payment.booking_id if payment else '')


@api_view(['GET'])
@jwt_required('PASSENGER')
def cancel_quote(request, booking_id):
    booking = Booking.objects.select_related('schedule').filter(
        id=booking_id, user=request.api_user).first()
    if booking is None:
        return err('Booking not found.', status=404)
    if booking.booking_status == 'PENDING_PAYMENT':
        return ok({'refund': 0, 'fee': 0, 'tier': 'not yet paid - full hold released, nothing charged'})
    if booking.booking_status in ('CANCELLED', 'REFUNDED', 'EXPIRED'):
        return err('This booking is already cancelled.', status=409)
    refund, fee, tier = cancellation_quote(booking)
    return ok({'refund': float(refund), 'fee': float(fee), 'tier': tier})


@api_view(['POST'])
@jwt_required('PASSENGER')
def cancel_booking(request, booking_id):
    user = request.api_user
    booking = Booking.objects.select_related('schedule__bus__operator').filter(
        id=booking_id, user=user).first()
    if booking is None:
        return err('Booking not found.', status=404)
    if booking.booking_status in ('CANCELLED', 'REFUNDED', 'EXPIRED'):
        return err('This booking is already cancelled.', status=409)
    if booking.schedule.departure_time <= timezone.now():
        return err('This trip has already departed and cannot be cancelled.', status=409)

    if booking.booking_status == 'PENDING_PAYMENT':
        booking.booking_status = 'CANCELLED'
        booking.cancel_reason = 'Cancelled by passenger before payment'
        booking.cancelled_by = 'PASSENGER'
        booking.cancelled_at = timezone.now()
        booking.save(update_fields=['booking_status', 'cancel_reason', 'cancelled_by', 'cancelled_at'])
        Payment.objects.filter(booking=booking, status__in=['INITIATED', 'PENDING']).update(status='CANCELED')
        seat_numbers = list(booking.booking_seats.values_list('seat__seat_number', flat=True))
        if seat_numbers:
            broadcast_seat_update(booking.schedule_id, seat_numbers, 'AVAILABLE')
        log_activity(user, 'PASSENGER', 'BOOKING_CANCELLED', f'{booking.booking_code} - cancelled before payment')
        return ok({'refund': 0, 'fee': 0, 'tier': 'not yet paid', 'status': booking.booking_status})

    refund, fee, tier = cancellation_quote(booking)
    booking.booking_status = 'REFUNDED' if refund > 0 else 'CANCELLED'
    booking.cancel_reason = f'Cancelled by passenger - {tier}'
    booking.cancelled_by = 'PASSENGER'
    booking.cancellation_fee_amount = fee
    booking.cancelled_at = timezone.now()
    booking.save(update_fields=['booking_status', 'cancel_reason', 'cancelled_by',
                                'cancellation_fee_amount', 'cancelled_at'])
    if refund > 0:
        new_status = 'FULL_REFUND' if fee == 0 else 'PARTIAL_REFUND'
        Payment.objects.filter(booking=booking, status='COMPLETE').update(status=new_status)
    seat_numbers = list(booking.booking_seats.values_list('seat__seat_number', flat=True))
    if seat_numbers:
        broadcast_seat_update(booking.schedule_id, seat_numbers, 'AVAILABLE')
    from testing_app.realtime import notify_operator
    notify_operator(booking.schedule.bus.operator_id, 'booking_cancelled',
                    {'booking_code': booking.booking_code, 'refund': float(refund)})
    log_activity(user, 'PASSENGER', 'BOOKING_CANCELLED',
                f'{booking.booking_code} - {tier} - refund Rs.{refund}', amount=refund)
    return ok({'refund': float(refund), 'fee': float(fee), 'tier': tier, 'status': booking.booking_status})


@api_view(['GET'])
@jwt_required('PASSENGER')
def ticket_pdf(request, booking_id):
    import io
    import qrcode
    from reportlab.lib.pagesizes import A5
    from reportlab.lib.units import mm
    from reportlab.lib.utils import ImageReader
    from reportlab.pdfgen import canvas

    user = request.api_user
    b = Booking.objects.select_related(
        'schedule__bus__operator', 'schedule__route__source_city',
        'schedule__route__destination_city').filter(id=booking_id, user=user).first()
    if b is None or b.booking_status not in ('CONFIRMED', 'CHECKED_IN', 'COMPLETED'):
        return err('E-tickets are available for confirmed bookings only.', status=409)

    token = signing.dumps({'t': 'ticket', 'b': b.id, 'c': b.booking_code})
    qr_img = qrcode.make(token, box_size=6, border=2)
    qr_buf = io.BytesIO(); qr_img.save(qr_buf, format='PNG'); qr_buf.seek(0)

    buf = io.BytesIO()
    w, h = A5[1], A5[0]
    c = canvas.Canvas(buf, pagesize=(w, h))
    c.setFillColorRGB(0.05, 0.16, 0.4)
    c.rect(0, h - 26 * mm, w, 26 * mm, stroke=0, fill=1)
    c.setFillColorRGB(1, 1, 1)
    c.setFont('Helvetica-Bold', 16)
    c.drawString(12 * mm, h - 15 * mm, 'YATRA BOOKING · E-TICKET')
    c.setFont('Helvetica', 9)
    c.drawString(12 * mm, h - 21 * mm, b.schedule.bus.operator.company_name)

    y = h - 38 * mm
    rows = [
        ('Booking code', b.booking_code),
        ('Passenger', user.full_name),
        ('Trip', f"{b.schedule.route.source_city.city_name}  →  {b.schedule.route.destination_city.city_name}"),
        ('Bus', f"{b.schedule.bus.bus_name or ''} ({b.schedule.bus.registration_no or ''})"),
        ('Departure', timezone.localtime(b.schedule.departure_time).strftime('%a, %d %b %Y · %I:%M %p')),
        ('Seats', ', '.join(sorted(bs.seat.seat_number for bs in b.booking_seats.select_related('seat')))
                  or f'{b.seats_count} seat(s)'),
        ('Paid', f'Rs. {b.total_amount}'),
        ('Status', b.booking_status),
    ]
    for label, value in rows:
        c.setFont('Helvetica', 8.5); c.setFillColorRGB(0.45, 0.48, 0.55)
        c.drawString(12 * mm, y, label.upper())
        c.setFont('Helvetica-Bold', 11); c.setFillColorRGB(0.12, 0.16, 0.22)
        c.drawString(45 * mm, y, str(value))
        y -= 9 * mm

    c.drawImage(ImageReader(qr_buf), w - 52 * mm, h - 82 * mm, 40 * mm, 40 * mm)
    c.setFont('Helvetica', 7); c.setFillColorRGB(0.45, 0.48, 0.55)
    c.drawCentredString(w - 32 * mm, h - 86 * mm, 'Scan to verify · cryptographically signed')
    c.showPage(); c.save()
    buf.seek(0)
    resp = HttpResponse(buf.read(), content_type='application/pdf')
    resp['Content-Disposition'] = f'attachment; filename="yatra-{b.booking_code}.pdf"'
    return resp


@api_view(['POST'])
@jwt_required('PASSENGER')
def rate_booking(request, booking_id):
    user = request.api_user
    data = body(request)
    booking = Booking.objects.select_related('schedule__bus__operator').filter(
        id=booking_id, user=user).first()
    if booking is None:
        return err('Booking not found.', status=404)
    if booking.booking_status not in ('CONFIRMED', 'CHECKED_IN', 'COMPLETED'):
        return err('Only paid bookings can be rated.', status=409)
    if booking.schedule.arrival_time > timezone.now():
        return err('You can rate this trip after it arrives.', status=409)
    if hasattr(booking, 'rating'):
        return err('You already rated this trip.', status=409)

    stars = data.get('stars')
    try:
        stars = int(stars)
    except (TypeError, ValueError):
        stars = 0
    if not (1 <= stars <= 5):
        return err('Pick a rating from 1 to 5 stars.')

    issue_flag = bool(data.get('issue_flag'))
    Rating.objects.create(
        booking=booking, user=user, operator=booking.schedule.bus.operator, stars=stars,
        review=str(data.get('review', '')).strip()[:1000],
        issue_flag=issue_flag,
        issue_note=str(data.get('issue_note', '')).strip()[:1000] if issue_flag else '',
    )
    log_activity(user, 'PASSENGER', 'RATING_SUBMITTED',
                f'{stars}★ on {booking.booking_code}' + (' (issue reported)' if issue_flag else ''))
    return ok({'stars': stars, 'issue_flag': issue_flag}, status=201)


# ---------------------------------------------------------------------------
# Profile (FR-7)
# ---------------------------------------------------------------------------

@api_view(['GET'])
@jwt_required('PASSENGER')
def profile(request):
    u = request.api_user
    return ok({
        'full_name': u.full_name, 'email': u.email, 'phone': u.phone,
        'email_verified': u.email_verified,
        'avatar_url': request.build_absolute_uri(u.avatar.url) if u.avatar else '',
        'avatar_color': u.avatar_color,
        'member_since': u.created_at.strftime('%Y-%m-%d') if u.created_at else None,
    })


@api_view(['PUT'])
@jwt_required('PASSENGER')
def update_profile(request):
    u = request.api_user
    data = body(request)
    full_name = str(data.get('full_name', '')).strip()
    phone = str(data.get('phone', '') or '').strip()

    if not full_name:
        return err('Name cannot be empty.')
    if phone and not (phone.isdigit() and len(phone) == 10 and phone[:2] in ('97', '98')):
        return err('Phone number must be a 10-digit Nepali mobile number starting 97 or 98.')
    if phone and User.objects.filter(phone=phone).exclude(id=u.id).exists():
        return err('This phone number is already registered to another account.', status=409)

    u.full_name = full_name
    u.phone = phone or None
    u.save(update_fields=['full_name', 'phone'])
    return ok({'full_name': u.full_name, 'phone': u.phone})


@api_view(['POST'])
@jwt_required('PASSENGER')
def change_password(request):
    u = request.api_user
    data = body(request)
    current = str(data.get('current_password', ''))
    new = str(data.get('new_password', ''))

    if not verify_password(u, current):
        return err('Current password is incorrect.', status=401)
    if len(new) < 8 or not any(c.isdigit() for c in new):
        return err('New password must be at least 8 characters and include a number.')

    u.password_hash = make_password(new)
    u.token_version += 1  # invalidate other sessions
    u.save(update_fields=['password_hash', 'token_version'])
    tokens = issue_tokens(u, 'PASSENGER')  # keep THIS session alive with a fresh pair
    return ok({**tokens, 'changed': True})


@api_view(['POST'])
@jwt_required('PASSENGER')
def upload_avatar(request):
    f = request.FILES.get('avatar')
    if not f:
        return err('No file uploaded.')
    if f.size > 4 * 1024 * 1024:
        return err('Image must be under 4MB.')
    u = request.api_user
    u.avatar = f
    u.save(update_fields=['avatar'])
    return ok({'avatar_url': request.build_absolute_uri(u.avatar.url)})