import base64
import hashlib
import hmac
import json
import os
from pathlib import Path
from threading import Lock, Timer
from datetime import datetime, timedelta, timezone

from dotenv import load_dotenv

from app import crud
from app.database import SessionLocal
from app.logging_utils import get_logger
from app.models import Meeting
from app.services import recall_service

load_dotenv(Path(__file__).resolve().parent.parent / ".env")
load_dotenv()
logger = get_logger(__name__)
WAITING_ROOM_TIMEOUT_MINUTES = int(os.getenv("RECALL_WAITING_ROOM_TIMEOUT_MINUTES", "2"))
OPEN_MEETING_EMPTY_TIMEOUT_MINUTES = int(os.getenv("RECALL_EMPTY_OPEN_MEETING_TIMEOUT_MINUTES", "2"))
OPEN_MEETING_AFTER_LEFT_TIMEOUT_MINUTES = int(os.getenv("RECALL_EMPTY_AFTER_PEOPLE_LEFT_TIMEOUT_MINUTES", "1"))
_waiting_room_timers: dict[int, Timer] = {}
_waiting_room_timer_lock = Lock()
_open_meeting_timers: dict[int, Timer] = {}
_open_meeting_timer_lock = Lock()
_meetings_with_human_seen: set[int] = set()


def verify_request_from_recall(secret: str, headers: dict[str, str], payload: str | None) -> None:
    if not secret or not secret.startswith("whsec_"):
        raise ValueError("Verification secret is missing or invalid")

    msg_id = headers.get("webhook-id") or headers.get("svix-id")
    msg_timestamp = headers.get("webhook-timestamp") or headers.get("svix-timestamp")
    msg_signature = headers.get("webhook-signature") or headers.get("svix-signature")
    if not msg_id or not msg_timestamp or not msg_signature:
        raise ValueError("Missing webhook verification headers")

    key = base64.b64decode(secret.replace("whsec_", "", 1))
    signed_content = f"{msg_id}.{msg_timestamp}.{payload or ''}".encode("utf-8")
    expected_sig = base64.b64encode(hmac.new(key, signed_content, hashlib.sha256).digest()).decode("utf-8")

    candidates = []
    for chunk in msg_signature.split(" "):
        if not chunk:
            continue
        candidates.extend(part for part in chunk.split(" ") if part)
        candidates.extend(part for part in chunk.split(";") if part)

    if not candidates:
        candidates = [msg_signature]

    for versioned_sig in candidates:
        entry = versioned_sig.strip()
        if not entry:
            continue
        try:
            version, signature = entry.split(",", 1)
        except ValueError:
            continue
        if version.strip() == "v1" and hmac.compare_digest(signature.strip(), expected_sig):
            return

    raise ValueError("No matching webhook signature found")


def verify_recall_request(headers: dict[str, str], payload: str, query_token: str | None) -> None:
    normalized_headers = {key.lower(): value for key, value in headers.items()}

    configured_token = os.getenv("RECALL_WEBHOOK_TOKEN")
    if configured_token:
        if query_token != configured_token:
            raise ValueError("Invalid webhook token")
        logger.info("Recall webhook verification passed with the configured webhook token.")
        return

    last_error = None
    for secret in [
        os.getenv("RECALL_WORKSPACE_VERIFICATION_SECRET"),
        os.getenv("RECALL_SVIX_WEBHOOK_SECRET"),
    ]:
        if not secret:
            continue
        try:
            verify_request_from_recall(secret, normalized_headers, payload)
            logger.info("Recall webhook signature verification passed.")
            return
        except ValueError as exc:
            last_error = str(exc)
            continue

    logger.warning(
        "Recall webhook verification failed. Webhook id present: %s. Timestamp present: %s. Signature present: %s. Last error: %s.",
        bool(normalized_headers.get("webhook-id") or normalized_headers.get("svix-id")),
        bool(normalized_headers.get("webhook-timestamp") or normalized_headers.get("svix-timestamp")),
        bool(normalized_headers.get("webhook-signature") or normalized_headers.get("svix-signature")),
        last_error,
    )
    raise ValueError("Recall webhook verification is not configured or verification failed")


