# content/models.py
import uuid
from decimal import Decimal, ROUND_HALF_UP, InvalidOperation
from io import BytesIO
from urllib.parse import urlparse, parse_qs, unquote
from django.apps import apps
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.core.files.base import ContentFile
from django.core.mail import send_mail
from django.core.validators import MinValueValidator, MaxValueValidator, RegexValidator
from django.db import models, transaction
from django.db.models import F, Q, Count
from django.urls import reverse
from django.utils import timezone
import re
from django.db.models.signals import post_save, post_delete, post_migrate
from django.dispatch import receiver
from reportlab.lib.pagesizes import A4
from reportlab.pdfgen import canvas
import threading
from django.contrib import messages
import traceback
import re









User = settings.AUTH_USER_MODEL

# For relations (FK/O2O) — string label is safest across apps/migrations
USER_MODEL = settings.AUTH_USER_MODEL

# For querying/creating users in code
# (UserModel line deleted to fix circular import)
_thread_locals = threading.local()





def _grade_from_percent(pct):
    pct = float(pct)
    if pct >= 90: return "A+"
    elif pct >= 80: return "A"
    elif pct >= 70: return "A-"
    elif pct >= 60: return "B"
    elif pct >= 50: return "C"
    elif pct >= 40: return "D"
    else: return "F"







class TimeStampedModel(models.Model):
    created_at = models.DateTimeField(default=timezone.now, editable=False)
    updated_at = models.DateTimeField(auto_now=True, editable=False)

    class Meta:
        abstract = True


class ActiveQuerySet(models.QuerySet):
    def active(self): return self.filter(is_active=True)


class ActiveManager(models.Manager):
    def get_queryset(self): return ActiveQuerySet(model=self.model, using=self._db)

    def active(self): return self.get_queryset().active()


class ImageUrlMixin:
    @property
    def image_src(self):
        try:
            f = getattr(self, "image", None)
            if f and f.url:  # tolerate missing files in prod
                return f.url
        except Exception:
            pass
        return getattr(self, "image_url", "") or ""


def branding_upload_to(instance, filename):
    dt = timezone.now()
    return f"branding/{dt:%Y/%m}/{filename}"


class SiteBranding(TimeStampedModel, ImageUrlMixin):
    objects = ActiveManager()
    """
    Global site branding you can reuse anywhere (logo, favicon, site name).
    Create one active record; the latest active is used by templates.
    """
    is_active = models.BooleanField(default=True, help_text="The latest active record is used site-wide.")

    site_name = models.CharField(
        max_length=160,
        blank=True,
        help_text="Optional site title/brand text (fallback if logo missing)."
    )

    # Logo: either upload a file or paste a URL (file wins)
    logo = models.ImageField(upload_to=branding_upload_to, blank=True, null=True)
    logo_url = models.URLField(blank=True)

    # Favicon (optional)
    favicon = models.ImageField(upload_to=branding_upload_to, blank=True, null=True)
    favicon_url = models.URLField(blank=True)

    # Alt text (accessibility)
    logo_alt = models.CharField(max_length=200, blank=True, default="Site logo")

    created_at = models.DateTimeField(auto_now_add=True, editable=False)
    updated_at = models.DateTimeField(auto_now=True, editable=False)

    class Meta:
        ordering = ("-updated_at", "-id")
        verbose_name = "Site Branding"
        verbose_name_plural = "Site Branding"

    def __str__(self):
        return self.site_name or f"Branding #{self.pk}"

    @property
    def logo_src(self) -> str:
        # --- THIS IS THE CORRECTED PROPERTY ---
        # 1. Use the manual logo_url field first if it's filled in.
        if self.logo_url:
            return self.logo_url

        # 2. If not, build the FULL absolute URL from the uploaded logo file.
        try:
            if self.logo and self.logo.url:
                # self.logo.url is "/media/logo.png"
                # settings.BASE_URL is "https://school.mentosh.top"
                # This returns "https://school.mentosh.top/media/logo.png"
                return settings.BASE_URL + self.logo.url
        except Exception:
            pass
        
        # 3. Fallback
        return ""
        # --- END OF CORRECTION ---

    @property
    def favicon_src(self) -> str:
        # --- THIS IS THE CORRECTED PROPERTY ---
        # 1. Use manual favicon_url first.
        if self.favicon_url:
            return self.favicon_url
            
        # 2. Build absolute URL from the uploaded favicon file.
        try:
            if self.favicon and self.favicon.url:
                return settings.BASE_URL + self.favicon.url
        except Exception:
            pass
            
        # 3. Fallback
        return ""
        # --- END OF CORRECTION ---


def banner_upload_to(instance, filename):
    return f"banners/{timezone.now():%Y/%m}/{filename}"


def notice_upload_to(instance, filename):
    return f"notices/{timezone.now():%Y/%m}/{filename}"


# //////////////////////////////////
# Banner
# //////////////////////////////////
class Banner(TimeStampedModel, ImageUrlMixin):
    objects = ActiveManager()
    """Homepage slider banner with optional image file or external URL."""
    title = models.CharField(
        max_length=150,
        help_text="Main headline shown on the banner."
    )
    subtitle = models.CharField(
        max_length=300, blank=True,
        help_text="Optional sub-headline under the title."
    )

    image = models.ImageField(
        upload_to=banner_upload_to, blank=True, null=True,
        help_text="Preferred ~1920×600 JPG/PNG. Ignored if Image URL is set."
    )
    image_url = models.URLField(
        blank=True,
        help_text="External image URL (used if no uploaded image or if set)."
    )

    button_text = models.CharField(
        max_length=40, blank=True,
        help_text="Optional CTA text (e.g. ‘Apply now’)."
    )
    button_link = models.CharField(
        max_length=300, blank=True,
        help_text="Internal path or full URL (e.g. /admission or https://example.com)."
    )

    order = models.PositiveIntegerField(
        default=0,
        help_text="Lower numbers appear first."
    )
    is_active = models.BooleanField(
        default=True,
        help_text="Uncheck to hide this banner."
    )

    created_by = models.ForeignKey(
        settings.AUTH_USER_MODEL, null=True, blank=True,
        on_delete=models.SET_NULL, related_name="banners_created"
    )
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        ordering = ("order", "-created_at")

    def __str__(self):
        return self.title

    @property
    def image_src(self) -> str:
        """Prefer uploaded file; fall back to external URL; never crash."""
        if self.image:
            try:
                return self.image.url
            except Exception:
                pass
        return self.image_url or ""

    def clean(self):
        """Trim fields and require at least one image source."""
        for f in ("title", "subtitle", "button_text", "button_link", "image_url"):
            v = getattr(self, f, "")
            if isinstance(v, str):
                setattr(self, f, v.strip())

        if not self.image and not (self.image_url or "").strip():
            raise ValidationError("Provide an image file or an image URL.")


# //////////////////////////////////
# Notice
# //////////////////////////////////
class Notice(TimeStampedModel, ImageUrlMixin):
    objects = ActiveManager()
    title = models.CharField(
        max_length=200,
        help_text="Notice title shown on the card."
    )
    subtitle = models.TextField(
        blank=True,
        help_text="Optional short description shown under the title."
    )
    image = models.ImageField(
        upload_to=notice_upload_to, blank=True, null=True,
        help_text="Optional image for the notice card."
    )
    image_url = models.URLField(
        blank=True,
        help_text="Optional external image URL (used if no uploaded image)."
    )
    link_url = models.URLField(
        blank=True,
        help_text="Optional ‘Read more’ target; if empty, the internal detail page is used."
    )
    published_at = models.DateTimeField(
        default=timezone.now,
        help_text="Publish date/time (controls ordering). Format: YYYY-MM-DD HH:MM (24-hour)."
    )
    is_active = models.BooleanField(
        default=True,
        help_text="Uncheck to hide this notice."
    )

    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        ordering = ("-published_at", "-created_at")

    def __str__(self):
        return self.title

    @property
    def image_src(self) -> str:
        if getattr(self, "image", None):
            try:
                return self.image.url
            except Exception:
                pass
        return getattr(self, "image_url", "") or ""

    def get_absolute_url(self) -> str:
        return reverse("notice_detail", args=[self.pk])

    @property
    def url(self) -> str:
        lu = getattr(self, "link_url", "") or ""
        return lu if lu.strip() else self.get_absolute_url()


# //////////////////////////////////
# Timeline
# //////////////////////////////////
class TimelineEvent(TimeStampedModel, ImageUrlMixin):
    objects = ActiveManager()
    title = models.CharField(
        max_length=150,
        help_text="Event title (shown on the timeline)."
    )
    description = models.TextField(
        blank=True,
        help_text="Optional short description under the title."
    )
    date = models.DateField(
        help_text="Event date (YYYY-MM-DD)."
    )
    order = models.PositiveIntegerField(
        default=0,
        help_text="Secondary sort within the same date. Lower appears first."
    )
    is_active = models.BooleanField(
        default=True,
        help_text="Uncheck to hide this event."
    )

    created_by = models.ForeignKey(
        settings.AUTH_USER_MODEL, null=True, blank=True,
        on_delete=models.SET_NULL, related_name="timeline_created"
    )
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        ordering = ("date", "order", "-created_at")

    def __str__(self):
        return f"{self.date} — {self.title}"


# //////////////////////////////////
# Gallery
# //////////////////////////////////
class GalleryItem(TimeStampedModel, ImageUrlMixin):
    objects = ActiveManager()
    IMAGE = "image"
    VIDEO = "video"
    KIND_CHOICES = [(IMAGE, "Image"), (VIDEO, "Video (MP4)")]

    kind = models.CharField(
        max_length=10, choices=KIND_CHOICES, default=IMAGE,
        help_text="Choose ‘Image’ to upload a photo or ‘Video (MP4)’ for a local video upload."
    )
    title = models.CharField(
        max_length=200,
        help_text="Display title for this item."
    )
    place = models.CharField(
        max_length=120, blank=True,
        help_text="Optional location (e.g., ‘Main Hall’)."
    )
    taken_at = models.DateTimeField(
        null=True, blank=True,
        help_text="When the photo/video was taken. Format: YYYY-MM-DD HH:MM (24-hour)."
    )

    # media
    image = models.ImageField(
        upload_to="gallery/", blank=True,
        help_text="Upload if Kind=Image. Recommended ~1600px on the long side."
    )
    video = models.FileField(
        upload_to="gallery/videos/", blank=True,
        help_text="Upload if Kind=Video. Must be MP4 format."
    )
    thumbnail = models.ImageField(
        upload_to="gallery/thumbs/", blank=True,
        help_text="Optional custom thumbnail for grids (e.g., 600×400). If empty, the main image is used for images; for videos, a placeholder is used unless provided."
    )

    # housekeeping
    order = models.PositiveIntegerField(
        default=0,
        help_text="Lower numbers appear first."
    )
    is_active = models.BooleanField(
        default=True,
        help_text="Uncheck to hide this item."
    )

    class Meta:
        ordering = ["order", "-taken_at", "-id"]

    def __str__(self):
        return self.title

    # --- convenience ---
    @property
    def date_str(self):
        return self.taken_at.strftime("%Y-%m-%d") if self.taken_at else ""

    @property
    def time_str(self):
        # Portable (Windows-safe) 12-hour format without leading zero
        return self.taken_at.strftime("%I:%M %p").lstrip("0") if self.taken_at else ""

    @property
    def thumb_src(self):
        if getattr(self, "thumbnail", None):
            try:
                return self.thumbnail.url
            except Exception:
                pass
        if self.kind == self.IMAGE and getattr(self, "image", None):
            try:
                return self.image.url
            except Exception:
                pass
        return ""  # no placeholder

    def clean(self):
        """Basic sanity: require media for selected kind."""
        if self.kind == self.IMAGE and not self.image:
            raise ValidationError("For Kind=Image, please upload an image.")
        if self.kind == self.VIDEO and not self.video:
            raise ValidationError("For Kind=Video, please upload an MP4 video.")


# //////////////////////////////////
# About
# //////////////////////////////////
def about_upload_to(instance, filename):
    return f"about/{timezone.now():%Y/%m}/{filename}"


class AboutSection(TimeStampedModel, ImageUrlMixin):
    objects = ActiveManager()
    """
    One editable 'About the College' block with up to 4 fading images.
    (Single model by request—no separate image table.)
    """
    # --- content ---
    title = models.CharField(
        max_length=150,
        help_text="Section heading shown above the block (e.g., 'About My College')."
    )
    college_name = models.CharField(
        max_length=150,
        blank=True,
        help_text="Optional sub-heading inside the block (e.g., your college name)."
    )
    body = models.TextField(
        blank=True,
        help_text="Main paragraph text. Keep it concise."
    )
    bullets = models.TextField(
        blank=True,
        help_text="Bullet points — one per line. Example:\nSmart Classrooms\nExperienced Faculty\nModern Labs"
    )

    # --- images (up to 4, optional) ---
    image_1 = models.ImageField(
        upload_to=about_upload_to, blank=True, null=True,
        help_text="Fading image #1 (landscape recommended)."
    )
    image_1_alt = models.CharField(
        max_length=200, blank=True,
        help_text="Alt text for image #1 (accessibility)."
    )

    image_2 = models.ImageField(
        upload_to=about_upload_to, blank=True, null=True,
        help_text="Fading image #2 (optional)."
    )
    image_2_alt = models.CharField(
        max_length=200, blank=True,
        help_text="Alt text for image #2 (accessibility)."
    )

    image_3 = models.ImageField(
        upload_to=about_upload_to, blank=True, null=True,
        help_text="Fading image #3 (optional)."
    )
    image_3_alt = models.CharField(
        max_length=200, blank=True,
        help_text="Alt text for image #3 (accessibility)."
    )

    image_4 = models.ImageField(
        upload_to=about_upload_to, blank=True, null=True,
        help_text="Fading image #4 (optional)."
    )
    image_4_alt = models.CharField(
        max_length=200, blank=True,
        help_text="Alt text for image #4 (accessibility)."
    )

    # --- ordering/visibility ---
    order = models.PositiveIntegerField(default=0, help_text="Lower comes first.")
    is_active = models.BooleanField(default=True, help_text="Uncheck to hide this section.")

    # --- housekeeping ---
    created_at = models.DateTimeField(auto_now_add=True, editable=False)
    updated_at = models.DateTimeField(auto_now=True, editable=False)

    class Meta:
        ordering = ("order", "-updated_at")
        verbose_name = "About Section"
        verbose_name_plural = "About Sections"

    def __str__(self):
        return self.title

    @property
    def bullet_list(self):
        """Return bullets as a cleaned list (skip blanks)."""
        return [b.strip() for b in (self.bullets or "").splitlines() if b.strip()]

    @property
    def image_list(self):
        """
        Returns a list of (url, alt) for all present images, in order.
        Useful in templates for the fading stack.
        """
        out = []
        for idx in (1, 2, 3, 4):
            img = getattr(self, f"image_{idx}", None)
            if img:
                try:
                    url = img.url
                except Exception:
                    url = ""
                if url:
                    alt = getattr(self, f"image_{idx}_alt", "") or ""
                    out.append((url, alt))
        return out

    @property
    def image_count(self):
        return len(self.image_list)


