import json
import random
import urllib.request
from functools import wraps
from decimal import Decimal, InvalidOperation
from django.conf import settings
from django.views.decorators.http import require_POST
from django.contrib.auth.decorators import login_required
from django.http import HttpResponseBadRequest
from django.middleware.csrf import get_token
from django.utils import translation, timezone

from django.contrib.auth import authenticate, login, logout, update_session_auth_hash
from django.contrib.auth.models import User
from django.contrib.auth.password_validation import validate_password
from django.contrib.admin.models import LogEntry, CHANGE, ADDITION
from django.core.exceptions import ValidationError
from django.shortcuts import render, redirect
from django.http import JsonResponse

from .models import Profile, Agent, Transaction, WalletBinding, WithdrawalRequest, MovieRating, TicketBooking, PremiumTicket, DepositAddress, DepositRequest, SetApprovalRequest, FeaturedVideo
from . import services


def index(request):
    if request.user.is_authenticated:
        return redirect('dashboard')
    return render(request, 'main/index.html')


def login_view(request):
    if request.method == 'POST':
        # The account username is the full international phone number, built the
        # same way as at registration (country code + national number). A raw
        # ``username`` is still accepted for backwards compatibility.
        national = (request.POST.get('phone') or '').strip()
        country_code = (request.POST.get('country_code') or '').strip()
        username = (request.POST.get('username') or '').strip()
        if not username:
            username = _full_phone(country_code, national)
        password = request.POST.get('password')
        user = authenticate(request, username=username, password=password)
        if user is not None:
            login(request, user)
            return redirect('dashboard')

        # authenticate() returns None for inactive users too. Distinguish a
        # correct-but-blocked login (frozen/banned/awaiting approval) from
        # genuinely bad credentials.
        existing = User.objects.filter(username=username).first()
        if existing is not None and existing.check_password(password):
            status = getattr(getattr(existing, 'profile', None), 'status', Profile.ACTIVE)
            if status == Profile.BANNED:
                error = 'This account has been banned. Please contact support.'
            elif status == Profile.FROZEN:
                error = 'This account is frozen. Please contact support.'
            elif not existing.is_active:
                error = 'Your account is awaiting admin approval.'
            else:
                error = 'Invalid username or password.'
        else:
            error = 'Invalid username or password.'
        return render(request, 'main/login.html', {'error': error, 'phone': national})

    return render(request, 'main/login.html')


def _new_captcha():
    """A short alphanumeric graphic-captcha code (stub for a real image captcha)."""
    alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
    return ''.join(random.choice(alphabet) for _ in range(4))


def _resolve_agent(code):
    """Resolve a referral code to the Agent a new user should be grouped under.

    Accepts an agent's own referral code, or an existing user's referral code
    (in which case the new user inherits that referrer's agent). Returns the
    Agent, or ``None`` if the code matches nothing usable.
    """
    code = (code or '').strip().upper()
    if not code:
        return None
    agent = Agent.objects.filter(referral_code=code, is_active=True).first()
    if agent is not None:
        return agent
    referrer = Profile.objects.filter(referral_code=code).select_related('agent').first()
    if referrer is not None and referrer.agent is not None:
        return referrer.agent
    return None


def _full_phone(country_code, national):
    """Combine a dial code (e.g. '+33') and a national number into a single
    international number used as the account username. Strips a leading 0 from
    the national part and any duplicate leading code the user may have typed."""
    code = (country_code or '').strip()
    digits = ''.join(ch for ch in (national or '') if ch.isdigit())
    digits = digits.lstrip('0')
    if not code:
        return ('+' + digits) if digits else digits
    code_digits = ''.join(ch for ch in code if ch.isdigit())
    # Avoid doubling the code if the user typed it into the number field too.
    if code_digits and digits.startswith(code_digits):
        digits = digits[len(code_digits):]
    return code + digits


def register(request):
    if request.user.is_authenticated:
        return redirect('dashboard')

    if request.method == 'POST':
        national = (request.POST.get('phone') or '').strip()
        country_code = (request.POST.get('country_code') or '').strip()
        phone = _full_phone(country_code, national)
        captcha = (request.POST.get('captcha') or '').strip().upper()
        sms_code = (request.POST.get('sms_code') or '').strip()
        password = request.POST.get('password') or ''
        confirm = request.POST.get('confirm_password') or ''
        invitation = (request.POST.get('invitation_code') or '').strip()

        # A valid referral code (an agent's, or another user's) is required so
        # every account is grouped under an agent for management.
        agent = _resolve_agent(invitation)

        error = None
        if not phone:
            error = 'Please enter your phone number.'
        elif captcha != (request.session.get('captcha') or ''):
            error = 'The graphic verification code is incorrect.'
        elif not sms_code or sms_code != (request.session.get('sms_code') or ''):
            error = 'The 6-digit verification code is incorrect.'
        elif not invitation:
            error = 'A referral code is required to register.'
        elif agent is None:
            error = 'That referral code is invalid. Please check and try again.'
        elif password != confirm:
            error = 'Password and confirmation do not match.'
        elif User.objects.filter(username=phone).exists():
            error = 'An account with this phone number already exists.'
        else:
            try:
                validate_password(password)
            except ValidationError as exc:
                error = ' '.join(exc.messages)

        if error:
            # Issue a fresh captcha so the displayed code matches the next attempt.
            request.session['captcha'] = _new_captcha()
            context = {
                'error': error,
                'captcha': request.session['captcha'],
                'phone': national,
                'invitation_code': invitation,
            }
            return render(request, 'main/register.html', context)

        # Frozen account: cannot log in until an admin approves (is_active=True).
        # The user is grouped under the resolved agent; uid + referral_code are
        # generated automatically in Profile.save().
        user = User.objects.create_user(username=phone, password=password, is_active=False)
        Profile.objects.create(user=user, phone=phone, invitation_code=invitation, agent=agent)
        # Welcome bonus: every new user is credited a one-time bonus that funds
        # their first trial set of tickets. It is clawed back once that set is
        # complete (see api_rate_movie), leaving only the commission they earned.
        Transaction.objects.create(
            user=user,
            kind=Transaction.WELCOME_BONUS,
            amount=services.WELCOME_BONUS,
            created_at=timezone.now(),
        )
        # Clear one-time codes.
        request.session.pop('captcha', None)
        request.session.pop('sms_code', None)
        return render(request, 'main/register.html', {'success': True})

    # GET — issue a captcha code for display and prefill any referral code from
    # the agent's shared link (/register/?ref=<code>).
    request.session['captcha'] = _new_captcha()
    return render(request, 'main/register.html', {
        'captcha': request.session['captcha'],
        'invitation_code': (request.GET.get('ref') or '').strip(),
    })