def process_recall_webhook_event(webhook_event_id: int) -> None:
    db = SessionLocal()
    try:
        webhook_event = crud.get_recall_webhook_event(db, webhook_event_id)
        if not webhook_event:
            return

        payload = json.loads(webhook_event.payload)
        event_name = webhook_event.event
        logger.info(
            "Recall webhook processing has started. Local webhook event id: %s. Webhook id: %s. Event: %s.",
            webhook_event_id,
            webhook_event.webhook_id,
            event_name,
        )

        if event_name.endswith(".deleted"):
            _handle_media_deleted_event(db, webhook_event, payload)
        elif event_name.startswith("bot."):
            _handle_bot_event(db, webhook_event, payload)
        elif event_name.startswith(("recording.", "video_mixed.", "participant_events.", "meeting_metadata.")):
            _handle_recording_related_event(db, webhook_event, payload)
        elif event_name.startswith("transcript."):
            _handle_transcript_event(db, webhook_event, payload)

        crud.mark_recall_webhook_processed(db, webhook_event)
        logger.info(
            "Recall webhook processing finished. Local webhook event id: %s. Webhook id: %s. Event: %s.",
            webhook_event_id,
            webhook_event.webhook_id,
            event_name,
        )
    except Exception as exc:
        webhook_event = crud.get_recall_webhook_event(db, webhook_event_id)
        if webhook_event:
            crud.mark_recall_webhook_failed(db, webhook_event, str(exc))
        logger.exception("Recall webhook processing failed for local webhook event id %s. Error: %s.", webhook_event_id, exc)
        raise
    finally:
        db.close()


def _handle_media_deleted_event(db, webhook_event, payload: dict):
    meeting = _find_meeting_for_payload(db, payload, webhook_event.bot_id)
    if not meeting and webhook_event.transcript_id:
        transcript = crud.get_transcript_by_recall_transcript_id(db, webhook_event.transcript_id)
        if transcript:
            meeting = db.query(Meeting).filter(Meeting.id == transcript.meeting_id).first()
            
    if not meeting:
        logger.info("Recall media deleted webhook was ignored because no local meeting was found. Event: %s.", webhook_event.event)
        return
        
    logger.info("Recall media deleted webhook is marking recording media expired for meeting %s. Event: %s.", meeting.id, webhook_event.event)
    crud.mark_recording_media_expired(db, meeting.id)


def _handle_bot_event(db, webhook_event, payload: dict):
    meeting = _find_meeting_for_payload(db, payload, webhook_event.bot_id)
    if not meeting:
        logger.info("Recall bot webhook was ignored because no local meeting matched bot id %s. Event: %s.", webhook_event.bot_id, webhook_event.event)
        return
    status_data = (payload.get("data") or {}).get("data") or {}
    sub_code = status_data.get("sub_code") or webhook_event.status_sub_code
    if sub_code:
        crud.save_meeting_bot_sub_code(db, meeting, sub_code)

    if _should_ignore_aborted_meeting(meeting, webhook_event.event):
        return

    event_status = webhook_event.event
    logger.info("Recall bot webhook is updating meeting %s. Event: %s.", meeting.id, event_status)
    crud.set_meeting_status_from_recall_code(db, meeting, event_status, payload=payload)
    if event_status == "bot.fatal":
        _clear_open_meeting_timeout_state(meeting.id)
        logger.info(
            "Recall bot webhook processing stopped for meeting %s because status is bot.fatal. Sub code: %s.",
            meeting.id,
            sub_code,
        )
        return

    if meeting.recall_bot_id:
        bot_data = recall_service.get_bot(meeting.recall_bot_id)
        crud.sync_meeting_from_recall_bot(db, meeting, bot_data)
        recording = crud.upsert_recording_from_recall(db, meeting.id, recall_service.latest_recording(bot_data))
        enforce_waiting_room_timeout(db, meeting, schedule_if_needed=True)
        enforce_open_meeting_timeout(db, meeting, schedule_if_needed=True)

        if event_status in {"bot.call_ended", "bot.done"}:
            _clear_open_meeting_timeout_state(meeting.id)
            logger.info("Automatic transcript creation is being checked for meeting %s after event %s.", meeting.id, event_status)
            _auto_create_transcript_if_ready(db, meeting.id, recording)


