from dataclasses import asdict, dataclass
from datetime import timedelta
from enum import Enum
from typing import Self, TypedDict
from uuid import uuid4

from layercake.dateutils import now, ttl
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair
from layercake.strutils import md5_hash

from config import ORDER_TABLE
from models import Course, Enrollment

Tenant = TypedDict('Tenant', {'id': str, 'name': str})
Author = TypedDict('Author', {'id': str, 'name': str})
DeduplicationWindow = TypedDict('DeduplicationWindow', {'offset_days': int})


class LinkedEntity(str):
    def __new__(cls, id: str, type: str) -> Self:
        return super().__new__(cls, '#'.join([type.upper(), id]))

    def __init__(self, id: str, type: str) -> None:
        # __init__ is used to store the parameters for later reference.
        # For immutable types like str, __init__ cannot change the instance's value.
        self.id = id
        self.type = type


@dataclass(frozen=True)
class Slot:
    id: str
    sk: str

    @property
    def order_id(self) -> LinkedEntity:
        idx, _ = self.sk.split('#')
        return LinkedEntity(idx, 'order')


class LifecycleEvents(str, Enum):
    """Lifecycle events related to scheduling actions."""

    # Reminder if the user does not access within 3 days
    # REMINDER_NO_ACCESS_3_DAYS = 'schedules#reminder_no_access_3_days'
    DOES_NOT_ACCESS = 'schedules#does_not_access'

    # When there is no activity 7 days after the first access
    # NO_ACTIVITY_7_DAYS = 'schedules#no_activity_7_days'
    NO_ACTIVITY = 'schedules#no_activity'

    # Reminder 30 days before the access period expires
    # ACCESS_PERIOD_REMINDER_30_DAYS = 'schedules#access_period_reminder_30_days'
    ACCESS_PERIOD_ENDS = 'schedules#access_period_ends'

    # Reminder for certificate expiration set to 30 days from now
    CERT_EXPIRATION_REMINDER_30_DAYS = 'schedules#cert_expiration_reminder_30_days'

    # Archive the course after the certificate expires
    # SET_AS_ARCHIVE = 'schedules#set_as_archive'
    ARCHIVE_IT = 'schedules#archive_it'

    # When the access period ends for a course without a certificate
    # SET_AS_EXPIRE = 'schedules#set_as_expire'
    EXPIRATION = 'schedules#expiration'


def enroll(
    enrollment: Enrollment,
    *,
    tenant: Tenant,
    slot: Slot | None = None,
    author: Author | None = None,
    linked_entities: frozenset[LinkedEntity] = frozenset(),
    deduplication_window: DeduplicationWindow | None = None,
    persistence_layer: DynamoDBPersistenceLayer,
) -> bool:
    """Enrolls a user into a course and schedules lifecycle events."""
    now_ = now()
    user = enrollment.user
    course = enrollment.course
    tenant_id = tenant['id']
    lock_hash = md5_hash('%s%s' % (user.id, course.id))

    with persistence_layer.transact_writer() as transact:
        if slot:
            linked_entities = frozenset({slot.order_id}) | linked_entities

        transact.put(
            item={
                'sk': '0',
                'created_at': now_,
                'tenant_id': tenant_id,
                **enrollment.model_dump(),
            },
        )
        transact.put(
            item={
                'id': enrollment.id,
                'sk': 'tenant',
                'org_id': tenant_id,
                'name': tenant['name'],
                'created_at': now_,
            },
        )
        transact.put(
            item={
                'id': enrollment.id,
                # Post-migration: uncomment the following line
                # 'sk': LifecycleEvents.REMINDER_NO_ACCESS_3_DAYS,
                'sk': LifecycleEvents.DOES_NOT_ACCESS,
                'name': user.name,
                'email': user.email,
                'course': course.name,
                'created_at': now_,
                'ttl': ttl(days=3, start_dt=now_),
            },
        )
        # Enrollment expires by default when the access period ends.
        # When the course is finished, it is automatically removed,
        # and the `schedules#course_archived` event is created.
        transact.put(
            item={
                'id': enrollment.id,
                'sk': LifecycleEvents.EXPIRATION,
                # Post-migration: uncomment the following line
                # 'sk': LifecycleEvents.COURSE_EXPIRED,
                'name': user.name,
                'email': user.email,
                'course': course.name,
                'created_at': now_,
                'ttl': ttl(start_dt=now_ + timedelta(days=course.access_period)),
            },
        )
        transact.put(
            item={
                'id': enrollment.id,
                # Post-migration: uncomment the following line
                # 'sk': LifecycleEvents.ACCESS_PERIOD_REMINDER_30_DAYS,
                'sk': LifecycleEvents.ACCESS_PERIOD_ENDS,
                'name': user.name,
                'email': user.email,
                'course': course.name,
                'created_at': now_,
                'ttl': ttl(start_dt=now_ + timedelta(days=course.access_period - 30)),
            },
        )

        for entity in linked_entities:
            type = entity.type.lower()
            transact.put(
                item={
                    'id': enrollment.id,
                    'sk': f'linked_entities#{type}',
                    'created_at': now_,
                    f'{type}_id': entity.id,
                }
            )

        if slot:
            transact.put(
                item={
                    'id': enrollment.id,
                    # Post-migration: uncomment the following line
                    # 'sk': 'metadata#parent_slot',
                    'sk': 'parent_vacancy',
                    'vacancy': asdict(slot),
                    'created_at': now_,
                }
            )

            class SlotDoesNotExistError(Exception):
                def __init__(self, *args):
                    super().__init__('Slot does not exist')

            transact.delete(
                key=KeyPair(slot.id, slot.sk),
                cond_expr='attribute_exists(sk)',
                exc_cls=SlotDoesNotExistError,
            )
            transact.put(
                item={
                    'id': enrollment.id,
                    'sk': 'cancel_policy',
                    'created_at': now_,
                }
            )

        if author:
            transact.put(
                item={
                    'id': enrollment.id,
                    'sk': 'author',
                    'user_id': author['id'],
                    'name': author['name'],
                    'created_at': now_,
                },
            )

        class DeduplicationConflictError(Exception):
            def __init__(self, *args):
                super().__init__('Enrollment already exists')

        # Prevents the user from enrolling in the same course again until
        # the deduplication window expires or is removed.
        if deduplication_window:
            offset_days = deduplication_window['offset_days']
            ttl_expiration = ttl(
                start_dt=now_ + timedelta(days=course.access_period - offset_days)
            )
            transact.put(
                item={
                    'id': 'lock',
                    'sk': lock_hash,
                    'enrollment_id': enrollment.id,
                    'created_at': now_,
                    'ttl': ttl_expiration,
                },
                cond_expr='attribute_not_exists(sk)',
                exc_cls=DeduplicationConflictError,
            )
            transact.put(
                item={
                    'id': enrollment.id,
                    'sk': 'lock',
                    'hash': lock_hash,
                    'created_at': now_,
                    'ttl': ttl_expiration,
                },
            )
            # Deduplication window can be recalculated if needed
            transact.put(
                item={
                    'id': enrollment.id,
                    'sk': 'metadata#deduplication_window',
                    'offset_days': offset_days,
                    'created_at': now_,
                },
            )
        else:
            transact.condition(
                key=KeyPair('lock', lock_hash),
                cond_expr='attribute_not_exists(sk)',
                exc_cls=DeduplicationConflictError,
            )

    return True