@require_POST
def api_send_code(request):
    """Generate a 6-digit verification code (stub for SMS).

    In DEBUG the code is returned in the response so the flow can be completed
    without a real SMS gateway; in production it would be sent to the phone.
    """
    code = '{:06d}'.format(random.randint(0, 999999))
    request.session['sms_code'] = code
    return JsonResponse({'success': True, 'code': code})


def logout_view(request):
    logout(request)
    return redirect('home')


def dashboard(request):
    if not request.user.is_authenticated:
        return redirect('login')
    # Admin-managed "now playing" movie for the dashboard hero. When set it
    # overrides the live-listings preview (see dashboard.html). Falls back to
    # the listings API when no active FeaturedVideo exists.
    featured = FeaturedVideo.objects.filter(is_active=True).first()
    featured_data = None
    if featured and featured.play_url:
        featured_data = {
            'id': 'featured',
            'title': featured.title,
            'trailer': featured.play_url,
            'image': featured.poster_url,
            'is_youtube': featured.is_youtube,
            'status': 'Now Playing',
            'premiered': '',
            'summary': '',
        }
    return render(request, 'main/dashboard.html', {'featured': featured_data})


def task(request):
    if not request.user.is_authenticated:
        return redirect('login')
    ticketing_count = services.completed_tickets(request.user)
    processing_count = TicketBooking.objects.filter(
        user=request.user, status=TicketBooking.PROCESSING).count()
    # An admin-set special ticket (premium / golden egg / special), when pending,
    # is the only ticket the user can book next and shows a notice + bonus commission.
    special = services.pending_special_ticket(request.user)
    return render(request, 'main/task.html', {
        'ticketing_count': ticketing_count,
        'progress': services.ticketing_progress(request.user),
        'special_pending': special is not None,
        'special_kind': special.kind if special else '',
        'special_price': services.format_eur(special.amount) if special else '',
        'special_commission': services.format_eur(special.commission) if special else '',
        'awaiting_approval': services.needs_set_approval(request.user),
        'wallet_summary': {
            'balance': services.format_eur(services.user_balance(request.user)),
            'tickets': ticketing_count,
            'bonus': services.format_eur(services.wallet_bonus(request.user)),
            'processing': services.format_eur(services.processing_total(request.user)),
            'processing_count': processing_count,
        },
    })


def orders(request):
    if not request.user.is_authenticated:
        return redirect('login')
    
    # Fetch actual ticket bookings from database
    user_orders = TicketBooking.objects.filter(user=request.user).order_by('-created_at')
    
    # For now, let's assume all bookings in DB are 'completed' since they are 
    # created upon successful rating. If there were a multi-step process, 
    # we'd have a status field in TicketBooking.
    
    return render(request, 'main/orders.html', {'orders': user_orders})


def description(request):
    if not request.user.is_authenticated:
        return redirect('login')
    return render(request, 'main/description.html')


def wallet(request):
    if not request.user.is_authenticated:
        return redirect('login')
    # Show the per-set ticket count (max 32, restarts each set / after an admin
    # reset) rather than the cumulative lifetime count.
    ticketing_count = services.tickets_in_set(request.user)
    processing_count = TicketBooking.objects.filter(
        user=request.user, status=TicketBooking.PROCESSING).count()
    context = {
        'balance': services.format_eur(services.user_balance(request.user)),
        'earnings': services.format_eur(services.total_earnings(request.user)),
        'ticketing_count': ticketing_count,
        'tickets_per_set': services.TICKETS_PER_SET,
        'tickets_set_remaining': max(0, services.TICKETS_PER_SET - ticketing_count),
        'processing_count': processing_count,
        'processing': services.format_eur(services.processing_total(request.user)),
        'progress': services.ticketing_progress(request.user),
        'welcome_bonus_active': services.welcome_bonus_active(request.user),
        'welcome_bonus': services.format_eur(services.wallet_bonus(request.user)),
    }
    return render(request, 'main/wallet.html', context)


def event(request):
    if not request.user.is_authenticated:
        return redirect('login')
    return render(request, 'main/event.html')


# Suggested top-up amounts (in euros) and supported deposit methods.
DEPOSIT_QUICK_AMOUNTS = [50, 100, 200, 300, 500, 600, 800, 1000]
DEPOSIT_PAYMENT_METHODS = [
    {'id': 'eth', 'label': 'ETHEREUM ETH', 'icon': 'Ξ', 'color': '#627eea'},
    {'id': 'btc', 'label': 'BITCOIN BTC', 'icon': '₿', 'color': '#f7931a'},
    {'id': 'usdt_erc20', 'label': 'USDT ERC20', 'icon': '₮', 'color': '#26a17b'},
    {'id': 'usdt_trc20', 'label': 'USDT TRC20', 'icon': '₮', 'color': '#26a17b'},
    {'id': 'revolut', 'label': 'Revolut', 'icon': 'R', 'color': '#0a0a0a'},
]


