"""
License files: how a tenant's license (app/crud/licensing.py) travels from
the Super Admin to the tenant's own administrators.

The Super Admin issues a file for a tenant with the terms to grant; nothing
changes yet. The tenant's administrator applies it (Users page, or the
sign-in page when the license has already ended), and only then do the
tenant's license terms become the file's.

The file is the terms, encrypted and authenticated (Fernet: AES + HMAC)
with a key derived from WORKSPACE_ENCRYPTION_KEY, so nobody can read or
change it, or make one, without that key. Each file also names a
license_files row, which makes it:
- **single-use** — applied once;
- **revocable** — the Super Admin can withdraw one not applied yet;
- **ordered** — a file issued before the one in effect can't be applied
  over it.
A file only applies to the tenant it was issued for.

Changing WORKSPACE_ENCRYPTION_KEY makes earlier files unreadable; issue
new ones.
"""

import base64
import json
import re
import uuid
from datetime import datetime, timezone
from functools import lru_cache

from cryptography.fernet import Fernet, InvalidToken
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from app.core.config import settings
from app.crud import licensing
from app.crud.audit import write_audit_log
from app.models.identity import LicenseFile, Tenant, User
from app.schemas.identity import LicenseFileCheck, LicenseFileRead, TenantLicense

_BEGIN = "-----BEGIN FLOWFABRIC LICENSE-----"
_END = "-----END FLOWFABRIC LICENSE-----"
_VERSION = 1


class LicenseFileError(Exception):
    def __init__(self, message: str, status_code: int = 422) -> None:
        super().__init__(message)
        self.message = message
        self.status_code = status_code


def http_error(exc: LicenseFileError) -> HTTPException:
    return HTTPException(status_code=exc.status_code, detail=exc.message)


@lru_cache(maxsize=1)
def _fernet() -> Fernet:
    # Its own key, not the one that encrypts stored passwords.
    key = HKDF(algorithm=hashes.SHA256(), length=32, salt=None, info=b"flowfabric license file v1").derive(
        settings.workspace_encryption_key.encode()
    )
    return Fernet(base64.urlsafe_b64encode(key))


def seal(payload: dict) -> str:
    token = _fernet().encrypt(json.dumps(payload, separators=(",", ":")).encode()).decode()
    # Fixed 64-character lines (textwrap.wrap would also break at the "-"
    # characters base64url uses, giving ragged lines).
    return "\n".join([_BEGIN, *(token[i : i + 64] for i in range(0, len(token), 64)), _END, ""])


def unseal(content: str) -> dict:
    body = content.strip()
    if body.startswith(_BEGIN) and body.endswith(_END):
        body = body[len(_BEGIN) : -len(_END)]
    token = re.sub(r"\s+", "", body)
    try:
        payload = json.loads(_fernet().decrypt(token.encode()))
    except (InvalidToken, ValueError):
        raise LicenseFileError(
            "This isn't a valid license file. It may be damaged, or issued by a different FlowFabric installation."
        ) from None
    if not isinstance(payload, dict) or payload.get("v") != _VERSION:
        raise LicenseFileError("This license file is from a different version of FlowFabric.")
    return payload


def tenant_of(content: str) -> uuid.UUID:
    """The tenant a license file is for, before anything else is checked."""
    try:
        return uuid.UUID(unseal(content)["tenant_id"])
    except (KeyError, TypeError, ValueError):
        raise LicenseFileError("This isn't a valid license file.") from None


def filename_for(tenant: Tenant, file: LicenseFile) -> str:
    slug = re.sub(r"[^a-z0-9]+", "-", tenant.name.lower()).strip("-") or "tenant"
    until = f"-until-{file.ends_on.isoformat()}" if file.ends_on else ""
    return f"{slug}-license{until}.lic"


def content_for(tenant: Tenant, file: LicenseFile) -> str:
    terms = TenantLicense.model_validate(file.license).model_dump(mode="json")
    return seal({"v": _VERSION, "id": str(file.id), "tenant_id": str(tenant.id), "tenant_name": tenant.name, "issued_at": file.issued_at.isoformat(), **terms})


# ---- the Super Admin ----------------------------------------------------------------------------------------


async def issue(db: AsyncSession, tenant: Tenant, terms: TenantLicense, admin_id: uuid.UUID) -> tuple[LicenseFile, str]:
    """A new file granting `terms`. `db` must be scoped to the tenant."""
    if terms.ends_on is not None and terms.ends_on < licensing.today():
        raise LicenseFileError("The end date has already passed — a file for it couldn't be used.")
    file = LicenseFile(
        tenant_id=tenant.id, max_users=terms.max_users, max_portal_users=terms.max_portal_users,
        starts_on=terms.starts_on, ends_on=terms.ends_on, issued_at=datetime.now(timezone.utc), issued_by=admin_id,
    )
    db.add(file)
    await db.flush()
    await write_audit_log(
        db, tenant.id, None, "tenant.license_file_issued", "license_file", file.id,
        {"license": terms.model_dump(mode="json"), "platform_admin_id": str(admin_id)},
    )
    return file, content_for(tenant, file)