# //////////////////////////////////
# ACADEMIC CALENDAR
# //////////////////////////////////
class AcademicCalendarItem(TimeStampedModel, ImageUrlMixin):
    objects = ActiveManager()
    TONE_CHOICES = [
        ("blue", "Blue"),
        ("green", "Green"),
        ("red", "Red"),
        ("purple", "Purple"),
        ("orange", "Orange"),
    ]

    # Content
    title = models.CharField(
        max_length=150,
        help_text="Short heading (e.g., ‘Semester Start’)."
    )
    date_text = models.CharField(
        max_length=150,
        help_text="Human-friendly date or range (e.g., ‘10 Sep 2025’ or ‘20 Dec 2025 – 5 Jan 2026’)."
    )
    description = models.TextField(
        blank=True,
        help_text="Optional brief description shown under the date."
    )

    # Appearance
    icon_class = models.CharField(
        max_length=80,
        default="bi bi-calendar-event",
        help_text=(
            "CSS class for the icon.\n"
            "• Bootstrap Icons (load BI in base.html): e.g. bi bi-calendar-week\n"
            "• Font Awesome (if you load FA): e.g. fa-solid fa-calendar-days"
        ),
    )
    tone = models.CharField(
        max_length=10,
        choices=TONE_CHOICES,
        default="blue",
        help_text="Color accent for the round icon badge."
    )

    # Placement / lifecycle
    order = models.PositiveIntegerField(
        default=0,
        help_text="Lower numbers appear first."
    )
    is_active = models.BooleanField(
        default=True,
        help_text="Uncheck to hide this item from the site."
    )
    created_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True, blank=True,
        on_delete=models.SET_NULL,
        related_name="calendar_items",
        help_text="Auto-filled on first save."
    )
    created_at = models.DateTimeField(auto_now_add=True, editable=False)
    updated_at = models.DateTimeField(auto_now=True, editable=False)

    class Meta:
        ordering = ("order", "-updated_at")
        verbose_name = "Academic Calendar Item"
        verbose_name_plural = "Academic Calendar Items"

    def __str__(self):
        return self.title

    @property
    def icon_tone_class(self) -> str:
        return f"icon-{self.tone}"


# //////////////////////////////////
# Programs & Courses
# //////////////////////////////////

def course_image_upload_to(instance, filename):
    """
    Upload path for a course's cover image.
    Uses the category's 'slug' for a clean path.
    """
    if instance.category and instance.category.slug:
        category_path = instance.category.slug
    else:
        category_path = "uncategorized"
    
    return f"courses/{category_path}/{filename}"


def course_syllabus_upload_to(instance, filename):
    """
    Upload path for a course's syllabus file (PDF/Doc, etc.).
    Uses the category's 'slug' for a clean path.
    """
    if instance.category and instance.category.slug:
        category_path = instance.category.slug
    else:
        category_path = "uncategorized"
        
    return f"courses/syllabi/{category_path}/{filename}"


class CourseCategory(models.Model):
    name = models.CharField(
        max_length=100,
        unique=True,
        help_text="Category title (e.g., Science, Commerce, Arts)"
    )
    slug = models.SlugField(
        max_length=120,
        unique=True,
        help_text="Used for filtering, URLs, etc."
    )
    order = models.PositiveIntegerField(default=0)

    class Meta:
        ordering = ("order", "name")
        verbose_name = "Course Category"
        verbose_name_plural = "Course Categories"

    def __str__(self):
        return self.name





class Course(TimeStampedModel, ImageUrlMixin):
    objects = ActiveManager()
    """
    A program that students can enroll into (e.g., Science, Commerce).
    Use the fee fields to publish a transparent, itemized cost breakdown
    in the frontend and to snapshot fees into applications.
    """

    CATEGORY_CHOICES = [
        ("science", "Science"),
        ("commerce", "Commerce"),
        ("arts", "Arts"),
        ("vocational", "Vocational"),
    ]

    title = models.CharField(
        max_length=150,
        help_text="Public name of the course as shown on the website.",
    )
    category = models.ForeignKey(
        CourseCategory,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="courses",
        help_text="Select a category for this course."
    )

    image = models.ImageField(
        upload_to=course_image_upload_to,
        blank=True, null=True,
        help_text="Optional cover image for cards/headers (recommended landscape).",
    )
    syllabus_file = models.FileField(
        upload_to=course_syllabus_upload_to,
        blank=True, null=True,
        help_text="Attach a PDF/DOC syllabus students can download.",
    )

    duration = models.CharField(
        max_length=50, blank=True,
        help_text="e.g., '2 years', 'Jan–Dec', or 'Semester based'.",
    )
    shift = models.CharField(
        max_length=50, blank=True,
        help_text="e.g., 'Morning', 'Day', 'Evening' (optional).",
    )
    description = models.TextField(
        blank=True,
        help_text="A short overview of what the course covers and outcomes.",
    )
    eligibility = models.CharField(
        max_length=200, blank=True,
        help_text="Minimum requirements (e.g., 'SSC pass with GPA ≥ 3.5').",
    )

    # >>> Fee fields you will set in admin <<<
    admission_fee = models.DecimalField(
        max_digits=10, decimal_places=2, default=Decimal("0.00"),
        help_text="One-time admission/registration fee.",
    )
    first_month_tuition = models.DecimalField(
        max_digits=10, decimal_places=2, default=Decimal("0.00"),
        help_text="Tuition fee for the first month (or initial installment).",
    )
    exam_fee = models.DecimalField(
        max_digits=10, decimal_places=2, default=Decimal("0.00"),
        help_text="Internal/board exam fee (if applicable).",
    )
    bus_fee = models.DecimalField(
        max_digits=10, decimal_places=2, default=Decimal("0.00"), blank=True, null=True,
        help_text="Optional transport/bus fee (per month or fixed).",
    )
    hostel_fee = models.DecimalField(
        max_digits=10, decimal_places=2, default=Decimal("0.00"),blank=True, null=True,
        help_text="Optional hostel/accommodation fee.",
    )
    marksheet_fee = models.DecimalField(
        max_digits=10, decimal_places=2, default=Decimal("0.00"),blank=True, null=True,
        help_text="Optional marksheet/certification processing fee.",
    )

    # (optional legacy)
    monthly_fee = models.DecimalField(
        max_digits=10, decimal_places=2, blank=True, null=True,
        help_text="Legacy field. Prefer the explicit fee fields above.",
    )

    order = models.PositiveIntegerField(
        default=0,
        help_text="Lower numbers appear first in listings.",
    )
    is_active = models.BooleanField(
        default=True,
        help_text="Untick to hide this course from the website.",
    )
    created_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        related_name="courses_created",
        on_delete=models.SET_NULL, null=True, blank=True, editable=False,
        help_text="User who created this record (set automatically).",
    )
    created_at = models.DateTimeField(
        auto_now_add=True, editable=False,
        help_text="When this record was created.",
    )
    updated_at = models.DateTimeField(
        auto_now=True, editable=False,
        help_text="When this record was last updated.",
    )

    class Meta:
        ordering = ("order", "title")
        verbose_name = "Course"
        verbose_name_plural = "Courses"

    def __str__(self):
        return self.title


# ---------- Admissions ----------

def admission_photo_upload_to(instance, filename):
    """
    Upload path for applicant photos. Uses current timestamp folders so it
    works before the object is saved to DB.
    """
    dt = timezone.now()
    return f"admissions/photos/{dt:%Y/%m}/{filename}"


def admission_doc_upload_to(instance, filename):
    """
    Upload path for applicant transcripts/attachments.
    """
    dt = timezone.now()
    return f"admissions/docs/{dt:%Y/%m}/{filename}"


#
# REPLACE your AdmissionApplication class with THIS ONE
#