def deposit(request):
    if not request.user.is_authenticated:
        return redirect('login')

    wallet = WalletBinding.objects.filter(user=request.user).first()

    if request.method == 'POST':
        amount = request.POST.get('amount', '').strip()
        method_id = request.POST.get('method')

        # Validate amount.
        try:
            amount_value = Decimal(amount)
        except (TypeError, ValueError, InvalidOperation):
            amount_value = Decimal('0')
        method = next((m for m in DEPOSIT_PAYMENT_METHODS if m['id'] == method_id), None)

        if amount_value < services.MIN_TICKET_BALANCE or method is None:
            context = {
                'balance': services.format_eur(services.user_balance(request.user)),
                'quick_amounts': DEPOSIT_QUICK_AMOUNTS,
                'payment_methods': DEPOSIT_PAYMENT_METHODS,
                'amount': amount,
                'wallet': wallet,
                'error': 'The minimum deposit is {}. Please enter a valid amount '
                         'and select a deposit method.'.format(
                             services.format_eur(services.MIN_TICKET_BALANCE)),
            }
            return render(request, 'main/deposit.html', context)

        # Step 2 — show the Crypto Pay screen with the admin-managed receiving
        # address + QR for this method. Nothing is credited until an admin
        # verifies the uploaded payment proof.
        address = DepositAddress.objects.filter(method=method['id']).first()
        return render(request, 'main/crypto_pay.html', {
            'amount': '{:.2f}'.format(amount_value),
            'method': method,
            'address': address,
        })

    context = {
        'balance': services.format_eur(services.user_balance(request.user)),
        'quick_amounts': DEPOSIT_QUICK_AMOUNTS,
        'payment_methods': DEPOSIT_PAYMENT_METHODS,
        'wallet': wallet,
    }
    return render(request, 'main/deposit.html', context)


@login_required
@require_POST
def deposit_proof(request):
    """Receive the payment-proof screenshot and file a pending DepositRequest
    for admin verification. The balance is credited only on admin approval."""
    amount = (request.POST.get('amount') or '').strip()
    method_id = request.POST.get('method')
    proof = request.FILES.get('proof')

    try:
        amount_value = Decimal(amount)
    except (TypeError, ValueError, InvalidOperation):
        amount_value = Decimal('0')
    method = next((m for m in DEPOSIT_PAYMENT_METHODS if m['id'] == method_id), None)

    if amount_value < services.MIN_TICKET_BALANCE or method is None or proof is None:
        address = DepositAddress.objects.filter(method=method_id).first() if method else None
        error = ('The minimum deposit is {}.'.format(
                    services.format_eur(services.MIN_TICKET_BALANCE))
                 if amount_value < services.MIN_TICKET_BALANCE
                 else 'Please upload a payment proof screenshot to submit.')
        return render(request, 'main/crypto_pay.html', {
            'amount': amount,
            'method': method or {'id': method_id, 'label': method_id},
            'address': address,
            'error': error,
        })

    dr = DepositRequest.objects.create(
        user=request.user,
        method=method['id'],
        amount=amount_value,
        proof=proof,
        status=DepositRequest.PENDING,
    )
    # Surface the submission in the admin log so it can be reviewed/verified.
    LogEntry.objects.log_actions(
        user_id=request.user.pk,
        queryset=DepositRequest.objects.filter(pk=dr.pk),
        action_flag=ADDITION,
        change_message='Deposit proof submitted — awaiting verification.',
        single_object=True,
    )
    return render(request, 'main/deposit_submitted.html', {
        'amount': '{:.2f}'.format(amount_value),
        'method_label': method['label'],
    })


# Withdrawal display/business rules.
WITHDRAWAL_MIN = 20  # euros
WITHDRAWAL_SERVICE_CHARGE = 0  # flat fee (euros) for withdrawals under €20,000


def _withdrawal_methods(user):
    """The selectable payout methods, each annotated with the user's bound
    address for that network (empty string if not bound)."""
    bound = {w.network: w.address for w in WalletBinding.objects.filter(user=user)}
    icons = {
        'eth': ('Ξ', '#627eea'), 'btc': ('₿', '#f7931a'),
        'erc20': ('₮', '#26a17b'), 'trc20': ('₮', '#26a17b'),
        'iban': ('🏦', '#1e293b'), 'revolut': ('R', '#0b1220'),
    }
    methods = []
    for net, label in WalletBinding.NETWORK_CHOICES:
        icon, color = icons.get(net, ('•', '#334155'))
        methods.append({
            'id': net, 'label': label, 'icon': icon, 'color': color,
            'address': bound.get(net, ''),
        })
    return methods