def _handle_recording_related_event(db, webhook_event, payload: dict):
    meeting = _find_meeting_for_payload(db, payload, webhook_event.bot_id)
    if not meeting or not meeting.recall_bot_id:
        logger.info(
            "Recall recording webhook was ignored because the meeting could not be resolved or the bot is not linked. Bot id: %s. Recording id: %s. Event: %s.",
            webhook_event.bot_id,
            webhook_event.recording_id,
            webhook_event.event,
        )
        return
    if _should_ignore_aborted_meeting(meeting, webhook_event.event):
        return

    logger.info("Recall recording webhook is updating meeting %s. Event: %s.", meeting.id, webhook_event.event)
    crud.set_meeting_status_from_recall_code(db, meeting, webhook_event.event, payload=payload)
    bot_data = recall_service.get_bot(meeting.recall_bot_id)
    crud.sync_meeting_from_recall_bot(db, meeting, bot_data)
    recording = crud.upsert_recording_from_recall(db, meeting.id, recall_service.latest_recording(bot_data))
    enforce_open_meeting_timeout(db, meeting, schedule_if_needed=True)

    if webhook_event.event == "recording.done":
        logger.info("Automatic transcript creation is being checked for meeting %s because the recording is done.", meeting.id)
        _auto_create_transcript_if_ready(db, meeting.id, recording)


def _handle_transcript_event(db, webhook_event, payload: dict):
    meeting = _find_meeting_for_payload(db, payload, webhook_event.bot_id)
    if not meeting and webhook_event.transcript_id:
        transcript = crud.get_transcript_by_recall_transcript_id(db, webhook_event.transcript_id)
        if transcript:
            meeting = db.query(Meeting).filter(Meeting.id == transcript.meeting_id).first()

    if not meeting:
        logger.info("Recall transcript webhook was ignored because no local meeting matched transcript id %s. Event: %s.", webhook_event.transcript_id, webhook_event.event)
        return
    if _should_ignore_aborted_meeting(meeting, webhook_event.event):
        return

    transcript_id = webhook_event.transcript_id or _extract_transcript_id(payload)
    if not transcript_id:
        transcript = crud.create_transcript(db, meeting.id)
        transcript.status = webhook_event.event
        db.commit()
        logger.info("A placeholder transcript status was saved for meeting %s. Status: %s.", meeting.id, webhook_event.event)
        return

    logger.info(
        "Recall transcript webhook is syncing meeting %s. Transcript id: %s. Event: %s.",
        meeting.id,
        transcript_id,
        webhook_event.event,
    )
    transcript_detail = recall_service.get_transcript(transcript_id)
    transcript = crud.sync_recall_transcript_details(db, meeting.id, transcript_detail)
    _sync_transcript_content_if_ready(db, meeting.id, transcript)


def _should_ignore_aborted_meeting(meeting: Meeting, event_name: str) -> bool:
    if meeting.status in {"abort", "not_admitted"}:
        logger.info(
            "Recall webhook event %s was ignored for meeting %s because the meeting is already terminal with status %s.",
            event_name,
            meeting.id,
            meeting.status,
        )
        return True
    return False


def enforce_waiting_room_timeout(
    db,
    meeting: Meeting,
    schedule_if_needed: bool = False,
) -> bool:
    bot_status = meeting.bot_status or ""
    if bot_status not in {"in_waiting_room", "bot.in_waiting_room"} and meeting.status != "bot.in_waiting_room":
        return False

    if not meeting.scheduled_at or not meeting.recall_bot_id:
        return False

    scheduled_at = meeting.scheduled_at
    if scheduled_at.tzinfo is None:
        scheduled_at = scheduled_at.replace(tzinfo=timezone.utc)
    deadline = scheduled_at + timedelta(minutes=WAITING_ROOM_TIMEOUT_MINUTES)
    now = datetime.now(timezone.utc)

    if now < deadline:
        if schedule_if_needed:
            _schedule_waiting_room_timeout_check(meeting.id, deadline)
        return False

    bot_id = meeting.recall_bot_id
    try:
        recall_service.leave_call(bot_id)
    except Exception as exc:
        logger.warning(
            "Waiting-room timeout cleanup failed for meeting %s. Bot id: %s. Error: %s.",
            meeting.id,
            bot_id,
            exc,
        )
        return False

    meeting.status = "not_admitted"
    db.commit()
    db.refresh(meeting)
    logger.info(
        "Meeting %s was removed from the waiting room after exceeding the %s-minute limit. Previous bot id: %s.",
        meeting.id,
        WAITING_ROOM_TIMEOUT_MINUTES,
        bot_id,
    )
    return True