class AdmissionApplication(TimeStampedModel, ImageUrlMixin):
    objects = ActiveManager()
    """
    A student's online admission form. We snapshot course fees into the application
    so that later fee changes in the Course model won't affect past applications.
    """

    # --- applicant info ---
    full_name = models.CharField(max_length=200, help_text="Applicant’s full legal name.")
    email = models.EmailField(blank=True, db_index=True, help_text="Applicant’s email (optional, but helps for communication).")
    phone = models.CharField(max_length=20, db_index=True, help_text="Primary contact number (WhatsApp preferred if available).")
    date_of_birth = models.DateField(blank=True, null=True, help_text="Optional, used for record verification.")
    address = models.TextField(blank=True, help_text="Present address with district/upazila for correspondence.")
    guardian_name = models.CharField(max_length=200, blank=True, help_text="Parent/guardian full name (optional).")
    guardian_phone = models.CharField(max_length=20, blank=True, help_text="Parent/guardian phone number (optional).")

    desired_course = models.ForeignKey(
        "content.Course",
        on_delete=models.PROTECT,
        related_name="applications",
        null=True, blank=True,
        help_text="The course the applicant is applying to.",
    )
    shift = models.CharField(max_length=20, blank=True, help_text="Preferred shift (Morning/Day/Evening).")

    previous_school = models.CharField(max_length=200, blank=True, help_text="Last attended school/college (optional).")
    ssc_gpa = models.DecimalField(max_digits=3, decimal_places=2, blank=True, null=True,
                                  help_text="Secondary exam GPA (or equivalent), if applicable.")

    photo = models.ImageField(upload_to=admission_photo_upload_to, blank=True, null=True,
                              help_text="Passport-size photo (optional in demo).")
    transcript = models.FileField(upload_to=admission_doc_upload_to, blank=True, null=True,
                                  help_text="Academic transcript/certificate (optional).")
    message = models.TextField(blank=True, help_text="Any additional information or questions.")

    # --- user-selectable add-ons (optional) ---
    add_bus = models.BooleanField(default=False, help_text="Applicant opts for transport service.")
    add_hostel = models.BooleanField(default=False, help_text="Applicant opts for hostel/accommodation.")
    add_marksheet = models.BooleanField(default=False,
                                        help_text="Applicant opts for marksheet/certification processing.")

    # --- mandatory base rows (non-editable on forms) ---
    add_admission = models.BooleanField(default=True, editable=False, help_text="Always included: Admission fee row.")
    add_tuition = models.BooleanField(default=True, editable=False,
                                      help_text="Always included: First Month Tuition row.")
    add_exam = models.BooleanField(default=True, editable=False, help_text="Always included: Exam fee row.")

    # --- fee snapshots (copied from Course at submit/change) ---
    fee_admission = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal("0.00"))
    fee_tuition = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal("0.00"))
    fee_exam = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal("0.00"))
    fee_bus = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal("0.00"))
    fee_hostel = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal("0.00"))
    fee_marksheet = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal("0.00"))

    # --- totals (computed) ---
    fee_base_subtotal = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal("0.00"),
                                            help_text="Admission + First Month Tuition + Exam.")
    fee_selected_total = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal("0.00"),
                                             help_text="Total of all selected rows (base + add-ons).")
    fee_total = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal("0.00"),
                                    help_text="Legacy mirror of fee_selected_total.")

    # --- payment tracking ---
    PAYMENT_STATUS = [("pending", "Pending"), ("paid", "Paid"), ("failed", "Failed"), ("approved", "Approved")]
    payment_status = models.CharField(max_length=20, choices=PAYMENT_STATUS, default="pending")
    payment_provider = models.CharField(max_length=30, blank=True)
    payment_txn_id = models.CharField(max_length=100, blank=True, db_index=True)
    paid_at = models.DateTimeField(null=True, blank=True)

    created_at = models.DateTimeField(auto_now_add=True)

    # --- enrollment targets (for profile creation) ---
    enroll_class = models.ForeignKey("content.AcademicClass", on_delete=models.PROTECT, null=True, blank=True)
    enroll_section = models.CharField(max_length=20, blank=True)
    generated_roll = models.PositiveIntegerField(null=True, blank=True)

    # --------- helpers ---------
    def _next_roll(self):
        from django.db.models import Max
        if not self.enroll_class:
            return None
        q = StudentProfile.objects.filter(school_class=self.enroll_class)
        if self.enroll_section:
            q = q.filter(section__iexact=self.enroll_section)
        last = q.aggregate(m=Max("roll_number"))["m"] or 0
        return last + 1

    # --- snapshot & totals ---
    def _snapshot_fees_from_course(self, force: bool = False):
        if not self.desired_course_id:
            return
        c = self.desired_course

        all_zero = all(
            Decimal(getattr(self, f, 0) or 0) == 0
            for f in ("fee_admission", "fee_tuition", "fee_exam", "fee_bus", "fee_hostel", "fee_marksheet")
        )
        if not force and not all_zero:
            return

        self.fee_admission = Decimal(c.admission_fee or 0)
        self.fee_tuition = Decimal(c.first_month_tuition or 0)
        self.fee_exam = Decimal(c.exam_fee or 0)
        self.fee_bus = Decimal(c.bus_fee or 0)
        self.fee_hostel = Decimal(c.hostel_fee or 0)
        self.fee_marksheet = Decimal(c.marksheet_fee or 0)

    def _recompute_totals(self):
        adm = Decimal(self.fee_admission or 0)
        tui = Decimal(self.fee_tuition or 0)
        exm = Decimal(self.fee_exam or 0)
        bus = Decimal(self.fee_bus or 0) if self.add_bus else Decimal("0.00")
        hst = Decimal(self.fee_hostel or 0) if self.add_hostel else Decimal("0.00")
        mks = Decimal(self.fee_marksheet or 0) if self.add_marksheet else Decimal("0.00")

        self.fee_base_subtotal = adm + tui + exm
        self.fee_selected_total = self.fee_base_subtotal + bus + hst + mks
        self.fee_total = self.fee_selected_total  # legacy mirror

    # --- validation & save ---
    def clean(self):
        super().clean()
        self.add_admission = True
        self.add_tuition = True
        self.add_exam = True

    def save(self, *args, **kwargs):
        self.add_admission = True
        self.add_tuition = True
        self.add_exam = True
        course_changed = False
        if self.pk:
            try:
                old = type(self).objects.only("desired_course_id").get(pk=self.pk)
                course_changed = (old.desired_course_id != self.desired_course_id)
            except type(self).DoesNotExist:
                course_changed = True
        else:
            course_changed = True

        if self.desired_course_id:
            self._snapshot_fees_from_course(force=course_changed)

        self._recompute_totals()
        super().save(*args, **kwargs)

    def approve(self, request=None, by_user=None, create_first_month_invoice=True):
        # --- Imports to avoid circulars ---
        from django.contrib.auth import get_user_model
        from decimal import Decimal
        from django.utils import timezone
        from django.db.models import Max
        
        # Import models
        from .models import (
            Member, StudentProfile, ExamTerm, StudentMarksheet, Subject, StudentMarksheetItem,
            TuitionInvoice 
        )
        # Import helpers
        from .models import _ensure_custom_invoice, _ensure_monthly_invoice

        User = get_user_model()

        # ---------------------------------------------------------
        # ✅ STEP 1: Create or attach User
        # ---------------------------------------------------------
        # Guard Clause: Need a class to assign roll number
        if not self.enroll_class:
            return None

        user = None
        if self.email:
            user = User.objects.filter(email__iexact=self.email).first()
        if not user and self.phone:
            user = User.objects.filter(username__iexact=self.phone).first()

        if not user:
            base_name = (self.phone or self.email or "").split("@")[0]
            if not base_name:
                base_name = f"std{int(timezone.now().timestamp())}"
            
            username = base_name
            counter = 1
            while User.objects.filter(username=username).exists():
                username = f"{base_name}{counter}"
                counter += 1

            user = User.objects.create(
                username=username,
                email=self.email or None,
                first_name=(self.full_name or "").split(" ")[0],
                last_name=" ".join((self.full_name or "").split(" ")[1:])
            )

        if getattr(user, "role", None) != "STUDENT":
            user.role = "STUDENT"
            user.save(update_fields=["role"])

        # ---------------------------------------------------------
        # ✅ STEP 2: Calculate Guaranteed Unique Roll Number
        # ---------------------------------------------------------
        target_section = (self.enroll_section or "").strip().upper()
        
        max_roll_agg = StudentProfile.objects.filter(
            school_class=self.enroll_class,
            section__iexact=target_section
        ).aggregate(m=Max("roll_number"))
        
        current_max = max_roll_agg.get("m") or 0
        candidate_roll = current_max + 1

        while StudentProfile.objects.filter(
            school_class=self.enroll_class,
            section__iexact=target_section,
            roll_number=candidate_roll
        ).exists():
            candidate_roll += 1

        # ---------------------------------------------------------
        # ✅ STEP 3: Update Student Profile
        # ---------------------------------------------------------
        # This handles both scenarios: 
        # 1. User registered -> Empty profile exists -> We UPDATE it.
        # 2. Admin entered user manually -> No profile -> We CREATE it.
        sp, created_sp = StudentProfile.objects.get_or_create(user=user)

        # Force assignment of class/roll now that approval is happening
        sp.school_class = self.enroll_class
        sp.section = target_section
        sp.roll_number = candidate_roll
        
        if not sp.joined_on:
            sp.joined_on = timezone.now().date()
            
        if self.photo: 
            sp.profile_img = self.photo
            
        # Set fees based on application choices
        sp.has_bus_service = bool(self.add_bus)
        sp.has_hostel_seat = bool(self.add_hostel)
        sp.bus_monthly_fee = self.fee_bus
        sp.hostel_monthly_fee = self.fee_hostel
        sp.monthly_fee = self.fee_tuition
        
        sp.save()

        # ---------------------------------------------------------
        # ✅ STEP 4: Create/Sync Public Member Profile
        # ---------------------------------------------------------
        # Check if linked, otherwise try to find by name/role
        member = sp.member_profile
        if not member:
            existing_members = Member.objects.filter(name=self.full_name, role=Member.Role.STUDENT)
            member = existing_members.first()
        
        if not member:
            member = Member.objects.create(
                role=Member.Role.STUDENT,
                name=self.full_name,
                is_active=True,
            )
        
        # Sync data
        member.post = f"Student, {sp.school_class.name}"
        member.section = sp.section
        if self.photo: 
            member.photo = self.photo
        member.save()

        if sp.member_profile != member:
            sp.member_profile = member
            sp.save(update_fields=['member_profile'])

        # ---------------------------------------------------------
        # 🚨 STEP 5: Finalize Approval Status
        # ---------------------------------------------------------
        self.generated_roll = sp.roll_number
        self.payment_status = "approved"
        self.save(update_fields=["generated_roll", "payment_status"])

        # ---------------------------------------------------------
        # ✅ STEP 6: Generate Invoices
        # ---------------------------------------------------------
        today = timezone.localdate()
        
        if getattr(self, 'add_admission', True) and self.fee_admission > 0:
            _ensure_custom_invoice(user, "Admission Fee", self.fee_admission, due_date=today)
        
        if getattr(self, 'add_exam', True) and self.fee_exam > 0:
            _ensure_custom_invoice(user, "Exam Fee", self.fee_exam, due_date=today)

        if self.add_marksheet and self.fee_marksheet > 0:
            _ensure_custom_invoice(user, "Exact Marksheet", self.fee_marksheet, due_date=today)

        if create_first_month_invoice:
            if getattr(self, 'add_tuition', True) and self.fee_tuition > 0:
                _ensure_monthly_invoice(user, today.year, today.month, self.fee_tuition, due_date=today.replace(day=28))
            
            if self.add_bus and self.fee_bus > 0:
                _ensure_custom_invoice(user, f"Bus Service ({today.strftime('%b %Y')})", self.fee_bus, due_date=today)

            if self.add_hostel and self.fee_hostel > 0:
                _ensure_custom_invoice(user, f"Hostel Fee ({today.strftime('%b %Y')})", self.fee_hostel, due_date=today)

        # ---------------------------------------------------------
        # ✅ STEP 7: Create Marksheet (If active term exists)
        # ---------------------------------------------------------
        current_term = ExamTerm.objects.filter(is_active=True).order_by("-start_date", "-id").first()
        if current_term:
            try:
                ms, created = StudentMarksheet._base_manager.get_or_create(
                    school_class=sp.school_class,
                    term=current_term,
                    student_full_name=sp.user.get_full_name(),
                    roll_number=sp.roll_number,
                    section=sp.section,
                    defaults={
                        "created_by": by_user or (request.user if request else None),
                        "is_published": True,
                    },
                )
                if created:
                    subjects = Subject.objects.filter(school_class=sp.school_class, is_active=True).order_by("order", "name", "id")
                    for idx, subj in enumerate(subjects, start=1):
                        StudentMarksheetItem.objects.get_or_create(
                            marksheet=ms,
                            subject=subj,
                            defaults={"max_marks": 100, "marks_obtained": 0, "order": idx},
                        )
                    if request:
                        from django.contrib import messages
                        messages.success(request, f"✅ Marksheet created for {sp.user.get_full_name()}")
                elif request:
                    from django.contrib import messages
                    messages.info(request, f"ℹ️ Marksheet already exists for {sp.user.get_full_name()}")

            except Exception:
                import traceback
                if request:
                    from django.contrib import messages
                    messages.error(request, "🔥 Marksheet failed: " + traceback.format_exc())

        return sp
    class Meta:
        ordering = ("-created_at",)
        verbose_name = "Admission Application"
        verbose_name_plural = "Admission Applications"

    def __str__(self):
        return f"{self.full_name} — {self.desired_course}"

    def mark_paid(self, provider: str, txn_id: str):
        self.payment_provider = provider
        self.payment_txn_id = txn_id
        self.payment_status = "paid"
        self.paid_at = timezone.now()
        self.save(update_fields=["payment_provider", "payment_txn_id", "payment_status", "paid_at"])

# --- 1. Helper to check approval status ---
def _is_student_approved_for_invoicing(student_user):
    """
    Security Gate: Returns True ONLY if the student has an 
    AdmissionApplication that is 'paid' or 'approved'.
    """
    # Always allow invoices for Admin/Staff (for testing)
    if student_user.is_staff or student_user.is_superuser:
        return True

    # Build the query: Match by Email OR Phone
    match_query = Q()
    
    if student_user.email:
        match_query |= Q(email__iexact=student_user.email)
    
    if student_user.username:
        match_query |= Q(phone__iexact=student_user.username)

    if not match_query:
        return False

    # Check if an Approved application exists
    # (We removed 'user=' because that field does not exist)
    return AdmissionApplication.objects.filter(
        match_query,
        payment_status__in=['paid', 'approved']
    ).exists()


# --- 2. Secured Custom Invoice Function ---
def _ensure_custom_invoice(student, title, amount, due_date=None):
    """
    Idempotent custom invoice (one-time). 
    SECURED: Will silently fail (return None) if student is not approved.
    """
    # 🔒 SECURITY GUARD 🔒
    if not _is_student_approved_for_invoicing(student):
        return None 

    amount = Decimal(amount or 0)
    if amount <= 0:
        return None
        
    qs = TuitionInvoice.objects.filter(
        student=student, kind="custom", title=title, paid_amount__lt=F("tuition_amount")
    )
    inv = qs.first()
    if inv:
        return inv
        
    return TuitionInvoice.objects.create(
        student=student,
        kind="custom",
        title=title,
        tuition_amount=amount,
        due_date=due_date or timezone.localdate(),
    )


# --- 3. Secured Monthly Invoice Function ---
def _ensure_monthly_invoice(student, year, month, amount, due_date=None):
    """
    Unique monthly invoice.
    SECURED: Will silently fail (return None) if student is not approved.
    """
    # 🔒 SECURITY GUARD 🔒
    if not _is_student_approved_for_invoicing(student):
        return None 

    amount = Decimal(amount or 0)
    
    inv, created = TuitionInvoice.objects.get_or_create(
        student=student,
        kind="monthly",
        period_year=year,
        period_month=month,
        defaults={
            "tuition_amount": amount,
            "due_date": due_date or timezone.localdate().replace(day=28),
        },
    )
    # Sync amount if unpaid
    if not created and inv.paid_amount == 0 and amount > 0 and inv.tuition_amount != amount:
        inv.tuition_amount = amount
        if not inv.due_date:
            inv.due_date = due_date or timezone.localdate().replace(day=28)
        inv.save(update_fields=["tuition_amount", "due_date"])
        
    return inv


# ---------- Function Highlights ----------

class FunctionHighlight(TimeStampedModel, ImageUrlMixin):
    objects = ActiveManager()
    """
    A single highlight block for college functions/events.
    One image per item; provides a simple alternating left/right layout on the homepage.
    """
    title = models.CharField(
        max_length=200,
        help_text="Headline for the function highlight (shown over the image).",
    )
    image = models.ImageField(
        upload_to="functions/",
        help_text="Primary image for this highlight.",
    )
    place = models.CharField(
        max_length=200, blank=True,
        help_text="Venue/location (optional).",
    )
    date_text = models.CharField(
        max_length=120, blank=True,
        help_text="Human-friendly date (e.g., 'December 20, 2025').",
    )
    time_text = models.CharField(
        max_length=120, blank=True,
        help_text="Human-friendly time (e.g., '4:00 PM onwards').",
    )
    description = models.TextField(
        blank=True,
        help_text="Short description shown beside the image.",
    )
    order = models.PositiveIntegerField(
        default=0,
        help_text="Lower numbers appear first.",
    )
    is_active = models.BooleanField(
        default=True,
        help_text="Untick to hide this highlight from the site.",
    )

    class Meta:
        ordering = ["order", "-id"]
        verbose_name = "Function Highlight"
        verbose_name_plural = "Function Highlights"

    def __str__(self):
        return self.title

    @property
    def image_src(self):
        """Return a safe URL for the image (empty string if unavailable)."""
        if self.image:
            try:
                return self.image.url
            except Exception:
                pass
        return ""


# ---------- College Festivals ----------

# --- HELPER FUNCTION ---
# This helper will parse the ID from any YouTube URL
def get_youtube_id(url):
    """
    Extracts the YouTube video ID from a URL.
    Handles watch, embed, and youtu.be links.
    """
    if not url:
        return None
    try:
        # Regex to find the ID in various URL formats
        regex = r'(?:youtube\.com\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?\/\s]{11})'
        match = re.search(regex, url)
        return match.group(1) if match else None
    except Exception:
        return None

# --- UPDATED CollegeFestival MODEL ---
class CollegeFestival(TimeStampedModel, ImageUrlMixin):
    objects = ActiveManager()
    """
    A festival page/card containing an optional hero (image/video/YouTube),
    description, and a gallery of media items (see FestivalMedia).
    """
    title = models.CharField(
        max_length=200,
        help_text="Festival title as shown on cards and headers.",
    )
    slug = models.SlugField(
        unique=True,
        help_text="Unique slug for links. Auto-fill from title if unsure.",
    )
    place = models.CharField(
        max_length=200, blank=True,
        help_text="Venue/location (optional).",
    )
    date_text = models.CharField(
        max_length=100, blank=True,
        help_text="Friendly date (e.g., 'Feb 15, 2025').",
    )
    time_text = models.CharField(
        max_length=100, blank=True,
        help_text="Friendly time (e.g., '6:00 PM onwards').",
    )
    description = models.TextField(
        blank=True,
        help_text="Festival summary/notes.",
    )

    # Optional hero
    hero_image = models.ImageField(
        upload_to="festivals/hero/", blank=True, null=True,
        help_text="Hero image for the festival card/header (optional).",
    )
    hero_video = models.FileField(
        upload_to="festivals/video/", blank=True, null=True,
        help_text="Upload an MP4 (or similar) to play in modal (optional).",
    )
    hero_youtube_url = models.URLField(
        blank=True,
        help_text="Paste any YouTube link (watch/shorts/embed/youtu.be).",
    )
    
    # --- ⭐ NEW FIELD ---
    # This stores the ID for the frontend, just like your Gallery
    hero_youtube_id = models.CharField(
        max_length=20, blank=True, editable=False,
        help_text="Auto-extracted from the URL."
    )

    is_active = models.BooleanField(
        default=True,
        help_text="Untick to hide this festival.",
    )
    order = models.PositiveIntegerField(
        default=0,
        help_text="Lower numbers appear first across all festivals.",
    )

    created_by = models.ForeignKey(
        settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL,
        related_name="festivals_created",
        help_text="User who created this record (optional).",
    )
    # created_at and updated_at are inherited from TimeStampedModel

    class Meta:
        ordering = ("order", "-created_at")
        verbose_name = "College Festival"
        verbose_name_plural = "College Festivals"

    def __str__(self):
        return self.title

    # --- ⭐ NEW SAVE METHOD ---
    # This automatically extracts the ID from the URL before saving
    def save(self, *args, **kwargs):
        if self.hero_youtube_url:
            self.hero_youtube_id = get_youtube_id(self.hero_youtube_url)
        else:
            self.hero_youtube_id = ""
        super().save(*args, **kwargs)