def withdrawal(request):
    if not request.user.is_authenticated:
        return redirect('login')

    methods = _withdrawal_methods(request.user)
    progress = services.ticketing_progress(request.user)
    profile, _ = Profile.objects.get_or_create(user=request.user)
    has_pin = profile.has_withdrawal_pin

    def render_form(error=None, amount='', selected=''):
        return render(request, 'main/withdrawal.html', {
            'balance': services.format_eur(services.user_balance(request.user)),
            'methods': methods,
            'min_withdrawal': WITHDRAWAL_MIN,
            'service_charge': WITHDRAWAL_SERVICE_CHARGE,
            'amount': amount,
            'selected_method': selected,
            'error': error,
            'progress': progress,
            'has_pin': has_pin,
            'withdraw_locked': progress['withdraw_locked'],
            'withdraw_lock_reason': progress['withdraw_lock_reason'],
        })

    if request.method == 'POST':
        # Commission cannot be withdrawn until the required ticketing sets are
        # complete (REQUIRED_SETS sets of TICKETS_PER_SET rated movies).
        if not progress['can_withdraw']:
            if progress['withdraw_locked']:
                return render_form(
                    progress['withdraw_lock_reason'],
                    request.POST.get('amount', '').strip(),
                    request.POST.get('method'),
                )
            return render_form(
                'You must complete {sets} sets ({total} rated movies) before '
                'withdrawing commission. {remaining} more to go.'.format(
                    sets=progress['required_sets'],
                    total=progress['required_tickets'],
                    remaining=progress['remaining'],
                ),
                request.POST.get('amount', '').strip(),
                request.POST.get('method'),
            )

        amount = request.POST.get('amount', '').strip()
        method_id = request.POST.get('method')

        method = next((m for m in methods if m['id'] == method_id), None)
        try:
            amount_value = Decimal(amount)
        except (TypeError, ValueError, InvalidOperation):
            amount_value = Decimal('0')

        if method is None:
            return render_form('Please select a withdrawal method.', amount, method_id)
        if not method['address']:
            return render_form(
                'No wallet address is bound for this method. Please bind one first.',
                amount, method_id)
        if amount_value < WITHDRAWAL_MIN:
            return render_form(
                'The minimum withdrawal amount is €{}.'.format(WITHDRAWAL_MIN),
                amount, method_id)

        balance = services.user_balance(request.user)
        if amount_value > balance:
            return render_form(
                'Insufficient balance. Your current balance is {}.'.format(services.format_eur(balance)),
                amount, method_id)

        # A withdrawal PIN must be set and entered correctly to confirm.
        if not has_pin:
            return render_form(
                'Please set a 4-digit withdrawal PIN before withdrawing.',
                amount, method_id)
        if not profile.check_withdrawal_pin((request.POST.get('withdrawal_pin') or '').strip()):
            return render_form('Incorrect withdrawal PIN.', amount, method_id)

        # Record the request as pending and notify the admin so every
        # withdrawal can be reviewed and approved.
        wr = WithdrawalRequest.objects.create(
            user=request.user,
            method=method_id,
            address=method['address'],
            amount=amount_value,
            service_charge=WITHDRAWAL_SERVICE_CHARGE,
            status=WithdrawalRequest.PENDING,
        )
        LogEntry.objects.log_actions(
            user_id=request.user.pk,
            queryset=WithdrawalRequest.objects.filter(pk=wr.pk),
            action_flag=ADDITION,
            change_message='Withdrawal request submitted — awaiting approval.',
            single_object=True,
        )
        return render(request, 'main/withdrawal_success.html', {
            'amount': '{:.2f}'.format(float(amount_value)),
            'method_label': method['label'],     
            'address': method['address'],
            'pending': True,
        })

    return render_form()


# Bindable payout accounts shown on the bind-wallet screen. Each maps to a
# WalletBinding.network and carries display metadata (icon + colour).
WALLET_METHODS = [
    {'id': WalletBinding.ETH, 'label': 'ETHEREUM ETH', 'icon': 'Ξ', 'color': '#627eea'},
    {'id': WalletBinding.BTC, 'label': 'BITCOIN BTC', 'icon': '₿', 'color': '#f7931a'},
    {'id': WalletBinding.ERC20, 'label': 'USDT ERC20', 'icon': '₮', 'color': '#26a17b'},
    {'id': WalletBinding.TRC20, 'label': 'USDT TRC20', 'icon': '₮', 'color': '#26a17b'},
    {'id': WalletBinding.REVOLUT, 'label': 'ADD NEW REVOLUT', 'icon': 'R', 'color': '#0b1220'},
]


# Bank-style payout methods that collect full account details (name, number,
# contact, email, DOB, revtag) instead of a single crypto wallet address.
BANK_METHODS = {WalletBinding.IBAN, WalletBinding.REVOLUT}


def bind_wallet(request):
    if not request.user.is_authenticated:
        return redirect('login')

    valid_networks = dict(WalletBinding.NETWORK_CHOICES)

    if request.method == 'POST':
        network_id = request.POST.get('network')

        if network_id not in valid_networks:
            return redirect('bind_wallet')

        if network_id in BANK_METHODS:
            # Detailed bank/Revolut binding.
            details = {
                'account_name': request.POST.get('account_name', '').strip(),
                'account_number': request.POST.get('account_number', '').strip(),
                'contact_number': request.POST.get('contact_number', '').strip(),
                'email': request.POST.get('email', '').strip(),
                'date_of_birth': request.POST.get('date_of_birth', '').strip(),
                'revtag': request.POST.get('revtag', '').strip(),
            }
            # The primary payout identifier shown on the withdrawal screen:
            # the Revtag for Revolut, otherwise the IBAN/account number.
            address = (details['revtag'] if network_id == WalletBinding.REVOLUT
                       else details['account_number'])

            if not details['account_name'] or not address:
                return render(request, 'main/bind_wallet.html', {
                    'form_method': {'id': network_id, 'label': valid_networks[network_id]},
                    'is_bank': True,
                    'binding': details,
                    'error': 'Please fill in the required account information.',
                })

            WalletBinding.objects.update_or_create(
                user=request.user, network=network_id,
                defaults={'address': address, **details},
            )
            return redirect('bind_wallet')

        # Crypto network — single wallet address.
        address = request.POST.get('address', '').strip()
        if not address:
            return render(request, 'main/bind_wallet.html', {
                'form_method': {'id': network_id, 'label': valid_networks[network_id]},
                'address': address,
                'error': 'Please enter a valid wallet address.',
            })

        # Persist (or update) this network's binding for the user.
        WalletBinding.objects.update_or_create(
            user=request.user, network=network_id,
            defaults={'address': address},
        )
        return redirect('bind_wallet')

    # GET with ?network=… → show the entry form for that one method.
    network_id = request.GET.get('network')
    if network_id in valid_networks:
        existing = WalletBinding.objects.filter(user=request.user, network=network_id).first()
        if network_id in BANK_METHODS:
            return render(request, 'main/bind_wallet.html', {
                'form_method': {'id': network_id, 'label': valid_networks[network_id]},
                'is_bank': True,
                'binding': existing,
            })
        return render(request, 'main/bind_wallet.html', {
            'form_method': {'id': network_id, 'label': valid_networks[network_id]},
            'address': existing.address if existing else '',
        })

    # Default GET → the account list.
    bound = {w.network: w for w in WalletBinding.objects.filter(user=request.user)}
    methods = []
    for m in WALLET_METHODS:
        w = bound.get(m['id'])
        methods.append({**m, 'bound': w is not None, 'masked': w.masked_address if w else ''})
    return render(request, 'main/bind_wallet.html', {
        'methods': methods,
        'iban': bound.get(WalletBinding.IBAN),
    })