def enforce_open_meeting_timeout(
    db,
    meeting: Meeting,
    schedule_if_needed: bool = False,
) -> bool:
    logger.info(
        "Open-meeting timeout evaluation for meeting %s. schedule_if_needed: %s. status: %s. bot_status: %s. scheduled_at: %s. recall_join_at: %s. recall_bot_id_present: %s.",
        meeting.id,
        schedule_if_needed,
        meeting.status,
        meeting.bot_status,
        meeting.scheduled_at,
        meeting.recall_join_at,
        bool(meeting.recall_bot_id),
    )
    if not _is_scheduled_auto_join_meeting(meeting):
        logger.info(
            "Open-meeting timeout skipped for meeting %s because it is not a scheduled auto-join meeting.",
            meeting.id,
        )
        _clear_open_meeting_timeout_state(meeting.id)
        return False

    current_status = meeting.bot_status or meeting.status or ""
    if current_status in {"in_waiting_room", "bot.in_waiting_room", "joining_call", "bot.joining_call"}:
        logger.info(
            "Open-meeting timeout skipped for meeting %s because bot is not yet inside the call. Current status: %s.",
            meeting.id,
            current_status,
        )
        _clear_open_meeting_timeout_state(meeting.id)
        return False

    if current_status in {"in_call_recording", "bot.in_call_recording"}:
        _meetings_with_human_seen.add(meeting.id)
        _cancel_open_meeting_timeout_check(meeting.id)
        logger.info("Open-meeting timeout was cleared for meeting %s because humans are present in the call.", meeting.id)
        return False

    if current_status not in {"in_call_not_recording", "bot.in_call_not_recording"} or not meeting.recall_bot_id:
        if current_status in {"call_ended", "bot.call_ended", "done", "bot.done", "fatal", "bot.fatal"}:
            logger.info(
                "Open-meeting timeout cleared for meeting %s because meeting is terminal. Current status: %s.",
                meeting.id,
                current_status,
            )
            _clear_open_meeting_timeout_state(meeting.id)
        else:
            logger.info(
                "Open-meeting timeout skipped for meeting %s because status is not eligible. Current status: %s. recall_bot_id_present: %s.",
                meeting.id,
                current_status,
                bool(meeting.recall_bot_id),
            )
        return False

    humans_seen = meeting.id in _meetings_with_human_seen
    timeout_minutes = OPEN_MEETING_AFTER_LEFT_TIMEOUT_MINUTES if humans_seen else OPEN_MEETING_EMPTY_TIMEOUT_MINUTES
    reason = "everyone_left" if humans_seen else "initial_empty"
    deadline = datetime.now(timezone.utc) + timedelta(minutes=timeout_minutes)

    if schedule_if_needed:
        _schedule_open_meeting_timeout_check(meeting.id, deadline, reason)
        return False

    bot_id = meeting.recall_bot_id
    try:
        recall_service.leave_call(bot_id)
    except Exception as exc:
        logger.warning(
            "Open-meeting timeout cleanup failed for meeting %s. Bot id: %s. Reason: %s. Error: %s.",
            meeting.id,
            bot_id,
            reason,
            exc,
        )
        return False

    _clear_open_meeting_timeout_state(meeting.id)
    logger.info(
        "Meeting %s bot was removed from an open call after %s minute(s). Reason: %s. Previous bot id: %s.",
        meeting.id,
        timeout_minutes,
        reason,
        bot_id,
    )
    return True