# --- UPDATED FestivalMedia MODEL ---
class FestivalMedia(TimeStampedModel, ImageUrlMixin):
    objects = ActiveManager()
    """
    A single media item belonging to a CollegeFestival.
    Supports both images and YouTube links with an optional custom thumbnail.
    """
    KIND_CHOICES = (
        ("image", "Image"),
        ("youtube", "YouTube"),
    )

    festival = models.ForeignKey(
        CollegeFestival, on_delete=models.CASCADE, related_name="media_items",
        help_text="Festival this media belongs to.",
    )
    kind = models.CharField(
        max_length=20, choices=KIND_CHOICES, default="image",
        help_text="Choose 'Image' for uploads or 'YouTube' for embedded videos.",
    )

    image = models.ImageField(
        upload_to="festivals/gallery/", blank=True, null=True,
        help_text="Upload the image (required if kind=Image).",
    )
    youtube_url = models.URLField(
        blank=True,
        help_text="Paste any YouTube link (required if kind=YouTube).",
    )
    
    # --- ⭐ NEW FIELD ---
    # This stores the ID for the frontend, just like your Gallery
    youtube_id = models.CharField(
        max_length=20, blank=True, editable=False,
        help_text="Auto-extracted from the URL."
    )

    thumbnail = models.ImageField(
        upload_to="festivals/thumbs/", blank=True, null=True,
        help_text="Optional thumbnail (otherwise we try to display the original).",
    )

    caption = models.CharField(
        max_length=200, blank=True,
        help_text="Short caption or credit for the media item.",
    )
    order = models.PositiveIntegerField(
        default=0,
        help_text="Lower numbers appear first within the festival gallery.",
    )
    is_active = models.BooleanField(
        default=True,
        help_text="Untick to hide this media item.",
    )

    class Meta:
        ordering = ("order", "id")
        verbose_name = "Festival Media"
        verbose_name_plural = "Festival Media"

    def __str__(self):
        base = self.caption or (self.image.name if self.image else self.youtube_url) or "item"
        return f"{self.festival.title} – {base}"

    # --- ⭐ NEW SAVE METHOD ---
    # This automatically extracts the ID from the URL before saving
    def save(self, *args, **kwargs):
        if self.kind == 'youtube' and self.youtube_url:
            self.youtube_id = get_youtube_id(self.youtube_url)
        else:
            self.youtube_id = ""
        super().save(*args, **kwargs)


# ---------- People ----------

class Member(TimeStampedModel, ImageUrlMixin):
    objects = ActiveManager()
    """
    People directory for the site: HOD, Teachers, Students, and Staff.
    You can optionally supply a hosted image via 'photo_url' if 'photo' is empty.
    """

    class Role(models.TextChoices):
        HOD = "hod", "Head of Department"
        TEACHER = "teacher", "Teacher"
        STUDENT = "student", "Student"
        STAFF = "staff", "Staff"

    is_active = models.BooleanField(
        default=True,
        help_text="Untick to hide this member from listings.",
    )
    order = models.PositiveIntegerField(
        default=0,
        help_text="Lower numbers appear first within the same role.",
    )

    role = models.CharField(
        max_length=20, choices=Role.choices,
        help_text="Which group this member belongs to.",
    )
    name = models.CharField(
        max_length=120,
        help_text="Full display name.",
    )
    post = models.CharField(
        max_length=120, blank=True,
        help_text="Designation/position (e.g., Professor, Lab Assistant).",
    )
    section = models.CharField(
        max_length=10, blank=True,
        help_text="Student section if relevant (e.g., 'A').",
    )
    bio = models.TextField(
        blank=True,
        help_text="Short bio/intro (optional, shown in detail cards).",
    )

    photo = models.ImageField(
        upload_to="members/", blank=True, null=True,
        help_text="Upload a headshot (square images look best).",
    )
    photo_url = models.URLField(
        blank=True,
        help_text="Remote image URL if you prefer not to upload.",
    )

    created_by = models.ForeignKey(
        User, null=True, blank=True, on_delete=models.SET_NULL,
        related_name="members_created",
        help_text="User who created this record (optional).",
    )
    created_at = models.DateTimeField(
        auto_now_add=True,
        help_text="When this profile was created.",
    )
    updated_at = models.DateTimeField(
        auto_now=True,
        help_text="When this profile was last updated.",
    )

    class Meta:
        ordering = ("order", "name")
        verbose_name = "Member"
        verbose_name_plural = "Members"

    def __str__(self):
        return f"{self.name} ({self.get_role_display()})"

    @property
    def image_src(self):
        """Return the best available image URL (uploaded photo, else photo_url, else empty)."""
        try:
            if self.photo and self.photo.url:
                return self.photo.url
        except Exception:
            pass
        return self.photo_url or ""


# ---------- Contact ----------

class Contact(TimeStampedModel, ImageUrlMixin):
    objects = ActiveManager()
    """
    Single source of truth for the Contact section of your site.
    Add one active record; the latest active one is shown on the homepage.
    """
    is_active = models.BooleanField(
        default=True,
        help_text="Untick to stop showing this record on the site.",
    )
    address = models.CharField(
        max_length=255, blank=True,
        help_text="Street address and city/district.",
    )
    phone = models.CharField(
        max_length=100, blank=True,
        help_text="Primary phone number (can include multiple, separated by commas).",
    )
    email = models.EmailField(
        blank=True,
        help_text="Public contact email.",
    )
    hours = models.CharField(
        max_length=255, blank=True,
        help_text="Opening hours (e.g., 'Mon–Sat: 8:00 AM – 5:00 PM').",
    )
    map_embed_src = models.TextField(
        blank=True,
        help_text="Paste a Google Maps <iframe> src URL or an embeddable map URL.",
    )
    updated_at = models.DateTimeField(
        auto_now=True,
        help_text="When this record was last updated.",
    )

    class Meta:
        # 2. RENAME THE META
        verbose_name = "Contact"
        verbose_name_plural = "Contact"

    def __str__(self):
        # 3. RENAME THE STRING METHOD
        return f"Contact ({'active' if self.is_active else 'inactive'})"


class ContactMessage(TimeStampedModel, ImageUrlMixin):
    objects = ActiveManager()
    """
    Messages submitted from the site contact form. Use the 'handled' flag to
    mark messages as processed in the admin.
    """
    name = models.CharField(
        max_length=120,
        help_text="Sender’s name.",
    )
    email = models.EmailField(
        help_text="Sender’s email address.",
    )
    subject = models.CharField(
        max_length=200,
        help_text="Short subject line for the message.",
    )
    message = models.TextField(
        help_text="The user’s message or inquiry.",
    )
    phone = models.CharField(
        max_length=50, blank=True,
        help_text="Optional phone number for follow-up.",
    )
    website = models.CharField(
        max_length=200, blank=True,
        help_text="Honeypot anti-spam (should stay empty for humans).",
    )

    created_at = models.DateTimeField(
        auto_now_add=True,
        help_text="When this message was received.",
    )
    handled = models.BooleanField(
        default=False,
        help_text="Tick when the message has been replied to/resolved.",
    )

    class Meta:
        ordering = ("-created_at",)
        verbose_name = "Contact Message"
        verbose_name_plural = "Contact Messages"

    def __str__(self):
        return f"{self.name} <{self.email}> — {self.subject[:40]}"


# ---------- Footer ----------

class FooterSettings(TimeStampedModel, ImageUrlMixin):
    objects = ActiveManager()
    """
    Global footer configuration. Create/keep one active record; the latest active
    one is rendered on the site. Lets non-technical admins control links, socials,
    and branding without touching templates.
    """
    is_active = models.BooleanField(
        default=True,
        help_text="Only active records are considered on the website.",
    )
    title = models.CharField(
        max_length=120, default="Titu BD Science College",
        help_text="Display title/brand next to contact info.",
    )

    # Contact block
    address = models.CharField(
        max_length=255, blank=True,
        help_text="Footer contact address.",
    )
    phone = models.CharField(
        max_length=120, blank=True,
        help_text="Footer phone (can include multiple, separated by commas).",
    )
    email = models.EmailField(
        blank=True,
        help_text="Footer email address.",
    )

    # Quick links
    link_home_enabled = models.BooleanField(
        default=True,
        help_text="Include the Home link.",
    )
    link_admission_label = models.CharField(
        max_length=80, default="Admission",
        help_text="Text label for the Admission link.",
    )
    link_admission_url = models.URLField(
        blank=True,
        help_text="URL to your admission page/form.",
    )
    link_results_label = models.CharField(
        max_length=80, default="Results",
        help_text="Text label for the Results link.",
    )
    link_results_url = models.URLField(
        blank=True,
        help_text="URL to your results portal/page.",
    )
    link_events_label = models.CharField(
        max_length=80, default="Events (Highlights)",
        help_text="Text label for the Events anchor link.",
    )
    link_events_anchor = models.CharField(
        max_length=120, default="#college-functions",
        help_text="Anchor or URL to your events/highlights section.",
    )

    # Socials
    facebook_url = models.URLField(
        blank=True,
        help_text="Link to your Facebook page.",
    )
    whatsapp_url = models.URLField(
        blank=True,
        help_text="Link to your WhatsApp (group/business/profile).",
    )
    twitter_url = models.URLField(
        blank=True,
        help_text="Link to your Twitter/X profile.",
    )
    email_linkto = models.EmailField(
        blank=True,
        help_text="Different contact email for the mail icon (defaults to footer email).",
    )

    # Branding
    logo = models.ImageField(
        upload_to="footer/", blank=True, null=True,
        help_text="Footer brand/logo image (small).",
    )
    logo_url = models.URLField(
        blank=True,
        help_text="Remote logo URL if you don't want to upload a file.",
    )

    # Credits / copyright
    copyright_name = models.CharField(
        max_length=160, default="Titu BD Science College",
        help_text="Name displayed in copyright line.",
    )
    developer_name = models.CharField(
        max_length=160, default="DS",
        help_text="Developer/vendor credit name.",
    )
    developer_url = models.URLField(
        blank=True, default="https://t2bd.com",
        help_text="Link for the developer credit.",
    )

    # Audit
    created_by = models.ForeignKey(
        getattr(settings, "AUTH_USER_MODEL", "auth.User"),
        null=True, blank=True, on_delete=models.SET_NULL,
        related_name="footer_settings_created",
        help_text="User who created this footer config (optional).",
    )
    created_at = models.DateTimeField(
        auto_now_add=True,
        help_text="When this footer record was created.",
    )
    updated_at = models.DateTimeField(
        auto_now=True,
        help_text="When this footer record was last updated.",
    )

    class Meta:
        verbose_name = "Footer Settings"
        verbose_name_plural = "Footer Settings"

    def __str__(self):
        return f"Footer: {self.title} (active={self.is_active})"

    @property
    def logo_src(self) -> str:
        """Return a usable logo URL (uploaded file wins; fallback to remote URL)."""
        try:
            if self.logo and self.logo.url:
                return self.logo.url
        except Exception:
            pass
        return self.logo_url or ""


def gallery_upload_image_to(instance, filename):
    dt = timezone.now()
    return f"gallery/images/{dt:%Y/%m}/{filename}"


def gallery_upload_video_to(instance, filename):
    dt = timezone.now()
    return f"gallery/videos/{dt:%Y/%m}/{filename}"


class GalleryPost(TimeStampedModel, ImageUrlMixin):
    objects = ActiveManager()
    KIND_IMAGE = "image"
    KIND_VIDEO = "video"
    KIND_YT = "youtube"
    KIND_CHOICES = [
        (KIND_IMAGE, "Image"),
        (KIND_VIDEO, "Video (MP4)"),
        (KIND_YT, "YouTube URL"),
    ]

    is_active = models.BooleanField(default=True)
    order = models.PositiveIntegerField(default=0, help_text="Lower numbers appear first.")
    kind = models.CharField(max_length=20, choices=KIND_CHOICES, default=KIND_IMAGE)

    title = models.CharField(max_length=200)
    # one of the following depending on kind
    image = models.ImageField(upload_to=gallery_upload_image_to, blank=True, null=True)
    video = models.FileField(upload_to=gallery_upload_video_to, blank=True, null=True)  # e.g. .mp4
    youtube_url = models.URLField(blank=True)

    created_by = models.ForeignKey(
        getattr(settings, "AUTH_USER_MODEL", "auth.User"),
        null=True, blank=True, on_delete=models.SET_NULL, related_name="gallery_posts_created"
    )
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        ordering = ("order", "-created_at")

    def __str__(self):
        return self.title

    @property
    def thumb_src(self) -> str:
        """
        For image: the image URL.
        For video: use the video tag (no thumb) — template shows a play badge.
        For YouTube: try to render the standard thumbnail.
        """
        if self.kind == self.KIND_IMAGE and self.image:
            try:
                return self.image.url
            except Exception:
                return ""
        if self.kind == self.KIND_YT and self.youtube_id:
            # default thumbnail
            return f"https://img.youtube.com/vi/{self.youtube_id}/hqdefault.jpg"
        return ""

    @property
    def youtube_id(self) -> str:
        """
        Extract a video ID from common YouTube URL shapes (watch, youtu.be, shorts, embed).
        """
        import re
        url = (self.youtube_url or "").strip()
        if not url:
            return ""
        # patterns: youtu.be/ID, v=ID, /embed/ID, /shorts/ID
        for pat in [
            r"youtu\.be/([A-Za-z0-9_-]{6,})",
            r"[?&]v=([A-Za-z0-9_-]{6,})",
            r"/embed/([A-Za-z0-9_-]{6,})",
            r"/shorts/([A-Za-z0-9_-]{6,})",
        ]:
            m = re.search(pat, url)
            if m:
                return m.group(1)
        return ""