def _masked_phone(phone):
    """Mask the middle of an international number for display, e.g.
    +33784****1469 or +4477****1469. Keeps the leading country code (whatever
    it is) and the last 4 digits; works for any dial code, not just +33."""
    s = (phone or '').strip()
    digits = ''.join(ch for ch in s if ch.isdigit())
    if len(digits) < 7:
        return s
    prefix = '+' if s.startswith('+') else ''
    return '{}{}****{}'.format(prefix, digits[:5], digits[-4:])


def change_password(request):
    if not request.user.is_authenticated:
        return redirect('login')

    def render_form(error=None):
        # Issue a fresh captcha so the displayed code matches the next attempt.
        request.session['captcha'] = _new_captcha()
        return render(request, 'main/change_password.html', {
            'error': error,
            'captcha': request.session['captcha'],
            'masked_phone': _masked_phone(request.user.username),
        })

    if request.method == 'POST':
        captcha = (request.POST.get('captcha') or '').strip().upper()
        sms_code = (request.POST.get('sms_code') or '').strip()
        current = request.POST.get('current_password', '')
        new = request.POST.get('new_password', '')
        confirm = request.POST.get('confirm_password', '')

        error = None
        if captcha != (request.session.get('captcha') or ''):
            error = 'The graphic verification code is incorrect.'
        elif not sms_code or sms_code != (request.session.get('sms_code') or ''):
            error = 'The 6-digit verification code is incorrect.'
        elif not request.user.check_password(current):
            error = 'Your current password is incorrect.'
        elif new != confirm:
            error = 'New password and confirmation do not match.'
        else:
            try:
                validate_password(new, request.user)
            except ValidationError as exc:
                error = ' '.join(exc.messages)

        if error:
            return render_form(error)

        request.user.set_password(new)
        request.user.save(update_fields=['password'])
        # Record the change in the admin log so it shows up under "Recent actions"
        # and in each user's history (admin → Users → object → History).
        LogEntry.objects.log_actions(
            user_id=request.user.pk,
            queryset=User.objects.filter(pk=request.user.pk),
            action_flag=CHANGE,
            change_message='Changed login password (self-service).',
            single_object=True,
        )
        # Clear the one-time SMS code now that it has been used.
        request.session.pop('sms_code', None)
        # Keep the user logged in after the password change.
        update_session_auth_hash(request, request.user)
        return render(request, 'main/change_password.html', {'success': True})

    return render_form()


def withdrawal_pin(request):
    """Set or change the user's 4-digit withdrawal PIN, required to confirm a
    withdrawal. Changing an existing PIN requires entering the current one."""
    if not request.user.is_authenticated:
        return redirect('login')

    profile, _ = Profile.objects.get_or_create(user=request.user)
    has_pin = profile.has_withdrawal_pin

    if request.method == 'POST':
        current = (request.POST.get('current_pin') or '').strip()
        new = (request.POST.get('new_pin') or '').strip()
        confirm = (request.POST.get('confirm_pin') or '').strip()

        error = None
        if has_pin and not profile.check_withdrawal_pin(current):
            error = 'Your current PIN is incorrect.'
        elif not (new.isdigit() and len(new) == 4):
            error = 'The withdrawal PIN must be exactly 4 digits.'
        elif new != confirm:
            error = 'The new PIN and confirmation do not match.'

        if error:
            return render(request, 'main/withdrawal_pin.html',
                          {'error': error, 'has_pin': has_pin})

        profile.set_withdrawal_pin(new)
        profile.save(update_fields=['withdrawal_pin'])
        LogEntry.objects.log_actions(
            user_id=request.user.pk,
            queryset=User.objects.filter(pk=request.user.pk),
            action_flag=CHANGE,
            change_message='Set/changed withdrawal PIN (self-service).',
            single_object=True,
        )
        return render(request, 'main/withdrawal_pin.html',
                      {'success': True, 'has_pin': True})

    return render(request, 'main/withdrawal_pin.html', {'has_pin': has_pin})


def level(request):
    if not request.user.is_authenticated:
        return redirect('login')

    current = services.current_level(request.user)
    upcoming = services.next_level(request.user)
    context = {
        'current_level': current,
        'next_level': upcoming,
        'level_rows': services.level_rows(request.user),
        'balance': services.format_eur(services.user_balance(request.user)),
        'total_deposits': services.format_eur(services.total_deposits(request.user)),
    }
    return render(request, 'main/level.html', context)


def details(request):
    if not request.user.is_authenticated:
        return redirect('login')

    transactions = request.user.transactions.all()  # already ordered (-created_at)
    kind = request.GET.get('kind')
    valid_kinds = {k for k, _ in Transaction.KIND_CHOICES}
    if kind in valid_kinds:
        transactions = transactions.filter(kind=kind)

    context = {
        'transactions': transactions,
        'kind_choices': Transaction.KIND_CHOICES,
        'selected_kind': kind if kind in valid_kinds else '',
    }
    return render(request, 'main/details.html', context)


def about(request):
    if not request.user.is_authenticated:
        return redirect('login')
    return render(request, 'main/about.html')


def about_us(request):
    if not request.user.is_authenticated:
        return redirect('login')
    return render(request, 'main/about_us.html')


def agreement(request):
    if not request.user.is_authenticated:
        return redirect('login')
    return render(request, 'main/agreement.html')


def privacy(request):
    if not request.user.is_authenticated:
        return redirect('login')
    return render(request, 'main/privacy.html')


def certificate(request):
    if not request.user.is_authenticated:
        return redirect('login')
    return render(request, 'main/certificate.html')


def customer_service(request):
    if not request.user.is_authenticated:
        return redirect('login')
    return render(request, 'main/customer_service.html')


def help_center(request):
    if not request.user.is_authenticated:
        return redirect('login')
    return render(request, 'main/help_center.html')