async def history(db: AsyncSession, tenant_id: uuid.UUID) -> list[LicenseFileRead]:
    """Every file issued for the tenant, newest first."""
    rows = (
        await db.execute(
            select(LicenseFile, User.name)
            .outerjoin(User, User.id == LicenseFile.applied_by)
            .where(LicenseFile.tenant_id == tenant_id)
            .order_by(LicenseFile.issued_at.desc())
        )
    ).all()
    latest_applied = max((f.issued_at for f, _ in rows if f.applied_at), default=None)
    result = []
    for file, applied_by_name in rows:
        if file.revoked_at:
            status = "revoked"
        elif file.applied_at:
            status = "applied"
        elif latest_applied and file.issued_at < latest_applied:
            status = "superseded"
        else:
            status = "waiting"
        read = LicenseFileRead.model_validate(file)
        read.applied_by_name, read.status = applied_by_name, status
        result.append(read)
    return result


async def revoke(db: AsyncSession, tenant_id: uuid.UUID, file_id: uuid.UUID) -> None:
    file = await _row(db, tenant_id, file_id)
    if file.applied_at:
        raise LicenseFileError("This license file has already been applied — change the license instead.", 409)
    if file.revoked_at is None:
        file.revoked_at = datetime.now(timezone.utc)
        await write_audit_log(db, tenant_id, None, "tenant.license_file_revoked", "license_file", file.id)


async def _row(db: AsyncSession, tenant_id: uuid.UUID, file_id: uuid.UUID) -> LicenseFile:
    file = (await db.execute(select(LicenseFile).where(LicenseFile.tenant_id == tenant_id, LicenseFile.id == file_id))).scalar_one_or_none()
    if file is None:
        raise LicenseFileError("License file not found.", 404)
    return file


# ---- the tenant ---------------------------------------------------------------------------------------------


async def check(db: AsyncSession, tenant: Tenant, content: str) -> tuple[LicenseFile, LicenseFileCheck]:
    """The file's row, and what applying it would change — or why it can't
    be applied. `db` must be scoped to `tenant`."""
    payload = unseal(content)
    if payload.get("tenant_id") != str(tenant.id):
        raise LicenseFileError(f"This license file is for a different workspace ({payload.get('tenant_name') or 'unknown'}).")
    file = (
        await db.execute(select(LicenseFile).where(LicenseFile.tenant_id == tenant.id, LicenseFile.id == uuid.UUID(payload["id"])).with_for_update())
    ).scalar_one_or_none()
    if file is None:
        raise LicenseFileError("This license file is no longer valid. Ask for a new one.")
    terms = TenantLicense.model_validate(file.license)
    if terms.model_dump(mode="json") != TenantLicense.model_validate(payload).model_dump(mode="json"):
        raise LicenseFileError("This license file doesn't match what was issued. Ask for a new one.")
    if file.revoked_at:
        raise LicenseFileError("This license file was withdrawn. Ask for a new one.")
    if file.applied_at:
        raise LicenseFileError(f"This license file was already applied, on {licensing.long_date(file.applied_at.date())}.", 409)
    newer = (
        await db.execute(
            select(LicenseFile.applied_at).where(LicenseFile.tenant_id == tenant.id, LicenseFile.applied_at.is_not(None), LicenseFile.issued_at > file.issued_at)
        )
    ).first()
    if newer:
        raise LicenseFileError("A newer license file has already been applied, so this one can't be.", 409)
    if terms.ends_on is not None and terms.ends_on < licensing.today():
        raise LicenseFileError(f"This license ended on {licensing.long_date(terms.ends_on)}. Ask for a new one.")
    return file, LicenseFileCheck(
        tenant_name=tenant.name, license=terms, current=TenantLicense.model_validate(tenant.license), issued_at=file.issued_at
    )


async def apply(db: AsyncSession, tenant: Tenant, content: str, user_id: uuid.UUID | None) -> None:
    """Makes the file's terms the tenant's license. `db` must be scoped to
    `tenant`; `user_id` is who applied it (None from the sign-in page)."""
    file, checked = await check(db, tenant, content)
    before = checked.current.model_dump(mode="json")
    tenant.license_set_at = tenant.license_set_at or datetime.now(timezone.utc)
    tenant.license_max_users = file.max_users
    tenant.license_max_portal_users = file.max_portal_users
    tenant.license_starts_on = file.starts_on
    tenant.license_ends_on = file.ends_on
    file.applied_at = datetime.now(timezone.utc)
    file.applied_by = user_id
    await db.flush()
    licensing.forget(tenant.id)
    await write_audit_log(
        db, tenant.id, user_id, "tenant.license_file_applied", "license_file", file.id,
        {"before": before, "after": checked.license.model_dump(mode="json")},
    )