class AcademicClass(TimeStampedModel, ImageUrlMixin):
    objects = ActiveManager()
    name = models.CharField(
        max_length=80,
        help_text='Class name as shown to users, e.g. "Class 8" or "Grade 10".'
    )
    section = models.CharField(
        max_length=20,
        blank=True,
        help_text='Optional section/stream label, e.g. "A", "Science". Leave empty if not used.'
    )
    year = models.PositiveIntegerField(
        default=timezone.now().year,
        help_text="Academic year for this class (e.g., 2025)."
    )

    class Meta:
        unique_together = ("name", "section", "year")
        ordering = ("-year", "name", "section")

    def __str__(self):
        return f"{self.name}{' - ' + self.section if self.section else ''} ({self.year})"


class Subject(TimeStampedModel, ImageUrlMixin):
    objects = ActiveManager()
    """
    A subject belongs to a specific AcademicClass (grade).
    e.g., Class 10 → Physics, Chemistry, Math…
    """
    school_class = models.ForeignKey("content.AcademicClass", on_delete=models.PROTECT, related_name="subjects",
                                     null=True, blank=True, )
    name = models.CharField(max_length=120)
    order = models.PositiveIntegerField(default=0)
    is_active = models.BooleanField(default=True)

    class Meta:
        unique_together = [("school_class", "name")]
        ordering = ("school_class", "order", "name")

    def __str__(self):
        sec = f" – {self.school_class.section}" if (self.school_class and self.school_class.section) else ""
        sc = f"{self.school_class.name}{sec} {self.school_class.year}" if self.school_class else "Unassigned"
        return f"{self.name or '—'} ({sc})"


class ExamTerm(TimeStampedModel, ImageUrlMixin):
    objects = ActiveManager()

    name = models.CharField(
        max_length=80,
        help_text='Exam term name, e.g. "Midterm", "Final", "Term 1".'
    )
    year = models.PositiveIntegerField(
        default=timezone.now().year,
        help_text="Calendar year of the exam term (e.g., 2025)."
    )
    start_date = models.DateField(
        null=True,
        blank=True,
        help_text="Optional date when the exam term starts (for reference)."
    )

    # ✅ Add this new field
    is_active = models.BooleanField(
        default=True,
        help_text="Tick to mark this term as the current active exam term."
    )

    class Meta:
        unique_together = ("name", "year")
        ordering = ("-year", "name")

    def __str__(self):
        return f"{self.name} {self.year}"


class ClassResultSummary(TimeStampedModel, ImageUrlMixin):
    objects = ActiveManager()
    """
    One row per (Class, Term) with class-level aggregates only.
    """
    klass = models.ForeignKey(
        AcademicClass,
        on_delete=models.CASCADE,
        related_name="result_summaries",
        help_text="Which class these results belong to."
    )
    term = models.ForeignKey(
        ExamTerm,
        on_delete=models.CASCADE,
        related_name="result_summaries",
        help_text="Which exam term these results summarize."
    )

    total_students = models.PositiveIntegerField(
        default=0,
        help_text="Total students enrolled in the class."
    )
    appeared = models.PositiveIntegerField(
        default=0,
        help_text="Number of students who appeared for the exam in this term."
    )
    pass_rate_pct = models.DecimalField(
        max_digits=5, decimal_places=2, default=0,
        validators=[MinValueValidator(0), MaxValueValidator(100)],
        help_text="Pass percentage for the class (0–100)."
    )
    overall_avg_pct = models.DecimalField(
        max_digits=5, decimal_places=2, default=0,
        validators=[MinValueValidator(0), MaxValueValidator(100)],
        help_text="Overall average percentage for the class (0–100)."
    )
    highest_pct = models.DecimalField(
        max_digits=5, decimal_places=2, default=0,
        validators=[MinValueValidator(0), MaxValueValidator(100)],
        help_text="Highest percentage achieved in the class (0–100)."
    )
    lowest_pct = models.DecimalField(
        max_digits=5, decimal_places=2, default=0,
        validators=[MinValueValidator(0), MaxValueValidator(100)],
        help_text="Lowest percentage achieved in the class (0–100)."
    )
    remarks = models.TextField(
        blank=True,
        help_text="Optional notes or remarks shown on the class result page."
    )

    created_at = models.DateTimeField(
        auto_now_add=True,
        help_text="Timestamp when this summary was created (auto)."
    )

    class Meta:
        unique_together = ("klass", "term")
        ordering = ("-created_at",)

    def __str__(self):
        return f"{self.klass} — {self.term}"


class ClassResultSubjectAvg(TimeStampedModel, ImageUrlMixin):
    objects = ActiveManager()
    """
    Optional per-subject class averages.
    """
    summary = models.ForeignKey(
        ClassResultSummary,
        on_delete=models.CASCADE,
        related_name="subject_avgs",
        help_text="Select the (Class, Term) summary this average belongs to."
    )
    subject = models.ForeignKey(
        Subject,
        on_delete=models.PROTECT,
        related_name="class_avgs",
        help_text="Subject for which you are recording the class average."
    )
    avg_score = models.DecimalField(
        max_digits=6, decimal_places=2,
        help_text="Average marks the class scored in this subject."
    )
    out_of = models.PositiveIntegerField(
        default=100,
        help_text="Maximum possible marks for this subject average (e.g., 100)."
    )

    class Meta:
        unique_together = ("summary", "subject")
        ordering = ("subject__name",)

    def __str__(self):
        return f"{self.summary} — {self.subject.name}"

    @property
    def avg_pct(self) -> float:
        try:
            return round(float(self.avg_score) / float(self.out_of) * 100.0, 2)
        except Exception:
            return 0.0


def upload_student_profile_to(instance, filename):
    dt = timezone.now()
    return f"students/profiles/{dt:%Y/%m}/{filename}"


class ClassTopper(TimeStampedModel, ImageUrlMixin):
    objects = ActiveManager()
    """
    Toppers for a given class+term summary. No per-student subject rows—just the essentials.
    """
    summary = models.ForeignKey(
        ClassResultSummary,
        on_delete=models.CASCADE,
        related_name="toppers",
        help_text="Select the (Class, Term) summary this topper belongs to."
    )
    rank = models.PositiveIntegerField(
        default=1,
        help_text="Rank in the class for this term (1 = topper)."
    )
    name = models.CharField(
        max_length=120,
        help_text="Student full name as it should appear on the site."
    )
    roll_no = models.CharField(
        max_length=40,
        blank=True,
        help_text="Optional roll number / ID for reference."
    )
    profile_image = models.ImageField(
        upload_to=upload_student_profile_to,
        blank=True, null=True,
        help_text="Optional student profile photo (square crop recommended)."
    )

    total_pct = models.DecimalField(
        max_digits=5, decimal_places=2,
        help_text="Overall % (e.g., 92.50)."
    )
    grade = models.CharField(
        max_length=8,
        blank=True,
        help_text='Optional grade label, e.g., "A+", "A".'
    )

    class Meta:
        unique_together = ("summary", "rank")
        ordering = ("rank", "id")

    def __str__(self):
        return f"{self.summary} — Rank {self.rank}: {self.name}"


# ----------------------------
# ATTENDANCE SESSION
# ----------------------------

class AttendanceSession(TimeStampedModel, ImageUrlMixin):
    objects = ActiveManager()

    school_class = models.ForeignKey(
        "content.AcademicClass",
        on_delete=models.CASCADE,
        related_name="attendance_sessions",
    )

    section = models.CharField(max_length=20, blank=True, default="")

    date = models.DateField(default=timezone.localdate)
    created_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.SET_NULL, null=True, blank=True
    )
    created_at = models.DateTimeField(auto_now_add=True)
    notes = models.CharField(max_length=255, blank=True)

    # Auto-calculated fields
    present_count = models.PositiveIntegerField(default=0)
    absent_count = models.PositiveIntegerField(default=0)
    late_count = models.PositiveIntegerField(default=0)
    excused_count = models.PositiveIntegerField(default=0)

    class Meta:
        constraints = [
            models.UniqueConstraint(
                fields=["school_class", "section", "date"],
                name="uniq_class_section_day_session",
            ),
        ]
        ordering = ["-date", "-id"]

    def __str__(self):
        sec = f" – {self.section}" if self.section else ""
        return f"{self.school_class}{sec} – {self.date}"

    @property
    def total_count(self) -> int:
        return self.present_count + self.absent_count + self.late_count + self.excused_count

    @property
    def attendance_rate_pct(self) -> float:
        total = self.total_count
        if not total:
            return 0.0
        return round(100.0 * (self.present_count + self.excused_count) / total, 1)

    def save(self, *args, **kwargs):
        # Normalize section to avoid UniqueConstraint clashes
        self.section = (self.section or "").strip().upper()
        super().save(*args, **kwargs)

    def recalculate_counts(self):
        from .models import StudentAttendance  # avoid circular import

        counts = self.student_records.aggregate(
            present=Count("id", filter=Q(status="P")),
            absent=Count("id", filter=Q(status="A")),
            late=Count("id", filter=Q(status="L")),
            excused=Count("id", filter=Q(status="E")),
        )

        self.present_count = counts["present"] or 0
        self.absent_count = counts["absent"] or 0
        self.late_count = counts["late"] or 0
        self.excused_count = counts["excused"] or 0
        self.save(update_fields=["present_count", "absent_count", "late_count", "excused_count"])


class ExamRoutine(TimeStampedModel, ImageUrlMixin):
    objects = ActiveManager()
    """
    A published exam routine image for a given class and term/semester, with exam dates.
    """
    is_active = models.BooleanField(default=True)

    school_class = models.ForeignKey(
        "content.AcademicClass",
        on_delete=models.CASCADE,
        related_name="exam_routines",
    )
    term = models.ForeignKey(
        "content.ExamTerm",
        on_delete=models.CASCADE,
        related_name="exam_routines",
    )

    title = models.CharField(max_length=160, blank=True)
    routine_image = models.ImageField(upload_to="exam_routines/%Y/%m/", blank=True, null=True)
    routine_image_url = models.URLField(blank=True, default="")  # fallback if you prefer linking

    exam_start_date = models.DateField()
    exam_end_date = models.DateField(blank=True, null=True)  # optional (single-day if empty)
    notes = models.TextField(blank=True, default="")

    created_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.SET_NULL,
        null=True, blank=True, related_name="+"
    )
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        ordering = ("-exam_start_date", "-id")
        constraints = [
            models.CheckConstraint(
                name="exam_date_range_valid",
                check=Q(exam_end_date__gte=F("exam_start_date")) | Q(exam_end_date__isnull=True),
            )
        ]

    def __str__(self):
        base = self.title or f"{self.school_class} — {self.term}"
        if self.exam_end_date and self.exam_end_date != self.exam_start_date:
            return f"{base} ({self.exam_start_date} → {self.exam_end_date})"
        return f"{base} ({self.exam_start_date})"

    @property
    def image_src(self) -> str:
        """Prefer uploaded image, fall back to URL."""
        try:
            if self.routine_image and self.routine_image.url:
                return self.routine_image.url
        except Exception:
            pass
        return self.routine_image_url or ""


class BusRoute(TimeStampedModel, ImageUrlMixin):
    objects = ActiveManager()
    """A school bus route (morning/evening handled by times on stops or notes)."""
    is_active = models.BooleanField(default=True)
    order = models.PositiveIntegerField(default=0)

    name = models.CharField(max_length=120)
    code = models.CharField(max_length=32, blank=True, help_text="Optional short code, e.g. R1")

    start_point = models.CharField(max_length=200, blank=True)
    end_point = models.CharField(max_length=200, blank=True)

    # Human-friendly text like "Mon–Fri" or "All days"
    operating_days_text = models.CharField(max_length=120, blank=True)

    # Contact / vehicle (keep it simple, per-route)
    driver_name = models.CharField(max_length=120, blank=True)
    driver_phone = models.CharField(max_length=50, blank=True)
    assistant_name = models.CharField(max_length=120, blank=True)
    assistant_phone = models.CharField(max_length=50, blank=True)
    vehicle_plate = models.CharField(max_length=50, blank=True)
    vehicle_capacity = models.PositiveIntegerField(default=0, blank=True)

    # Optional media / map
    route_image = models.ImageField(upload_to="bus/routes/%Y/%m/", blank=True, null=True)
    route_image_url = models.URLField(blank=True)
    map_embed_src = models.TextField(blank=True, help_text="Google Maps embed URL (optional)")
    fare_info = models.CharField(max_length=200, blank=True)
    notes = models.TextField(blank=True)

    created_by = models.ForeignKey(
        settings.AUTH_USER_MODEL, null=True, blank=True,
        on_delete=models.SET_NULL, related_name="bus_routes_created"
    )
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        ordering = ("order", "name")

    def __str__(self):
        return self.name or f"Route #{self.pk}"

    @property
    def image_src(self) -> str:
        # mirror pattern used in your other models
        try:
            if self.route_image and self.route_image.url:
                return self.route_image.url
        except Exception:
            pass
        return self.route_image_url or ""


class BusStop(TimeStampedModel, ImageUrlMixin):
    objects = ActiveManager()
    """A stop on a route, with optional time string and geo."""
    route = models.ForeignKey(BusRoute, on_delete=models.CASCADE, related_name="stops")
    is_active = models.BooleanField(default=True)
    order = models.PositiveIntegerField(default=0)

    name = models.CharField(max_length=200)
    landmark = models.CharField(max_length=200, blank=True)

    # keep times as free text to avoid timezone hassles (e.g. "07:25 AM")
    time_text_morning = models.CharField(max_length=20, blank=True)
    time_text_evening = models.CharField(max_length=20, blank=True)

    # optional geo (decimal degrees)
    lat = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
    lng = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)

    class Meta:
        ordering = ("route", "order", "id")

    def __str__(self):
        return f"{self.route.name}: {self.name}"