def personal(request):
    if not request.user.is_authenticated:
        return redirect('login')
    profile = getattr(request.user, 'profile', None)
    context = {
        'user_phone': request.user.username,
        'uid': profile.uid if profile else '',
        'referral_code': profile.referral_code if profile else '',
        'balance': services.format_eur(services.user_balance(request.user)),
    }
    return render(request, 'main/personal.html', context)


def _commission_pct(price, commission):
    """Commission as a percentage string of the ticket price, e.g. '6.2%'."""
    price = Decimal(price or 0)
    if price <= 0:
        return '0%'
    pct = (Decimal(commission or 0) / price) * 100
    return '{:.1f}%'.format(pct)


@login_required
@require_POST
def api_start_ticket(request):
    """Step 1 — the user rated a movie: put the ticket into PROCESSING and
    deduct the principal from their balance.

    If a processing ticket already exists it is resumed (no second deduction),
    so abandoning the modal and reopening it doesn't double-charge.
    """
    try:
        data = json.loads(request.body)
        movie_id = data.get('movie_id')
        movie_title = data.get('movie_title')

        if not all([movie_id, movie_title]):
            return JsonResponse({'error': 'Missing data'}, status=400)

        # Resume an existing processing ticket rather than starting a new one.
        existing = TicketBooking.objects.filter(
            user=request.user, status=TicketBooking.PROCESSING).first()
        if existing is not None:
            bal = services.user_balance(request.user)
            resumed = {
                'success': True,
                'resumed': True,
                'booking_id': existing.id,
                'is_premium': existing.is_premium,
                'kind': existing.special_kind,
                'price': services.format_eur(existing.amount),
                'commission': services.format_eur(existing.commission),
                'commission_pct': _commission_pct(existing.amount, existing.commission),
                'balance': services.format_eur(bal),
                'processing': services.format_eur(services.processing_total(request.user)),
                'needs_deposit': bal < 0,
            }
            if bal < 0:
                resumed['error'] = ('Insufficient balance to submit this ticket ({}). '
                                    'Please deposit to continue.'.format(
                                        services.format_eur(existing.amount)))
            return JsonResponse(resumed)

        # Set gate: once a set (32 tickets) is finished, the user cannot start
        # the next set's tickets until an admin approves ("resets") them.
        if services.needs_set_approval(request.user):
            return JsonResponse({
                'error': 'You have completed this set. Please contact customer '
                         'support to be approved for the next set.',
                'needs_approval': True,
            }, status=403)

        # An admin-set special ticket (once per set) overrides normal pricing;
        # otherwise the price is 10% of balance with a random 4–8% commission.
        special = services.pending_special_ticket(request.user)
        if special is not None:
            ticket_price = special.amount
            commission = special.commission
            is_premium = True
            special_kind = special.kind
        else:
            # After the free trial round, a normal ticket needs a minimum
            # balance (€50); below it the user is asked to deposit / contact
            # support rather than booking.
            if services.training_complete(request.user):
                bal = services.user_balance(request.user)
                if bal < services.MIN_TICKET_BALANCE:
                    return JsonResponse({
                        'error': 'A minimum balance of {} is required to place a '
                                 'ticket. Please deposit or contact customer '
                                 'support.'.format(
                                     services.format_eur(services.MIN_TICKET_BALANCE)),
                        'needs_deposit': True,
                        'contact_support': True,
                    }, status=400)
            ticket_price = services.ticket_price_for(request.user)
            commission = services.ticket_commission(ticket_price)
            is_premium = False
            special_kind = ''

        # A ticket with a non-positive price can't be booked (e.g. a normal
        # ticket when the balance is zero) — prompt a deposit first.
        if ticket_price <= 0:
            return JsonResponse({
                'error': 'Insufficient balance to book this ticket. '
                         'Please deposit to continue.',
                'needs_deposit': True,
                'is_premium': is_premium,
                'price': services.format_eur(ticket_price),
            }, status=400)

        booking = TicketBooking.objects.create(
            user=request.user,
            movie_id=movie_id,
            movie_title=movie_title,
            amount=ticket_price,
            commission=commission,
            status=TicketBooking.PROCESSING,
            is_premium=is_premium,
            special_kind=special_kind,
        )
        # Deduct the full principal now (the ticket is processing). For a premium /
        # special ticket priced above the user's balance this pushes the balance
        # negative — the user must deposit that shortfall before they can submit.
        Transaction.objects.create(
            user=request.user,
            kind=Transaction.TICKET_PRINCIPAL,
            amount=-ticket_price,
            created_at=timezone.now(),
        )

        balance_after = services.user_balance(request.user)
        needs_deposit = balance_after < 0
        payload = {
            'success': True,
            'booking_id': booking.id,
            'is_premium': is_premium,
            'kind': special_kind,
            'price': services.format_eur(ticket_price),
            'commission': services.format_eur(commission),
            'commission_pct': _commission_pct(ticket_price, commission),
            'balance': services.format_eur(balance_after),
            'processing': services.format_eur(services.processing_total(request.user)),
            'needs_deposit': needs_deposit,
        }
        if needs_deposit:
            payload['error'] = ('Insufficient balance to submit this ticket ({}). '
                                'Please deposit to continue.'.format(
                                    services.format_eur(ticket_price)))
        return JsonResponse(payload)
    except (json.JSONDecodeError, InvalidOperation, ValueError) as e:
        return JsonResponse({'error': str(e)}, status=400)