def _schedule_waiting_room_timeout_check(meeting_id: int, deadline: datetime) -> None:
    delay_seconds = max((deadline - datetime.now(timezone.utc)).total_seconds(), 0)
    if delay_seconds <= 0:
        _run_waiting_room_timeout_check(meeting_id)
        return

    with _waiting_room_timer_lock:
        existing_timer = _waiting_room_timers.get(meeting_id)
        if existing_timer and existing_timer.is_alive():
            return

        timer = Timer(delay_seconds, _run_waiting_room_timeout_check, args=[meeting_id])
        timer.daemon = True
        _waiting_room_timers[meeting_id] = timer
        timer.start()
        logger.info(
            "Waiting-room timeout check was scheduled for meeting %s in %.1f seconds.",
            meeting_id,
            delay_seconds,
        )


def _schedule_open_meeting_timeout_check(meeting_id: int, deadline: datetime, reason: str) -> None:
    delay_seconds = max((deadline - datetime.now(timezone.utc)).total_seconds(), 0)
    if delay_seconds <= 0:
        _run_open_meeting_timeout_check(meeting_id, reason)
        return

    with _open_meeting_timer_lock:
        existing_timer = _open_meeting_timers.get(meeting_id)
        if existing_timer and existing_timer.is_alive():
            existing_timer.cancel()

        timer = Timer(delay_seconds, _run_open_meeting_timeout_check, args=[meeting_id, reason])
        timer.daemon = True
        _open_meeting_timers[meeting_id] = timer
        timer.start()
        logger.info(
            "Open-meeting timeout check was scheduled for meeting %s in %.1f seconds. Reason: %s.",
            meeting_id,
            delay_seconds,
            reason,
        )


def _run_waiting_room_timeout_check(meeting_id: int) -> None:
    with _waiting_room_timer_lock:
        _waiting_room_timers.pop(meeting_id, None)

    db = SessionLocal()
    try:
        meeting = crud.get_meeting_by_id(db, meeting_id)
        if not meeting:
            return
        enforce_waiting_room_timeout(db, meeting, schedule_if_needed=False)
    finally:
        db.close()


def _run_open_meeting_timeout_check(meeting_id: int, reason: str) -> None:
    with _open_meeting_timer_lock:
        _open_meeting_timers.pop(meeting_id, None)

    db = SessionLocal()
    try:
        meeting = crud.get_meeting_by_id(db, meeting_id)
        if not meeting:
            _clear_open_meeting_timeout_state(meeting_id)
            return

        current_status = meeting.bot_status or meeting.status or ""
        humans_seen = meeting_id in _meetings_with_human_seen
        expected_reason = "everyone_left" if humans_seen else "initial_empty"
        if current_status not in {"in_call_not_recording", "bot.in_call_not_recording"} or expected_reason != reason:
            logger.info(
                "Open-meeting timeout check was skipped for meeting %s because call state changed. Expected reason: %s. Current status: %s.",
                meeting_id,
                reason,
                current_status,
            )
            return

        enforce_open_meeting_timeout(db, meeting, schedule_if_needed=False)
    finally:
        db.close()


def _cancel_open_meeting_timeout_check(meeting_id: int) -> None:
    with _open_meeting_timer_lock:
        existing_timer = _open_meeting_timers.pop(meeting_id, None)
        if existing_timer and existing_timer.is_alive():
            existing_timer.cancel()


def _clear_open_meeting_timeout_state(meeting_id: int) -> None:
    _cancel_open_meeting_timeout_check(meeting_id)
    _meetings_with_human_seen.discard(meeting_id)


def _is_scheduled_auto_join_meeting(meeting: Meeting) -> bool:
    if meeting.scheduled_at and meeting.recall_join_at:
        return True
    if meeting.is_recurring and meeting.recall_join_at:
        logger.info(
            "Open-meeting timeout is using recall_join_at as the schedule anchor for recurring meeting %s because scheduled_at is missing.",
            meeting.id,
        )
        return True
    return False


def _auto_create_transcript_if_ready(db, meeting_id: int, recording) -> None:
    if not recording or not recording.recall_recording_id:
        logger.info("Automatic transcript creation was skipped for meeting %s because the recording id is missing.", meeting_id)
        return
    if crud.recall_transcript_already_requested(db, meeting_id):
        logger.info("Automatic transcript creation was skipped for meeting %s because a transcript request already exists.", meeting_id)
        return
    if recording.status != "done":
        logger.info("Automatic transcript creation was skipped for meeting %s because the recording is still %s.", meeting_id, recording.status)
        return

    logger.info(
        "Automatic transcript creation has started for meeting %s. Recording id: %s.",
        meeting_id,
        recording.recall_recording_id,
    )
    transcript_response = recall_service.create_transcript(recording.recall_recording_id, language_code="auto")
    crud.save_recall_transcript_request(db, meeting_id, transcript_response)
    logger.info("Automatic transcript creation request was submitted for meeting %s.", meeting_id)