# tune your pass rules here
PASS_PERCENT_CUTOFF = 40.0  # overall %
SUBJECT_MIN_CUTOFF = 40.0  # per-subject absolute (out of 100)


# expect these to exist elsewhere in this app
# from .models import AcademicClass, ExamTerm, Subject  # if needed, adjust import paths

# 1
class StudentMarksheet(TimeStampedModel, ImageUrlMixin):
    objects = ActiveManager()
    """
    One marksheet per student per class + term.
    Subjects come from Subject objects linked to the same AcademicClass.
    """
    school_class = models.ForeignKey("content.AcademicClass", on_delete=models.PROTECT, related_name="marksheets")
    term = models.ForeignKey("content.ExamTerm", on_delete=models.PROTECT, related_name="marksheets")

    student_full_name = models.CharField(max_length=200)
    roll_number = models.CharField(max_length=50, blank=True, default="")
    section = models.CharField(max_length=50, blank=True, default="")

    notes = models.TextField(blank=True, default="")

    total_marks = models.DecimalField(max_digits=7, decimal_places=2, default=0)  # sum of subject marks
    total_grade = models.CharField(max_length=10, blank=True, default="")  # A+/A/...

    is_pass = models.BooleanField(default=False, editable=False, db_index=True)
    is_published = models.BooleanField(default=True)

    created_by = models.ForeignKey(get_user_model(), on_delete=models.SET_NULL, null=True, blank=True, related_name="+")
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        unique_together = [("school_class", "term", "student_full_name", "roll_number")]
        ordering = ("-updated_at", "student_full_name")

    def __str__(self):
        base = self.student_full_name or "Student"
        return f"{base} — {self.school_class} — {self.term}"

    # ---------- calculations ----------

    def is_final_term(self) -> bool:
        """
        Treat as 'final' if ExamTerm has an `is_final` bool, else fallback to name contains 'final'.
        """
        term = self.term
        if hasattr(term, "is_final"):
            try:
                return bool(getattr(term, "is_final"))
            except Exception:
                pass
        name = (getattr(term, "name", "") or "").lower()
        return "final" in name

    def recalc_totals(self):
        """
        Compute totals using Decimal and keep model fields consistent.
        Returns Decimal total marks.
        """
        items = list(self.items.all())  # assume related_name is 'items'
        total = Decimal("0")
        for i in items:
            try:
                mo = Decimal(str(i.marks_obtained or 0))
            except (InvalidOperation, TypeError):
                mo = Decimal("0")
            total += mo

        # store as Decimal with 2 dp (match DecimalField scale)
        total = total.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
        self.total_marks = total

        # compute percent & grade & pass using existing helpers
        try:
            pct = Decimal(str(self.percent()))
        except Exception:
            pct = Decimal("0")

        # total_grade expects string like "A+"
        self.total_grade = _grade_from_percent(float(pct))
        self.is_pass = self._compute_pass(float(pct), items)
        return self.total_marks

    @property
    def student_name(self):
        return self.student_full_name

    @property
    def total_obtained(self):
        # mirror old name expected by templates
        return float(self.total_marks or 0)

    @property
    def total_out_of(self):
        # total maximum marks (sum of item.max_marks)
        # return as float or Decimal depending on templates — float is safe for display
        return float(self.max_marks_total() or 0)

    @property
    def grade_letter(self):
        return self.total_grade

    @property
    def items_count(self):
        try:
            return self.items.count()
        except Exception:
            # fallback if related name is different
            from reportcards.models import MarkRow
            return MarkRow.objects.filter(marksheet=self).count()

    def _compute_pass(self, pct, items) -> bool:
        # optional: allow pass calculation for all terms
        return float(pct) >= float(PASS_PERCENT_CUTOFF)

    def max_marks_total(self) -> float:
        vals = [float(i.max_marks or 0) for i in self.items.all()]
        return sum(vals) if vals else 0.0

    def percent(self) -> float:
        max_total = self.max_marks_total()
        if not max_total:
            return 0.0
        return round((float(self.total_marks) * 100.0) / float(max_total), 2)

    def save(self, *args, **kwargs):
        """
        Keep totals/grade/pass in sync whenever the sheet is saved.
        (Signals will also do this when item rows change.)
        """
        try:
            # If related manager available, recompute from items.
            _ = self.items.all()
            self.recalc_totals()
        except Exception:
            pass
        super().save(*args, **kwargs)


def _grade_from_percent_decimal(pct: Decimal) -> str:
    """
    Grading ladder using Decimal for precision.
    """
    try:
        pct_value = pct.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
    except (TypeError, InvalidOperation):
        return ""
        
    if pct_value >= Decimal("90.00"): return "A+"
    if pct_value >= Decimal("80.00"): return "A"
    if pct_value >= Decimal("70.00"): return "A-"
    if pct_value >= Decimal("60.00"): return "B"
    if pct_value >= Decimal("50.00"): return "C"
    if pct_value >= Decimal("40.00"): return "D"
    return "F"


def _subject_grade_from_marks(marks_obtained, max_marks) -> str:
    try:
        # Convert model DecimalFields to Decimal objects securely
        mo = Decimal(str(marks_obtained or 0))
        mm = Decimal(str(max_marks or 0))
        
        if mm <= 0:
            return ""
        
        # Calculate percentage using Decimal objects
        pct = (mo * Decimal("100")) / mm
        
        # Pass the Decimal percentage to the grading function
        return _grade_from_percent_decimal(pct)
        
    except (InvalidOperation, TypeError):
        return ""

# 1
class StudentMarksheetItem(TimeStampedModel, ImageUrlMixin):
    objects = ActiveManager()
    """
    Per-subject row for a marksheet.
    Subject belongs to the same AcademicClass as the parent marksheet.
    """
    marksheet = models.ForeignKey(StudentMarksheet, on_delete=models.CASCADE, related_name="items")
    subject = models.ForeignKey("content.Subject", on_delete=models.PROTECT, related_name="marksheet_items")

    max_marks = models.DecimalField(max_digits=6, decimal_places=2, default=100)
    marks_obtained = models.DecimalField(max_digits=6, decimal_places=2, default=0)

    grade_letter = models.CharField(max_length=5, blank=True, default="")
    remark = models.CharField(max_length=200, blank=True, default="")

    order = models.PositiveIntegerField(default=0)

    class Meta:
        unique_together = [("marksheet", "subject")]
        ordering = ("order", "id")

    def __str__(self):
        return f"{self.subject} — {self.marks_obtained}/{self.max_marks}"

    def clean(self):
        # Subject must belong to same class as parent marksheet.
        if self.subject and self.marksheet and getattr(self.subject, "school_class_id",
                                                       None) != self.marksheet.school_class_id:
            from django.core.exceptions import ValidationError
            raise ValidationError("Subject must belong to the same class as the marksheet.")

    def save(self, *args, **kwargs):
        # auto-calc per-subject letter grade
        self.grade_letter = _subject_grade_from_marks(self.marks_obtained, self.max_marks)
        super().save(*args, **kwargs)
        
        # FIX: Trigger parent marksheet total recalculation
        # This will call StudentMarksheet.save(), which calls recalc_totals()
        if self.marksheet:
            self.marksheet.save()


# ---------- signals: keep parent totals in sync when items change ----------

@receiver(post_save, sender=StudentMarksheetItem)
@receiver(post_delete, sender=StudentMarksheetItem)
def _recalc_sheet_totals(sender, instance, **kwargs):
    if getattr(_thread_locals, "skip_recalc", False):
        return
    try:
        _thread_locals.skip_recalc = True
        ms = instance.marksheet
        ms.recalc_totals()
        ms.save(update_fields=["total_marks", "total_grade", "is_pass", "updated_at"])
    finally:
        _thread_locals.skip_recalc = False


# content/models.py  (BOTTOM of file)


# ---------- Categories ----------
class IncomeCategory(TimeStampedModel, ImageUrlMixin):
    objects = ActiveManager()
    # e.g., admission, tuition, bus, donation, etc.
    code = models.SlugField(max_length=50, unique=True)  # stable programmatic key: 'admission', 'tuition'
    name = models.CharField(max_length=120)  # human label: 'Admission Fee'
    is_fixed = models.BooleanField(default=False)  # your built-ins are marked fixed
    is_active = models.BooleanField(default=True)

    class Meta:
        ordering = ["name"]

    def __str__(self):
        return self.name


class ExpenseCategory(TimeStampedModel, ImageUrlMixin):
    objects = ActiveManager()
    # e.g., salary, fuel, bus_repair, equipment_purchase, etc.
    code = models.SlugField(max_length=50, unique=True)
    name = models.CharField(max_length=120)
    is_fixed = models.BooleanField(default=False)
    is_active = models.BooleanField(default=True)

    class Meta:
        ordering = ["name"]

    def __str__(self):
        return self.name


# ---------- Ledger ----------
class Income(TimeStampedModel, ImageUrlMixin):
    objects = ActiveManager()
    category = models.ForeignKey(
        "IncomeCategory",
        on_delete=models.PROTECT,
        related_name="incomes",
        null=True, blank=True
    )
    student = models.ForeignKey(  # NEW
        settings.AUTH_USER_MODEL,
        null=True, blank=True,
        on_delete=models.SET_NULL,
        related_name="incomes",
        help_text="If this income is for a specific student (tuition, exam, bus, etc.)"
    )
    amount = models.DecimalField(max_digits=12, decimal_places=2)
    date = models.DateField(default=timezone.localdate)
    description = models.TextField(blank=True)

    # Optional link back to the originating object (Admission, TuitionInvoice, etc.)
    content_type = models.ForeignKey(ContentType, null=True, blank=True, on_delete=models.SET_NULL)
    object_id = models.PositiveIntegerField(null=True, blank=True)
    content_object = GenericForeignKey("content_type", "object_id")

    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        ordering = ["-date", "-id"]
        indexes = [
             models.Index(fields=["content_type", "object_id"]),
        ]

    def __str__(self):
        cat = self.category.name if self.category_id else "Uncategorized"
        return f"{cat} — {self.amount} on {self.date}"


class Expense(TimeStampedModel, ImageUrlMixin):
    objects = ActiveManager()
    category = models.ForeignKey(ExpenseCategory, on_delete=models.PROTECT, related_name="expenses")
    amount = models.DecimalField(max_digits=12, decimal_places=2)
    date = models.DateField(default=timezone.localdate)
    description = models.TextField(blank=True)
    vendor = models.CharField(max_length=160, blank=True)
    attachment = models.FileField(upload_to="finance/receipts/", blank=True)

    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        ordering = ["-date", "-id"]

    def __str__(self):
        return f"{self.category.name} — {self.amount} on {self.date}"


KIND_CHOICES = (
    ("monthly", "Monthly tuition"),
    ("custom", "Custom"),
)


class TuitionInvoice(TimeStampedModel, ImageUrlMixin):
    objects = ActiveManager()
    student = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        related_name="tuition_invoices",
    )

    # amounts
    tuition_amount = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal("0.00"))
    paid_amount = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal("0.00"))

    # “monthly” vs “custom”
    kind = models.CharField(max_length=20, choices=KIND_CHOICES, default="monthly")
    title = models.CharField(max_length=120, blank=True)

    # “monthly” period (nullable so “custom” can omit)
    period_year = models.IntegerField(null=True, blank=True)
    period_month = models.IntegerField(null=True, blank=True)

    due_date = models.DateField(null=True, blank=True, db_index=True,)
    created_at = models.DateTimeField(default=timezone.now, editable=False)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        ordering = ["period_year", "period_month", "id"]
        constraints = [
            models.UniqueConstraint(
                fields=["student", "kind", "period_year", "period_month"],
                name="uniq_student_monthly_invoice",
                condition=Q(kind="monthly"),
            )
        ]
        indexes = [
            models.Index(fields=["student", "kind"]),
            models.Index(fields=["period_year", "period_month"]),
            models.Index(fields=["paid_amount"]),
        ]

    paid_at = models.DateTimeField(blank=True, null=True)

    def maybe_mark_paid(self):
        """Call this after updating paid_amount."""
        if (self.tuition_amount or 0) <= (self.paid_amount or 0) and not self.paid_at:
            self.paid_at = timezone.now()
            self.save(update_fields=["paid_at"])

    @property
    def period_label(self):
        """Return 'January 2025' for monthly invoices."""
        if self.kind != "monthly":
            return self.title or "Custom Invoice"
        
        import calendar
        try:
            month_name = calendar.month_name[self.period_month]
            return f"{month_name} {self.period_year}"
        except Exception:
            return "Monthly Invoice"

    @property
    def balance(self) -> Decimal:
        return (self.tuition_amount or Decimal("0")) - (self.paid_amount or Decimal("0"))

    def clean(self):
        from django.core.exceptions import ValidationError

        if self.kind == "monthly":
            if not self.period_year or not self.period_month:
                raise ValidationError("Monthly invoices require year and month.")
        if self.kind == "custom" and not (self.title or "").strip():
            raise ValidationError("Custom invoices require a title.")
        super().clean()

    def __str__(self):
        label = self.title or (
            f"{self.period_year}-{self.period_month:02d}"
            if self.period_year and self.period_month else "Invoice"
        )
        return f"{self.get_kind_display()} — {self.student} — {label} — {self.tuition_amount}"

    # -------------------------------------------------------------------------
    # 🔒 SECURITY GUARD: Paste this inside class TuitionInvoice
    # -------------------------------------------------------------------------
    def save(self, *args, **kwargs):
        # 1. Check if this is a NEW invoice being created
        if self._state.adding:
            # 2. Allow Staff/Superusers to bypass check (Safety valve)
            if self.student.is_staff or self.student.is_superuser:
                super().save(*args, **kwargs)
                return

            # 3. The Security Check
            from .models import AdmissionApplication
            from django.db.models import Q

            # Build query to find the application (Email OR Phone)
            query = Q()
            if self.student.email:
                query |= Q(email__iexact=self.student.email)
            
            if self.student.username:
                 query |= Q(phone__iexact=self.student.username)

            if not query:
                # No identifiers? Block.
                return 

            # 4. Is there an APPROVED application?
            is_approved = AdmissionApplication.objects.filter(
                query,
                payment_status__in=['paid', 'approved']
            ).exists()

            if not is_approved:
                # ⛔ BLOCK: Student is not approved yet.
                return 
        
        # 5. Save normally if approved (or if updating existing invoice)
        super().save(*args, **kwargs)
        
        