def set_status_as_canceled(
    id: str,
    *,
    lock_hash: str | None = None,
    author: Author,
    course: Course | None = None,
    vacancy_key: KeyPair | None = None,
    persistence_layer: DynamoDBPersistenceLayer,
):
    """Cancel the enrollment if there's a `cancel_policy`
    and put its vacancy back if `vacancy_key` is provided."""
    now_ = now()

    with persistence_layer.transact_writer() as transact:
        transact.update(
            key=KeyPair(id, '0'),
            update_expr='SET #status = :canceled, updated_at = :updated_at',
            expr_attr_names={
                '#status': 'status',
            },
            expr_attr_values={
                ':canceled': 'CANCELED',
                ':updated_at': now_,
            },
        )
        transact.put(
            item={
                'id': id,
                'sk': 'canceled',
                'author': author,
                'canceled_at': now_,
            },
        )
        transact.delete(
            key=KeyPair(id, 'cancel_policy'),
            cond_expr='attribute_exists(sk)',
        )
        # Remove schedules lifecycle events, referencies and locks
        transact.delete(key=KeyPair(id, LifecycleEvents.ARCHIVE_IT))
        transact.delete(key=KeyPair(id, LifecycleEvents.NO_ACTIVITY))
        transact.delete(key=KeyPair(id, LifecycleEvents.ACCESS_PERIOD_ENDS))
        transact.delete(key=KeyPair(id, LifecycleEvents.DOES_NOT_ACCESS))
        transact.delete(key=KeyPair(id, 'parent_vacancy'))

        if lock_hash:
            transact.delete(key=KeyPair(id, 'lock'))
            transact.delete(key=KeyPair('lock', lock_hash))

        if vacancy_key and course:
            vacancy_pk, vacancy_sk = vacancy_key.values()
            org_id = vacancy_pk.removeprefix('vacancies#')
            order_id, enrollment_id = vacancy_sk.split('#')

            transact.condition(
                key=KeyPair(order_id, '0'),
                cond_expr='attribute_exists(id)',
                table_name=ORDER_TABLE,
            )
            # Put the vacancy back and assign a new ID
            transact.put(
                item={
                    # Post-migration: uncomment the following line
                    # 'id': f'slots#org#{org_id}',
                    'id': f'vacancies#{org_id}',
                    'sk': f'{order_id}#{uuid4()}',
                    'course': course,
                    'created_at': now_,
                },
                cond_expr='attribute_not_exists(sk)',
            )
            # Set the status of `generated_items` to `ROLLBACK` to know
            # which slot is available for reuse
            transact.update(
                # Post-migration: uncomment the following line
                # key=KeyPair(order_id, f'slots#enrollment#{enrollment_id}'),
                key=KeyPair(order_id, f'generated_items#{enrollment_id}'),
                update_expr='SET #status = :status, updated_at = :updated_at',
                expr_attr_names={
                    '#status': 'status',
                },
                expr_attr_values={
                    ':status': 'ROLLBACK',
                    ':updated_at': now_,
                },
                cond_expr='attribute_exists(sk)',
                table_name=ORDER_TABLE,
            )

    return True