def _sync_transcript_content_if_ready(db, meeting_id: int, transcript) -> None:
    if not transcript or transcript.status != "done":
        return

    download_url = transcript.download_url
    if not download_url:
        return

    if transcript.full_text and not recall_service.transcript_content_needs_refresh(
        transcript.transcript_json,
        transcript.speaker_map,
    ):
        logger.info("Transcript content download was skipped for meeting %s because the content is already saved.", meeting_id)
        return

    logger.info("Transcript content download has started for meeting %s from the Recall webhook flow.", meeting_id)
    payload = recall_service.download_transcript_json(download_url)
    normalized_payload = recall_service.normalize_transcript_payload(payload)
    crud.save_recall_transcript_content(db, meeting_id, normalized_payload)


def _find_meeting_for_payload(db, payload: dict, fallback_bot_id: str | None):
    data = payload.get("data") or {}
    bot = _extract_bot_object(payload)
    bot_id = (bot or {}).get("id") or fallback_bot_id
    metadata = (bot or {}).get("metadata") or {}

    if bot_id:
        meeting = crud.get_meeting_by_recall_bot_id(db, bot_id)
        if meeting:
            logger.info("Recall webhook was matched to meeting %s by bot id %s.", meeting.id, bot_id)
            return meeting
        logger.info("Recall webhook included bot id %s, but no local meeting matched it.", bot_id)

    recording_id = _extract_recording_id(payload)
    if recording_id:
        recording = crud.get_recording_by_recall_recording_id(db, recording_id)
        if recording:
            logger.info("Recall webhook was matched to meeting %s by recording id %s.", recording.meeting_id, recording_id)
            return db.query(Meeting).filter(Meeting.id == recording.meeting_id).first()
        logger.info("Recall webhook included recording id %s, but no local recording matched it.", recording_id)

    meeting_id = metadata.get("meeting_id")
    if meeting_id:
        try:
            meeting = db.query(Meeting).filter(Meeting.id == int(meeting_id)).first()
            if meeting:
                logger.info("Recall webhook was matched to meeting %s from bot metadata.", meeting_id)
                return meeting
            logger.info("Recall webhook bot metadata referenced meeting %s, but no local meeting matched it.", meeting_id)
            return None
        except (TypeError, ValueError):
            return None

    data_metadata = data.get("metadata") or {}
    meeting_id = data_metadata.get("meeting_id")
    if meeting_id:
        try:
            meeting = db.query(Meeting).filter(Meeting.id == int(meeting_id)).first()
            if meeting:
                logger.info("Recall webhook was matched to meeting %s from data metadata.", meeting_id)
                return meeting
            logger.info("Recall webhook data metadata referenced meeting %s, but no local meeting matched it.", meeting_id)
            return None
        except (TypeError, ValueError):
            return None
    logger.info("Recall webhook could not be matched to any local meeting.")
    return None


def _extract_bot_object(payload: dict) -> dict | None:
    data = payload.get("data") or {}
    if isinstance(data.get("bot"), dict):
        return data.get("bot")
    if payload.get("event", "").startswith("bot.") and isinstance(data, dict):
        return data
    return None


def _extract_recording_id(payload: dict) -> str | None:
    data = payload.get("data") or {}
    if isinstance(data.get("recording"), dict):
        return data["recording"].get("id")
    if payload.get("event", "").startswith("recording.") and isinstance(data, dict):
        return data.get("id")
    recording = data.get("recording") or {}
    return recording.get("id")


def _extract_transcript_id(payload: dict) -> str | None:
    data = payload.get("data") or {}
    if isinstance(data.get("transcript"), dict):
        return data["transcript"].get("id")
    if payload.get("event", "").startswith("transcript.") and isinstance(data, dict):
        return data.get("id")
    transcript = data.get("transcript") or {}
    return transcript.get("id")