class TuitionPayment(TimeStampedModel, ImageUrlMixin):
    objects = ActiveManager()
    
    # --- Consolidation 1: Provider Choices (Payment Method) ---
    PROVIDER_STRIPE = "stripe"
    PROVIDER_PAYPAL = "paypal"
    PROVIDER_BKASH = "bkash"
    PROVIDER_MANUAL = "manual"
    
    PROVIDERS = [
        (PROVIDER_STRIPE, "Stripe"),
        (PROVIDER_PAYPAL, "PayPal"),
        (PROVIDER_BKASH, "bKash"),
        (PROVIDER_MANUAL, "Manual"),
    ]
    
    # --- Consolidation 2: Gateway Choices (System Integration) ---
    # NOTE: Your initial code duplicated these blocks. We keep one set.
    GATEWAY_STRIPE = "stripe"
    GATEWAY_PAYPAL = "paypal"
    GATEWAY_MANUAL = "manual"
    GATEWAY_CHOICES = [
        (GATEWAY_STRIPE, "Stripe"),
        (GATEWAY_PAYPAL, "PayPal"),
        (GATEWAY_MANUAL, "Manual"),
    ]

    invoice = models.ForeignKey(
        "content.TuitionInvoice",
        on_delete=models.CASCADE,
        related_name="payments",
    )
    amount = models.DecimalField(max_digits=12, decimal_places=2)
    
    # Use the consolidated PROVIDERS list
    provider = models.CharField(max_length=60, choices=PROVIDERS, blank=True)
    
    # txn_id should be mandatory/unique for online payments.
    txn_id = models.CharField(max_length=120, blank=True, null=True) 
    
    paid_on = models.DateField(default=timezone.localdate)
    # created_at is inherited from TimeStampedModel
    
    # Use the consolidated GATEWAY_CHOICES list
    gateway = models.CharField(max_length=16, choices=GATEWAY_CHOICES, default=GATEWAY_MANUAL)
    gateway_ref = models.CharField(max_length=128, blank=True, null=True, db_index=True)  # PI / charge / capture_id
    gateway_payer_email = models.EmailField(blank=True, null=True)
    gateway_payer_id = models.CharField(max_length=128, blank=True, null=True)
    gateway_payload = models.JSONField(blank=True, null=True)
    paid_at = models.DateTimeField(blank=True, null=True)  # set when funds confirmed

    class Meta:
        ordering = ["-paid_on", "-id"]
        # 💡 Correction 1: Remove redundant GATEWAY_CHOICES definitions
        # 💡 Correction 2: Simplify UniqueConstraint for transactions
        # The constraint should typically enforce uniqueness based on txn_id OR gateway_ref, 
        # or just txn_id and provider, regardless of the invoice, unless you expect the same 
        # txn_id to pay multiple invoices (unlikely for final gateways). 
        # Keeping your original intent but cleaning up the redundant fields from the constraint:
        constraints = [
            models.UniqueConstraint(
                fields=["txn_id", "gateway_ref"],
                # Enforce uniqueness only when txn_id IS NOT NULL. 
                # This assumes a combination of txn_id and gateway_ref must be unique if present.
                condition=Q(txn_id__isnull=False) | Q(gateway_ref__isnull=False),
                name="uniq_payment_txn_ref",
            ),
        ]

    def __str__(self):
        # Use the single display_label property below
        return f"{self.invoice} · {self.amount} ({self.provider or 'n/a'})"
        

    @property
    def display_label(self):
        """Human-readable label for the payment."""
        inv = self.invoice
        if inv.kind == "monthly":
            return f"Tuition – {inv.period_label}"
        return inv.title or "Payment"


# ---------- Seed built-in categories after migrate ----------
@receiver(post_migrate)
def _seed_finance_categories(sender, **kwargs):
    """
    Ensures your fixed categories always exist, but keeps things flexible:
    you can add more categories later from Admin anytime.
    """
    if sender.name != __name__.rsplit(".", 1)[0]:  # only when this app migrated
        return

    income_defaults = [
        ("admission", "Admission Fee"),
        ("tuition", "Monthly Tuition"),
        ("bus", "Bus Service"),
        ("donation", "Donation/Other"),
    ]
    expense_defaults = [
        ("salary", "Salary"),
        ("fuel", "Fuel/Oil Purchase"),
        ("bus_repair", "Bus Repair"),
        ("bus_purchase", "Bus Purchase"),
        ("equip_purchase", "Equipment Purchase"),
        ("equip_repair", "Equipment Repair"),
        ("misc", "Miscellaneous"),
    ]
    for code, name in income_defaults:
        IncomeCategory.objects.get_or_create(code=code, defaults={"name": name, "is_fixed": True, "is_active": True})
    for code, name in expense_defaults:
        ExpenseCategory.objects.get_or_create(code=code, defaults={"name": name, "is_fixed": True, "is_active": True})


# =========================== END: Finance Models (Content App) ===========================


def _get_student_profile_model():
    """
    Try to find a StudentProfile model dynamically.
    Return None if it doesn't exist (code will still work).
    """
    for app_label in ("accounts", "students", "content"):
        try:
            m = apps.get_model(app_label, "StudentProfile")
            if m:
                return m
        except Exception:
            pass
    return None


def _admission_income_line_items(app):
    """
    Build (code, amount, label) tuples from a paid AdmissionApplication.
    Uses the fee snapshot & selection flags on the application.
    """
    rows = []
    # Base rows
    if getattr(app, "add_admission", False) and (getattr(app, "fee_admission", 0) > 0):
        rows.append(("admission", app.fee_admission, "Admission fee"))
    if getattr(app, "add_tuition", False) and (getattr(app, "fee_tuition", 0) > 0):
        rows.append(("tuition", app.fee_tuition, "First month tuition"))
    if getattr(app, "add_exam", False) and (getattr(app, "fee_exam", 0) > 0):
        rows.append(("exam", app.fee_exam, "Exam fee"))
    # Add-ons
    if getattr(app, "add_bus", False) and (getattr(app, "fee_bus", 0) > 0):
        rows.append(("bus", app.fee_bus, "Transport/Bus"))
    if getattr(app, "add_hostel", False) and (getattr(app, "fee_hostel", 0) > 0):
        rows.append(("hostel", app.fee_hostel, "Hostel"))
    if getattr(app, "add_marksheet", False) and (getattr(app, "fee_marksheet", 0) > 0):
        rows.append(("marksheet", app.fee_marksheet, "Marksheet/Certificate"))
    return rows


def _admission_has_income_already(app) -> bool:
    """
    If any Income exists with this txn_id in description,
    treat it as already posted (avoid duplicates).
    """
    from .models import Income  # local import to avoid circulars
    token = (getattr(app, "payment_txn_id", "") or "").strip()
    return bool(token) and Income.objects.filter(description__icontains=f"TXN:{token}").exists()


@receiver(post_save, sender=AdmissionApplication)
def _post_income_when_paid(sender, instance: "AdmissionApplication", created, **kwargs):
    # Only act when status is paid
    if getattr(instance, "payment_status", "") != "paid":
        return
    # Prevent duplicate posting
    if _admission_has_income_already(instance):
        return
    from django.contrib.auth import get_user_model
    UserModel = get_user_model()
    # ---------- (1) Ensure student user (minimal) ----------
    user = None
    email = (getattr(instance, "email", "") or "").strip()
    if email:
        user = UserModel.objects.filter(email__iexact=email).first()
    if not user:
        base = ((getattr(instance, "full_name", "") or "student").split() or ["student"])[0].lower()
        uname = f"{base}{int(timezone.now().timestamp())}"
        user = UserModel.objects.create_user(
            username=uname,
            email=email,
            first_name=(instance.full_name.split()[0] if getattr(instance, "full_name", "") else "")
        )

    # ---------- (2) Optionally create StudentProfile with auto roll ----------
    StudentProfile = _get_student_profile_model()
    profile = None
    if StudentProfile:
        # require these attrs on the AdmissionApplication to create a profile
        enroll_class = getattr(instance, "enroll_class", None)
        enroll_section = getattr(instance, "enroll_section", "") or ""
        if enroll_class:
            # pick a roll
            gen_roll = getattr(instance, "generated_roll", None)
            if not gen_roll:
                if hasattr(StudentProfile, "next_roll"):
                    # use provided helper if your model has it
                    try:
                        gen_roll = StudentProfile.next_roll(enroll_class, enroll_section)
                    except Exception:
                        gen_roll = None
                # fallback: sequential count+1 within class+section
                if not gen_roll:
                    try:
                        q = StudentProfile.objects.filter(school_class=enroll_class, section=enroll_section)
                        gen_roll = (q.count() or 0) + 1
                    except Exception:
                        gen_roll = int(timezone.now().timestamp())  # worst-case fallback

            # create profile if user doesn't have one
            profile = getattr(user, "student_profile", None)
            if not profile:
                try:
                    profile = StudentProfile.objects.create(
                        user=user,
                        school_class=enroll_class,
                        section=enroll_section,
                        roll_number=gen_roll,
                    )
                except Exception:
                    profile = None
            # persist generated roll back to application (if the field exists)
            if not getattr(instance, "generated_roll", None) and hasattr(instance, "generated_roll"):
                try:
                    instance.generated_roll = gen_roll
                    instance.save(update_fields=["generated_roll"])
                except Exception:
                    pass

    # ---------- (3) Post income rows (admission/tuition/exam/bus/hostel/marksheet) ----------
    from .models import Income, IncomeCategory  # local import to avoid circulars

    line_items = _admission_income_line_items(instance)
    if not line_items:
        return

    stamp = getattr(instance, "paid_at", None).date() if getattr(instance, "paid_at", None) else timezone.localdate()

    for code, amount, label in line_items:
        cat, _ = IncomeCategory.objects.get_or_create(
            code=code,
            defaults={"name": label, "is_fixed": True, "is_active": True}
        )

        # Build create kwargs, adding `student` only if the field exists in your schema
        create_kwargs = dict(
            category=cat,
            amount=amount,
            date=stamp,
            description=(
                f"{label} — Applicant: {getattr(instance, 'full_name', 'N/A')} "
                f"({getattr(instance, 'desired_course', 'Course')}) | "
                f"Provider: {getattr(instance, 'payment_provider', 'n/a') or 'n/a'} | "
                f"TXN:{getattr(instance, 'payment_txn_id', 'n/a') or 'n/a'}"
            ),
            content_object=instance,
        )
        # Only set student if Income has that field
        try:
            if hasattr(Income, "_meta") and "student" in [f.name for f in Income._meta.get_fields()]:
                if profile is not None:
                    create_kwargs["student"] = getattr(profile, "user", None) or user
        except Exception:
            pass

        Income.objects.create(**create_kwargs)


# Signals



# ======================== END: Admission -> Income (FULL BLOCK) ========================

class StudentProfile(TimeStampedModel, ImageUrlMixin):
    objects = ActiveManager()

    user = models.OneToOneField(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        null=True, blank=True,
        related_name="student_profile",
    )
    
    # --- Link to the public-facing Member profile ---
    # This is the connection you asked for.
    member_profile = models.OneToOneField(
        "content.Member",  # Assumes Member model is in the same 'content' app
        on_delete=models.SET_NULL, # If the public profile is deleted, don't delete the student record
        null=True, blank=True,
        related_name="student_record",
        help_text="The public-facing member profile for this student (auto-created)."
    ) # <-- CHANGED

    # --- Added Profile Image Field ---
    profile_img = models.ImageField(
        upload_to="student_profiles/", 
        blank=True, null=True,
        help_text="Student's primary profile picture (will be copied to the Member profile)."
    ) # <-- CHANGED

    school_class = models.ForeignKey(
        "content.AcademicClass",
        on_delete=models.PROTECT,
        related_name="students",
    )

    # normalize via save() below
    # <-- CHANGED: Added null=True
    section = models.CharField(max_length=20, blank=True, null=True, default="") 

    roll_number = models.PositiveIntegerField()
    
    # <-- CHANGED: Added null=True, blank=True
    joined_on = models.DateField(default=timezone.localdate, null=True, blank=True) 

    monthly_fee = models.DecimalField(max_digits=10, decimal_places=2,
                                      null=True, blank=True)
    has_bus_service = models.BooleanField(default=False)
    has_hostel_seat = models.BooleanField(default=False)

    bus_monthly_fee = models.DecimalField(max_digits=10, decimal_places=2,
                                          null=True, blank=True)
    hostel_monthly_fee = models.DecimalField(max_digits=10, decimal_places=2,
                                             null=True, blank=True)

    class Meta:
        unique_together = ("school_class", "section", "roll_number")
        ordering = ("school_class", "section", "roll_number")
        verbose_name = "Student Profile"
        verbose_name_plural = "Student Profiles"
        indexes = [
             models.Index(fields=["school_class", "section"]),
        ]

    def __str__(self):
        # Check if user exists before trying to access it
        user_str = str(self.user) if self.user else "[No User]"
        sec = f" – {self.section}" if self.section else ""
        return f"{user_str} — {self.school_class}{sec} — Roll {self.roll_number}"

    # <-- CHANGED: Overridden save() method to auto-create Member profile -->
    def save(self, *args, **kwargs):
        # Normalize section
        self.section = (self.section or "").strip().upper()
        
        # Check if this is a new profile being created
        is_new = self._state.adding
        
        # Save the StudentProfile first
        super().save(*args, **kwargs) 

        # --- Auto-create and link Member profile ---
        # If it's new, doesn't have a member profile yet, and has a user attached
        if is_new and not self.member_profile and self.user:
            
            # Get the name from the user account
            student_name = self.user.get_full_name()
            if not student_name:
                student_name = self.user.username

            # Create the new Member object
            member = Member.objects.create(
                role=Member.Role.STUDENT,
                name=student_name,
                post=f"Student, {self.school_class.name}", # e.g., "Student, Class 10"
                section=self.section,
                photo=self.profile_img, # Copies the image you uploaded
            )
            
            # Link this new Member profile back to the StudentProfile
            self.member_profile = member
            
            # Save again, but *only* this one field to prevent loops
            super().save(update_fields=['member_profile'])

    @classmethod
    def next_roll(cls, klass, section=""):
        section = (section or "").strip().upper()
        qs = cls.objects.filter(school_class=klass, section=section).order_by("-roll_number")
        last = qs.first()
        return (last.roll_number + 1) if last else 1