@login_required
@require_POST
def api_rate_movie(request):
    """Step 2 — the user submitted their rating: complete the processing ticket
    by returning the principal plus commission, and record the rating."""
    try:
        data = json.loads(request.body)
        booking_id = data.get('booking_id')
        # Stars are optional when completing from the Orders list (no picker
        # there); the task modal always supplies them. Default to 5, clamp 1–5.
        try:
            stars = int(data.get('stars') or 5)
        except (TypeError, ValueError):
            stars = 5
        stars = max(1, min(5, stars))

        if not booking_id:
            return JsonResponse({'error': 'Missing data'}, status=400)

        booking = TicketBooking.objects.filter(
            id=booking_id, user=request.user, status=TicketBooking.PROCESSING).first()
        if booking is None:
            return JsonResponse({'error': 'No processing ticket found.'}, status=404)

        # The principal was deducted when the ticket was reserved; if that pushed
        # the balance negative the user must deposit the shortfall before the
        # ticket can be submitted and the commission paid out.
        balance = services.user_balance(request.user)
        if balance < 0:
            return JsonResponse({
                'error': 'Insufficient balance to submit this ticket ({}). '
                         'Please deposit to continue.'.format(
                             services.format_eur(booking.amount)),
                'needs_deposit': True,
                'price': services.format_eur(booking.amount),
                'balance': services.format_eur(balance),
            }, status=400)

        # Store the rating against the booked movie.
        MovieRating.objects.create(
            user=request.user,
            movie_id=booking.movie_id,
            movie_title=booking.movie_title,
            stars=stars,
        )

        # Return the principal and pay the commission, then complete the ticket.
        now = timezone.now()
        Transaction.objects.create(
            user=request.user,
            kind=Transaction.TICKET_PRINCIPAL_RETURN,
            amount=booking.amount,
            created_at=now,
        )
        Transaction.objects.create(
            user=request.user,
            kind=Transaction.COMMISSION,
            amount=booking.commission,
            created_at=now,
        )
        booking.status = TicketBooking.COMPLETED
        booking.save(update_fields=['status'])

        # Welcome bonus expiry: once the trial set (32 tickets) is complete,
        # claw back the original bonus so only the commission earned during the
        # trial remains. Fires exactly once, and only for users who got a bonus.
        bonus_expired = False
        if (services.welcome_bonus_granted(request.user)
                and not services.welcome_bonus_reversed(request.user)
                and services.training_complete(request.user)):
            Transaction.objects.create(
                user=request.user,
                kind=Transaction.BONUS_REVERSAL,
                amount=-services.WELCOME_BONUS,
                created_at=now,
            )
            bonus_expired = True

        # Consume the admin's special ticket for this set (once per set).
        if booking.is_premium:
            special = services.pending_special_ticket(request.user)
            if special is not None:
                special.is_used = True
                special.used_at = now
                special.save(update_fields=['is_used', 'used_at'])

        # If this completion finished a set the user isn't approved past, raise an
        # approval request (the admin's notification) and flag the UI. One request
        # per completed set, keyed by ``set_completed`` — so a later set still
        # raises its own notification even if an earlier set's request is still
        # lying around (e.g. an admin advanced the user another way without
        # clearing it). A global "any pending?" guard would suppress it and leave
        # the user stuck a set behind, so their later sets never count.
        awaiting_approval = services.needs_set_approval(request.user)
        if awaiting_approval:
            set_completed = services.current_set_number(request.user) - 1
            sar, created = SetApprovalRequest.objects.get_or_create(
                user=request.user,
                set_completed=set_completed,
                defaults={'status': SetApprovalRequest.PENDING},
            )
            if created:
                LogEntry.objects.log_actions(
                    user_id=request.user.pk,
                    queryset=SetApprovalRequest.objects.filter(pk=sar.pk),
                    action_flag=ADDITION,
                    change_message='Set {} completed — awaiting approval to proceed.'.format(set_completed),
                    single_object=True,
                )

        total = booking.amount + booking.commission
        return JsonResponse({
            'success': True,
            'price': services.format_eur(booking.amount),
            'commission': services.format_eur(booking.commission),
            'total': services.format_eur(total),
            'balance': services.format_eur(services.user_balance(request.user)),
            'awaiting_approval': awaiting_approval,
            'bonus_expired': bonus_expired,
        })
    except (json.JSONDecodeError, InvalidOperation, ValueError) as e:
        return JsonResponse({'error': str(e)}, status=400)


def language_settings(request):
    if not request.user.is_authenticated:
        return redirect('login')
    # Determine current selection from session or cookie
    current = request.session.get('language') or request.COOKIES.get('django_language') or 'en'
    # ensure CSRF cookie is set
    get_token(request)
    context = {'current_language': current}
    return render(request, 'main/language.html', context)


@require_POST
def api_set_language(request):
    try:
        payload = json.loads(request.body.decode('utf-8')) if request.body else {}
    except Exception:
        return HttpResponseBadRequest('Invalid JSON')

    lang = payload.get('language') or request.POST.get('language')
    if not lang:
        return HttpResponseBadRequest('Missing language')
    valid_languages = {code for code, _name in settings.LANGUAGES}
    if lang not in valid_languages:
        return HttpResponseBadRequest('Unsupported language')

    # Persist to session and set cookie via response
    request.session['language'] = lang
    language_session_key = getattr(translation, 'LANGUAGE_SESSION_KEY', 'django_language')
    request.session[language_session_key] = lang
    # activate for this request so subsequent rendering can use it immediately
    translation.activate(lang)
    response = JsonResponse({'success': True, 'language': lang})
    response.set_cookie(
        settings.LANGUAGE_COOKIE_NAME,
        lang,
        max_age=60 * 60 * 24 * 365,
        path='/',
        samesite='Lax',
    )
    return response