class AttendanceStatus(models.TextChoices):
    PRESENT = "P", "Present"
    ABSENT = "A", "Absent"
    LATE = "L", "Late"
    EXCUSED = "E", "Excused"


# --- New Model for Individual Student Attendance ---
class StudentAttendance(TimeStampedModel, ImageUrlMixin):
    objects = ActiveManager()

    session = models.ForeignKey(
        "AttendanceSession",
        on_delete=models.CASCADE,
        related_name="student_records",
        help_text="The class session this attendance record belongs to.",
    )
    student = models.ForeignKey(
        "StudentProfile",
        on_delete=models.CASCADE,
        related_name="attendance_records",
        help_text="The student this attendance record is for.",
    )
    status = models.CharField(
        max_length=1,
        choices=AttendanceStatus.choices,
        default=AttendanceStatus.PRESENT,  # Default to present for checkbox ease
        help_text="The attendance status for the student.",
    )
    notes = models.CharField(max_length=100, blank=True)

    class Meta:
        # Ensures a student can only have one attendance record per session
        unique_together = ("session", "student")
        verbose_name = "Student Attendance Record"
        verbose_name_plural = "Student Attendance Records"
        ordering = ("student__roll_number",)

    def __str__(self):
        return f"{self.student.user} – {self.session.date} – {self.get_status_display()}"


@receiver(post_save, sender=TuitionPayment)
def _update_invoice_and_post_income(sender, instance: "TuitionPayment", created, **kwargs):
    if not created:
        return

    inv = instance.invoice

    # 1) Update invoice paid amount
    inv.paid_amount = (inv.paid_amount or 0) + instance.amount
    inv.save(update_fields=["paid_amount"])

    # 2) Post to Income ledger (idempotent inside helper)
    try:
        inv.post_income_line(instance, category_code="tuition")
    except Exception:
        pass

    # 3) Email (best-effort)
    student_email = getattr(inv.student, "email", "") or ""
    if student_email:
        try:
            send_mail(
                subject="Tuition payment received",
                message=(f"Hello {inv.student},\n\n"
                         f"We received your tuition payment of {instance.amount} "
                         f"for {inv.period_year}-{inv.period_month:02d}.\n"
                         f"Invoice balance is now {inv.balance}.\n\nThank you."),
                from_email=getattr(settings, "DEFAULT_FROM_EMAIL", None),
                recipient_list=[student_email],
                fail_silently=True,
            )
        except Exception:
            pass

    # 4) Create a PaymentReceipt with a simple PDF (idempotent by txn_id)
    if instance.txn_id and PaymentReceipt.objects.filter(txn_id=instance.txn_id).exists():
        return

    # Build the PDF
    pdf_buffer = BytesIO()
    p = canvas.Canvas(pdf_buffer, pagesize=A4)
    p.setFont("Helvetica-Bold", 16)
    p.drawString(200, 800, "Tuition Payment Receipt")
    p.setFont("Helvetica", 12)
    p.drawString(60, 760, f"Student: {inv.student}")
    label = (f"{inv.period_year}-{inv.period_month:02d}"
             if inv.period_year and inv.period_month else inv.title or "Monthly Tuition")
    p.drawString(60, 740, f"For: {label}")
    p.drawString(60, 720, f"Amount Paid: BDT {instance.amount}")
    p.drawString(60, 700, f"Provider: {instance.provider or 'manual'}")
    if instance.txn_id:
        p.drawString(60, 680, f"Reference: {instance.txn_id}")
    p.drawString(60, 660, f"Date: {instance.paid_on or timezone.localdate()}")
    p.drawString(60, 640, f"Invoice Balance After Payment: BDT {inv.balance}")
    p.showPage()
    p.save()
    pdf_buffer.seek(0)

    receipt = PaymentReceipt.objects.create(
        student=inv.student,
        payment=instance,
        amount=instance.amount,
        provider=instance.provider or "manual",
        txn_id=instance.txn_id or f"manual-{instance.pk}",
    )
    receipt.pdf.save(f"tuition_receipt_{receipt.id}.pdf", ContentFile(pdf_buffer.read()))


# --- START: Auto-generate receipts for AdmissionApplication ---

@receiver(post_save, sender=AdmissionApplication)
def make_receipt_for_admission(sender, instance: AdmissionApplication, created, **kwargs):
    # Only trigger after payment confirmed
    if instance.payment_status != "paid":
        return
    if not instance.pk or not (instance.payment_txn_id or "").strip():
        return
    if PaymentReceipt.objects.filter(txn_id=instance.payment_txn_id).exists():
        return

    # student to attach the receipt to
    student = getattr(instance, "user", None)
    if not student:
        # fallback: create a lightweight user if missing
        from django.contrib.auth import get_user_model
        User = get_user_model()
        student, _ = User.objects.get_or_create(
            username=f"admission_{instance.pk}",
            defaults={"first_name": (instance.full_name or "Applicant").split(" ")[0]},
        )

    # Build a very simple PDF
    pdf_buffer = BytesIO()
    p = canvas.Canvas(pdf_buffer, pagesize=A4)
    p.setFont("Helvetica-Bold", 16);
    p.drawString(180, 800, "Admission Payment Receipt")
    p.setFont("Helvetica", 12)
    p.drawString(60, 760, f"Name: {instance.full_name or ''}")
    p.drawString(60, 740, f"Course: {str(instance.desired_course) if instance.desired_course_id else ''}")
    p.drawString(60, 720, f"Amount Paid: BDT {instance.fee_total}")
    p.drawString(60, 700, f"Provider: {instance.payment_provider or 'manual'}")
    p.drawString(60, 680, f"Reference: {instance.payment_txn_id}")
    p.drawString(60, 660, f"Date: {timezone.localdate()}")
    p.drawString(60, 640, "Thank you for your admission payment.")
    p.showPage();
    p.save()
    pdf_buffer.seek(0)

    receipt = PaymentReceipt.objects.create(
        student=student,
        admission=instance,
        amount=instance.fee_total,
        provider=instance.payment_provider or "manual",
        txn_id=instance.payment_txn_id,
    )
    receipt.pdf.save(f"admission_receipt_{receipt.id}.pdf", ContentFile(pdf_buffer.read()))


# --- END ---


# --- START: PaymentReceipt model ---


def gen_txn_id(prefix: str = "LOCAL") -> str:
    return f"{prefix}-{timezone.now().strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex[:8]}"


# ---- add near your other models ----
class ProcessedGatewayEvent(TimeStampedModel, ImageUrlMixin):
    objects = ActiveManager()
    provider = models.CharField(max_length=32)  # 'stripe' | 'paypal'
    event_id = models.CharField(max_length=128, unique=True)  # Stripe event id / PayPal capture id
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        indexes = [models.Index(fields=["provider", "event_id"])]


class PaymentReceipt(TimeStampedModel, ImageUrlMixin):
    objects = ActiveManager()
    provider_choices = [
        ('stripe', 'Stripe'),
        ('paypal', 'PayPal'),
        ('manual', 'Manual'),
    ]

    student = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="receipts")
    payment = models.ForeignKey("TuitionPayment", on_delete=models.CASCADE, related_name="receipts", null=True,
                                blank=True)
    admission = models.ForeignKey("AdmissionApplication", on_delete=models.CASCADE, related_name="receipts", null=True,
                                  blank=True)

    amount = models.DecimalField(max_digits=10, decimal_places=2)
    provider = models.CharField(max_length=30, choices=provider_choices)
    txn_id = models.CharField(max_length=100, unique=True)
    pdf = models.FileField(upload_to="receipts/", null=True, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)

    def __str__(self):
        return f"Receipt #{self.id} — {self.student} — {self.amount}"

    class Meta:
        ordering = ["-created_at"]


# --- END ---


# --- NEW: simple settings row you can edit in admin ---
class FinanceSettings(TimeStampedModel, ImageUrlMixin):
    objects = ActiveManager()
    default_monthly_fee = models.DecimalField(max_digits=10, decimal_places=2, default=2000)
    notes = models.CharField(max_length=255, blank=True)

    class Meta:
        verbose_name_plural = "Finance settings"

    def __str__(self):
        return f"Finance Settings (monthly={self.default_monthly_fee})"

    @classmethod
    def current(cls):
        obj = cls.objects.first()
        if obj:
            return obj
        # lazily create one with default if missing
        return cls.objects.create(default_monthly_fee=2000)


# ################################################


class MessageTemplate(TimeStampedModel, ImageUrlMixin):
    objects = ActiveManager()
    KIND_EMAIL = "email"
    KIND_SMS = "sms"
    KIND_CHOICES = [(KIND_EMAIL, "Email"), (KIND_SMS, "SMS")]

    slug = models.SlugField(unique=True, max_length=80)
    kind = models.CharField(max_length=10, choices=KIND_CHOICES)
    subject_template = models.CharField(max_length=200, blank=True)  # email only
    body_text_template = models.TextField(blank=True)  # email or sms
    body_html_template = models.TextField(blank=True)  # email only
    is_active = models.BooleanField(default=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    # example: "dues_notice", "result_published", "receipt_ready"
    def __str__(self) -> str:
        return f"{self.slug} [{self.kind}]"


class OutboxStatus(models.TextChoices):
    QUEUED = "queued", "Queued"
    SENDING = "sending", "Sending"
    SENT = "sent", "Sent"
    FAILED = "failed", "Failed"
    BOUNCED = "bounced", "Bounced"


phone_validator = RegexValidator(r"^\+?\d{8,15}$", "Enter a valid international phone number.")


class SmsOutbox(TimeStampedModel, ImageUrlMixin):
    objects = ActiveManager()
    to = models.CharField(max_length=20, validators=[phone_validator])
    sender_id = models.CharField(max_length=15, blank=True)  # enforce by gateway
    template = models.ForeignKey(MessageTemplate, on_delete=models.PROTECT, limit_choices_to={"kind": "sms"})
    context = models.JSONField(default=dict, blank=True)

    status = models.CharField(max_length=12, choices=OutboxStatus.choices, default=OutboxStatus.QUEUED)
    attempts = models.IntegerField(default=0)
    last_error = models.TextField(blank=True)

    provider = models.CharField(max_length=32, blank=True)  # "generic", "twilio", etc
    provider_ref = models.CharField(max_length=120, blank=True)

    scheduled_at = models.DateTimeField(default=timezone.now)
    sent_at = models.DateTimeField(null=True, blank=True)
    next_attempt_at = models.DateTimeField(null=True, blank=True)

    created_by = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL,
                                   related_name="sms_created")
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        indexes = [
            models.Index(fields=["status", "scheduled_at"]),
            models.Index(fields=["to", "template", "status"]),
        ]


class EmailOutbox(TimeStampedModel, ImageUrlMixin):
    objects = ActiveManager()
    to = models.EmailField()
    template = models.ForeignKey(MessageTemplate, on_delete=models.PROTECT, limit_choices_to={"kind": "email"})
    context = models.JSONField(default=dict, blank=True)

    from_email = models.CharField(max_length=200, blank=True)  # fallback DEFAULT_FROM_EMAIL
    reply_to = models.CharField(max_length=200, blank=True)  # fallback EMAIL_REPLY_TO

    status = models.CharField(max_length=12, choices=OutboxStatus.choices, default=OutboxStatus.QUEUED)
    attempts = models.IntegerField(default=0)
    last_error = models.TextField(blank=True)

    provider = models.CharField(max_length=32, blank=True)  # "smtp", "ses", etc
    provider_ref = models.CharField(max_length=120, blank=True)  # message-id

    scheduled_at = models.DateTimeField(default=timezone.now)
    sent_at = models.DateTimeField(null=True, blank=True)
    next_attempt_at = models.DateTimeField(null=True, blank=True)

    created_by = models.ForeignKey(
        settings.AUTH_USER_MODEL, 
        null=True, blank=True, on_delete=models.SET_NULL,
        related_name="emails_created"
    )
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        indexes = [
            models.Index(fields=["status", "scheduled_at"]),
            models.Index(fields=["to", "template", "status"]),
        ]


class CommsLog(TimeStampedModel, ImageUrlMixin):
    objects = ActiveManager()
    when = models.DateTimeField(auto_now_add=True)
    channel = models.CharField(max_length=10)  # "sms" | "email"
    recipient = models.CharField(max_length=200)
    template_slug = models.CharField(max_length=80)
    status = models.CharField(max_length=12)
    detail = models.TextField(blank=True)

    def __str__(self):
        return f"[{self.channel}] {self.template_slug} -> {self.recipient} ({self.status})"


class EmailBounce(TimeStampedModel, ImageUrlMixin):
    objects = ActiveManager()
    # Marked via webhook or manual import
    email = models.EmailField()
    event = models.CharField(max_length=32)  # "bounce", "complaint", "blocked", etc.
    reason = models.CharField(max_length=120, blank=True)
    occurred_at = models.DateTimeField(default=timezone.now)
    raw = models.JSONField(default=dict, blank=True)

    def __str__(self):
        return f"{self.event} {self.email} @ {self.occurred_at:%Y-%m-%d}"



# ==========================================
# REGISTRATION SETTINGS (The Code)
# ==========================================
class RegistrationSetting(TimeStampedModel):
    # No ActiveManager needed here, we just fetch the first one
    code = models.CharField(
        max_length=50, 
        default="k5gg43lq", 
        help_text="The secret code users must enter to sign up."
    )
    
    class Meta:
        verbose_name = "Registration Setting (Invite Code)"
        verbose_name_plural = "Registration Settings"

    def __str__(self):
        return f"Current Code: {self.code}"

    @classmethod
    def get_code(cls):
        """Helper to get the current code, creating it if missing."""
        obj, created = cls.objects.get_or_create(pk=1)
        return obj.code

    def save(self, *args, **kwargs):
        self.pk = 1  # Force this to always be the first record (Singleton)
        super().save(*args, **kwargs)