def api_movies(request):
    if not request.user.is_authenticated:
        return JsonResponse({'detail': 'Authentication required.'}, status=403)

    api_url = 'https://api.tvmaze.com/shows?page=1'
    try:
        with urllib.request.urlopen(api_url, timeout=10) as response:
            shows = json.loads(response.read().decode())
    except Exception:
        return JsonResponse({'error': 'Unable to fetch movie listings.'}, status=503)

    # ODEON feature trailer (plays on the hero poster's ▶ button).
    odeon_video_url = 'https://www.youtube.com/embed/SshT6gW0sNg'
    trailer_urls = [
        odeon_video_url,
        'https://www.youtube.com/embed/5PS_S2vE3G0',
        'https://www.youtube.com/embed/YoHD9XEInc0',
    ]

    # Preview price = 10% of the user's current balance (same for all movies).
    # The authoritative price/commission are returned when the ticket is reserved.
    preview_price = services.ticket_price_for(request.user)

    movies = []
    for idx, show in enumerate(shows[:50]):
        # The first ("now playing") movie always uses the ODEON feature trailer.
        trailer = odeon_video_url if idx == 0 else trailer_urls[idx % len(trailer_urls)]
        movie_id = show.get('id')
        # Prefer the provider's average rating; fall back to a stable value.
        rating = (show.get('rating') or {}).get('average')
        if not rating:
            rating = services.movie_display_rating(movie_id)
        movies.append({
            'id': movie_id,
            'title': show.get('name'),
            'image': show.get('image', {}).get('medium') if show.get('image') else '',
            'summary': show.get('summary') or '',
            'status': show.get('status'),
            'premiered': show.get('premiered'),
            'trailer': trailer,
            'rating': rating,
            'price': services.format_eur(preview_price),
            'commission_hint': '3%',
        })

    return JsonResponse({'movies': movies})


# ---------------------------------------------------------------------------
# Agent portal — a shared back-office. Agents sign in (separately from
# end-users) and see ALL users, who referred each one, and who approved/unfroze
# them; agents can approve/unfreeze/freeze accounts. Auth is session-based
# against the Agent model, fully isolated from the end-user/admin login.
# ---------------------------------------------------------------------------

def _current_agent(request):
    """The active Agent for this session, or None."""
    agent_id = request.session.get('agent_id')
    if not agent_id:
        return None
    return Agent.objects.filter(pk=agent_id, is_active=True).first()


def agent_required(view):
    """Gate a portal view behind an agent session; redirect to login otherwise."""
    @wraps(view)
    def wrapper(request, *args, **kwargs):
        agent = _current_agent(request)
        if agent is None:
            request.session.pop('agent_id', None)
            return redirect('agent_login')
        request.agent = agent
        return view(request, *args, **kwargs)
    return wrapper


def agent_login(request):
    if _current_agent(request) is not None:
        return redirect('agent_portal')

    if request.method == 'POST':
        username = (request.POST.get('username') or '').strip()
        password = request.POST.get('password') or ''
        agent = Agent.objects.filter(username=username, is_active=True).first()
        if agent is not None and agent.check_password(password):
            request.session['agent_id'] = agent.pk
            return redirect('agent_portal')
        return render(request, 'main/agent_login.html',
                      {'error': 'Invalid username or password.'})

    return render(request, 'main/agent_login.html')


def agent_logout(request):
    request.session.pop('agent_id', None)
    return redirect('agent_login')


@agent_required
def agent_portal(request):
    agent = request.agent
    # Shared portal: every agent sees ALL users, who referred them, and who
    # approved/unfroze them.
    profiles = (Profile.objects
                .select_related('user', 'agent', 'activated_by')
                .order_by('-created_at'))

    members, online_count, awaiting_count = [], 0, 0
    for p in profiles:
        u = p.user
        is_online = p.is_online
        if is_online:
            online_count += 1
        # "Awaiting approval" = registered but not yet activated (and not
        # frozen/banned).
        if not u.is_active and p.status == Profile.ACTIVE:
            awaiting_count += 1

        is_banned = p.status == Profile.BANNED
        is_frozen = p.status == Profile.FROZEN
        members.append({
            'pk': p.pk,
            'username': u.username,
            'uid': p.uid,
            'referral_code': p.referral_code,
            'agent_role': p.agent.get_role_display() if p.agent_id else '-',
            'referred_by': p.agent.name if p.agent_id else '—',
            'approved_by': p.activated_by.name if p.activated_by_id else '—',
            'status': p.get_status_display(),
            'is_active': u.is_active,
            'is_banned': is_banned,
            'is_online': is_online,
            'last_seen': p.last_seen,
            'last_ip': p.last_ip,
            'balance': services.format_eur(services.user_balance(u)),
            'tickets': services.completed_tickets(u),
            'set_progress': services.set_progress_label(u),
            'joined': p.created_at,
            # Available actions (banned accounts are admin-only).
            'can_activate': (not is_banned) and (not u.is_active or is_frozen),
            'activate_label': 'Unfreeze' if is_frozen else 'Approve',
            'can_freeze': (not is_banned) and u.is_active and p.status == Profile.ACTIVE,
        })

    context = {
        'agent': agent,
        'members': members,
        'total': len(members),
        'online_count': online_count,
        'awaiting_count': awaiting_count,
        'referral_link': request.build_absolute_uri(agent.registration_path),
    }
    return render(request, 'main/agent_portal.html', context)


def _agent_target_profile(request):
    """The Profile targeted by an agent action, or None. Banned accounts are an
    admin-only decision and are never returned for agent mutation."""
    profile = (Profile.objects.select_related('user')
               .filter(pk=request.POST.get('profile_id')).first())
    if profile is None or profile.status == Profile.BANNED:
        return None
    return profile


@agent_required
@require_POST
def agent_approve_user(request):
    """Approve a pending account or unfreeze a frozen one so the user can log in,
    stamping the agent who did it."""
    profile = _agent_target_profile(request)
    if profile is None:
        return redirect('agent_portal')
    user = profile.user
    user.is_active = True
    user.save(update_fields=['is_active'])
    profile.status = Profile.ACTIVE
    profile.activated_by = request.agent
    profile.activated_at = timezone.now()
    profile.save(update_fields=['status', 'activated_by', 'activated_at'])
    return redirect('agent_portal')


@agent_required
@require_POST
def agent_freeze_user(request):
    """Freeze a user (block login). A frozen account has no current approver."""
    profile = _agent_target_profile(request)
    if profile is None:
        return redirect('agent_portal')
    user = profile.user
    user.is_active = False
    user.save(update_fields=['is_active'])
    profile.status = Profile.FROZEN
    profile.activated_by = None
    profile.activated_at = None
    profile.save(update_fields=['status', 'activated_by', 'activated_at'])
    return redirect('agent_portal')
