# app/models.py

from sqlalchemy import Boolean, Column, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint, func

from app.database import Base

# Foreign key constants
_FILES_ID_FK = "files.id"
_PIPELINES_ID_FK = "pipelines.id"
_ROUTING_RULES_TABLE = "pipeline_routing_rules"


class DocumentMetadata(Base):
    __tablename__ = "documents"

    id = Column(Integer, primary_key=True, index=True)
    filename = Column(String, unique=True, index=True)
    sender = Column(String)
    recipient = Column(String)
    tags = Column(String)
    summary = Column(String)


class FileRecord(Base):
    __tablename__ = "files"

    id = Column(Integer, primary_key=True, index=True)

    # Owner identifier for multi-user mode.
    # Stores the user's unique identifier (e.g. email or OAuth sub claim).
    # NULL means the file belongs to the shared/global space (single-user mode).
    owner_id = Column(String, nullable=True, index=True)

    # Hash of the file content (e.g. SHA-256)
    # Note: duplicates are allowed so filehash is not unique
    filehash = Column(String, index=True, nullable=False)

    # The name of the file as it was originally uploaded (if known)
    original_filename = Column(String)

    # The name/path we store on disk (e.g. /workdir/tmp/<uuid>.pdf)
    local_filename = Column(String, nullable=False)

    # Immutable original copy path (e.g. /workdir/original/<uuid>.pdf)
    # This is the first copy made when the file is ingested
    original_file_path = Column(String)

    # Processed copy path (e.g. /workdir/processed/2024-01-01_Invoice.pdf)
    # This is the final file with embedded metadata before upload
    processed_file_path = Column(String)

    # Size of the file in bytes
    file_size = Column(Integer, nullable=False)

    # MIME type or extension (optional)
    mime_type = Column(String, index=True)

    # Deduplication tracking: True if this file is a duplicate of another file
    # When a duplicate is detected, this file record is created but marked as duplicate
    is_duplicate = Column(Boolean, default=False, nullable=False, index=True)

    # If this is a duplicate, record the ID of the original file for reference
    duplicate_of_id = Column(Integer, ForeignKey(_FILES_ID_FK), nullable=True)

    # Full OCR/extracted text for full-text search and RAG
    ocr_text = Column(Text, nullable=True)

    # AI-assessed quality score for the OCR/extracted text (0–100; NULL = not yet assessed)
    ocr_quality_score = Column(Integer, nullable=True)

    # AI-extracted metadata stored as JSON string (filename, tags, title, sender, etc.)
    ai_metadata = Column(Text, nullable=True)

    # Human-readable document title from AI metadata
    document_title = Column(String, nullable=True)

    # PDF/A archival variant paths (generated when ENABLE_PDFA_CONVERSION is True)
    original_pdfa_path = Column(String, nullable=True)  # PDF/A copy of the original ingested file
    processed_pdfa_path = Column(String, nullable=True)  # PDF/A copy of the processed file

    # Pre-computed text embedding vector stored as JSON array of floats
    embedding = Column(Text, nullable=True)

    # Processing pipeline assigned to this file (NULL = use system default)
    pipeline_id = Column(Integer, ForeignKey(_PIPELINES_ID_FK), nullable=True, index=True)

    # Detected document language (ISO 639-1 code, e.g. "de", "en", "fr")
    # Extracted from AI metadata during processing; cached here for fast access.
    detected_language = Column(String(10), nullable=True)

    # Default-language translation of the extracted text.
    # Stored when the detected language differs from the user's/system default
    # document language.  Only the original text and this translation are persisted;
    # other languages are translated on the fly via the AI provider.
    default_language_text = Column(Text, nullable=True)

    # ISO 639-1 code of the default-language translation stored above (e.g. "en").
    default_language_code = Column(String(10), nullable=True)

    # Timestamp when we inserted this record
    created_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)


class FileProcessingStep(Base):
    """
    Tracks the current status of each processing step for a file.
    This provides a definitive, queryable state for each step without scanning logs.
    """

    __tablename__ = "file_processing_steps"

    id = Column(Integer, primary_key=True, index=True)
    file_id = Column(Integer, ForeignKey(_FILES_ID_FK), nullable=False, index=True)
    step_name = Column(String, nullable=False, index=True)  # e.g., "hash_file", "upload_to_dropbox"
    status = Column(String, nullable=False, index=True)  # "pending", "in_progress", "success", "failure", "skipped"
    started_at = Column(DateTime(timezone=True), nullable=True)  # When step started
    completed_at = Column(DateTime(timezone=True), nullable=True)  # When step finished (success/failure)
    error_message = Column(Text, nullable=True)  # Error message if status is "failure"
    created_at = Column(DateTime(timezone=True), server_default=func.now())
    updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())

    __table_args__ = (UniqueConstraint("file_id", "step_name", name="unique_file_step"),)


class ProcessingLog(Base):
    __tablename__ = "processing_logs"
    id = Column(Integer, primary_key=True, index=True)
    file_id = Column(Integer, ForeignKey(_FILES_ID_FK), nullable=True, index=True)  # Optional file association
    task_id = Column(String, index=True)  # Celery task ID
    step_name = Column(String)  # e.g., "OCR", "convert_to_pdf", "upload_s3"
    status = Column(String)  # "pending", "in_progress", "success", "failure"
    message = Column(String, nullable=True)  # Error text or success note
    detail = Column(Text, nullable=True)  # Verbose worker log output for diagnostics
    timestamp = Column(DateTime(timezone=True), server_default=func.now(), index=True)


class ApplicationSettings(Base):
    """Store application settings in database with precedence over environment variables"""

    __tablename__ = "application_settings"

    id = Column(Integer, primary_key=True, index=True)
    key = Column(String, unique=True, index=True, nullable=False)  # Setting key (e.g., 'database_url')
    value = Column(String, nullable=True)  # Setting value (stored as string, converted as needed)
    created_at = Column(DateTime(timezone=True), server_default=func.now())
    updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())


class SettingsAuditLog(Base):
    """Audit log for all configuration changes made via the settings UI."""

    __tablename__ = "settings_audit_log"

    id = Column(Integer, primary_key=True, index=True)
    key = Column(String, nullable=False, index=True)  # Setting key that was changed
    old_value = Column(String, nullable=True)  # Previous value (None if first-time set)
    new_value = Column(String, nullable=True)  # New value (None if deleted)
    changed_by = Column(String, nullable=False)  # Username of the admin who made the change
    changed_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
    action = Column(String, nullable=False)  # "update" or "delete"


class AuditLog(Base):
    """Comprehensive audit log for compliance tracking.

    Records all significant actions: login/logout, document CRUD, settings
    changes, and administrative operations.  Rows are append-only; the API
    and service layer never update or delete entries.
    """

    __tablename__ = "audit_logs"

    id = Column(Integer, primary_key=True, index=True)
    timestamp = Column(DateTime(timezone=True), server_default=func.now(), nullable=False, index=True)
    user = Column(String, nullable=False, index=True)  # Username or "anonymous" / "system"
    action = Column(String, nullable=False, index=True)  # e.g. "login", "document.create", "settings.update"
    resource_type = Column(String, nullable=True, index=True)  # e.g. "document", "user", "settings"
    resource_id = Column(String, nullable=True)  # ID of the affected resource
    ip_address = Column(String, nullable=True)  # Client IP address
    details = Column(Text, nullable=True)  # JSON-encoded extra context
    severity = Column(String(16), nullable=False, server_default="info")  # info / warning / error / critical


class SavedSearch(Base):
    """User-defined saved search filters for quick access to frequently used filter combinations."""

    __tablename__ = "saved_searches"

    id = Column(Integer, primary_key=True, index=True)
    user_id = Column(String, nullable=False, index=True)  # Username or user identifier from session
    name = Column(String, nullable=False)  # Human-readable name for the saved search
    filters = Column(Text, nullable=False)  # JSON-encoded filter parameters
    created_at = Column(DateTime(timezone=True), server_default=func.now())
    updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())

    __table_args__ = (UniqueConstraint("user_id", "name", name="unique_user_search_name"),)


class WebhookConfig(Base):
    """Webhook configuration for notifying external systems of document events."""

    __tablename__ = "webhook_configs"

    id = Column(Integer, primary_key=True, index=True)
    url = Column(String, nullable=False)  # Target URL for webhook delivery
    secret = Column(String, nullable=True)  # Shared secret for HMAC-SHA256 signature
    events = Column(Text, nullable=False)  # JSON list of subscribed events
    is_active = Column(Boolean, default=True, nullable=False)  # Whether the webhook is active
    description = Column(String, nullable=True)  # Optional human-readable description
    created_at = Column(DateTime(timezone=True), server_default=func.now())
    updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())


class AutomationHook(Base):
    """Zapier / Make.com compatible webhook subscription for automation triggers.

    External automation platforms subscribe to DocuElevate events via the REST
    hooks protocol.  When an event fires, DocuElevate POSTs a Zapier-compatible
    flat JSON payload to ``target_url``.  The ``hook_type`` field records which
    platform created the subscription (informational only).
    """

    __tablename__ = "automation_hooks"

    id = Column(Integer, primary_key=True, index=True)
    target_url = Column(String, nullable=False)  # URL to POST events to
    secret = Column(String, nullable=True)  # Optional HMAC-SHA256 signing secret
    events = Column(Text, nullable=False)  # JSON list of subscribed event names
    is_active = Column(Boolean, default=True, nullable=False)
    hook_type = Column(String(50), nullable=False, default="generic")  # zapier | make | generic
    description = Column(String, nullable=True)  # Optional human-readable label
    created_at = Column(DateTime(timezone=True), server_default=func.now())
    updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())


class LocalUser(Base):
    """A locally-registered user authenticated by email and bcrypt password.

    Created during the self-registration flow when ``allow_local_signup`` is
    enabled.  The account is inactive (``is_active=False``) until the user
    clicks the verification link sent to their email address.
    """

    __tablename__ = "local_users"

    id = Column(Integer, primary_key=True, index=True)
    email = Column(String(255), unique=True, nullable=False, index=True)
    username = Column(String(64), unique=True, nullable=False, index=True)
    display_name = Column(String(255), nullable=True)
    hashed_password = Column(String(255), nullable=False)
    is_active = Column(Boolean, nullable=False, default=False, server_default="0")
    is_admin = Column(Boolean, nullable=False, default=False, server_default="0")
    email_verification_token = Column(String(128), nullable=True)
    email_verification_sent_at = Column(DateTime(timezone=True), nullable=True)
    password_reset_token = Column(String(128), nullable=True)
    password_reset_sent_at = Column(DateTime(timezone=True), nullable=True)
    created_at = Column(DateTime(timezone=True), server_default=func.now())
    updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())


class UserProfile(Base):
    """Per-user profile for admin-managed settings in multi-user mode.

    Each row corresponds to one authenticated user (identified by their
    ``user_id``, which matches ``FileRecord.owner_id``).  The admin can
    create or update profiles to override global defaults such as the
    daily upload limit and to attach notes or block a user.
    """

    __tablename__ = "user_profiles"

    id = Column(Integer, primary_key=True, index=True)

    # Stable user identifier — matches FileRecord.owner_id (OAuth sub / email / username)
    user_id = Column(String, unique=True, nullable=False, index=True)

    # Optional human-readable display name set by the admin
    display_name = Column(String, nullable=True)

    # Per-user daily upload limit; NULL means "use global default"
    daily_upload_limit = Column(Integer, nullable=True)

    # Admin-only free-text notes about this user
    notes = Column(Text, nullable=True)

    # When True the user is prevented from uploading new documents
    is_blocked = Column(Boolean, default=False, nullable=False)

    # Subscription tier: "free" | "starter" | "professional" | "business"
    # NULL is treated as "free" by the subscription utility.
    subscription_tier = Column(String(50), nullable=True, default="free")

    # Billing cycle and overage settings (added in migration 016)
    subscription_billing_cycle = Column(String(10), nullable=False, default="monthly", server_default="monthly")
    subscription_period_start = Column(DateTime(timezone=True), nullable=True)
    allow_overage = Column(Boolean, nullable=False, default=False, server_default="0")

    # Pending subscription change (added in migration 020_add_subscription_change_pending)
    # When a user requests a downgrade, the new tier is stored here and the
    # change is applied on `subscription_change_pending_date`.  Upgrades are
    # applied immediately and these fields are left NULL.
    subscription_change_pending_tier = Column(String(50), nullable=True)
    subscription_change_pending_date = Column(DateTime(timezone=True), nullable=True)

    # When True, the user is on a complimentary (uncharged) plan — they keep all tier
    # quota benefits but are never billed via Stripe.  Automatically set for admin users.
    is_complimentary = Column(Boolean, nullable=False, default=False, server_default="0")

    # Onboarding tracking (added in migration 017)
    onboarding_completed = Column(Boolean, nullable=False, default=False, server_default="0")
    onboarding_completed_at = Column(DateTime(timezone=True), nullable=True)
    contact_email = Column(String(255), nullable=True)
    preferred_destination = Column(String(50), nullable=True)
    stripe_customer_id = Column(String(64), nullable=True)

    # UI language preference for i18n (ISO 639-1 code, e.g. "en", "de", "fr")
    # NULL means "auto-detect from browser Accept-Language header"
    preferred_language = Column(String(10), nullable=True)

    # Default document language for translated versions (ISO 639-1 code).
    # When a document's detected language differs from this value, the system
    # automatically generates and stores a translation into this language.
    # NULL means "use the global DEFAULT_DOCUMENT_LANGUAGE setting".
    default_document_language = Column(String(10), nullable=True)

    # UI colour scheme preference: "light" | "dark" | "system" (NULL = "system")
    preferred_theme = Column(String(10), nullable=True)

    # Custom profile avatar stored as a base64 data-URL (e.g. "data:image/png;base64,...")
    # NULL means use the Gravatar fallback derived from the user's e-mail address.
    avatar_data = Column(Text, nullable=True)

    created_at = Column(DateTime(timezone=True), server_default=func.now())
    updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())


class SubscriptionPlan(Base):
    """Dynamically configurable subscription plan stored in the database.

    Plans are shown on the public /pricing page and assigned to users via
    UserProfile.subscription_tier (which stores plan_id).  On first start the
    four default plans are seeded from TIER_DEFAULTS in app/utils/subscription.py.
    """

    __tablename__ = "subscription_plans"

    id = Column(Integer, primary_key=True, index=True)
    plan_id = Column(String(50), unique=True, nullable=False, index=True)
    name = Column(String(100), nullable=False)
    tagline = Column(String(255), nullable=True)

    # Pricing
    price_monthly = Column(Float, nullable=False, default=0.0)
    price_yearly = Column(Float, nullable=False, default=0.0)
    trial_days = Column(Integer, nullable=False, default=0)

    # Volume limits (0 = unlimited)
    lifetime_file_limit = Column(Integer, nullable=False, default=0)
    daily_upload_limit = Column(Integer, nullable=False, default=0)
    monthly_upload_limit = Column(Integer, nullable=False, default=0)
    max_storage_destinations = Column(Integer, nullable=False, default=0)
    max_ocr_pages_monthly = Column(Integer, nullable=False, default=0)
    max_file_size_mb = Column(Integer, nullable=False, default=0)
    max_mailboxes = Column(Integer, nullable=False, default=0)

    # Overage configuration
    overage_percent = Column(Integer, nullable=False, default=20)
    allow_overage_billing = Column(Boolean, nullable=False, default=False)
    overage_price_per_doc = Column(Float, nullable=True)
    overage_price_per_ocr_page = Column(Float, nullable=True)

    # Display / marketing
    is_active = Column(Boolean, nullable=False, default=True)
    is_highlighted = Column(Boolean, nullable=False, default=False)
    badge_text = Column(String(50), nullable=True)
    cta_text = Column(String(100), nullable=False, default="Get started")
    sort_order = Column(Integer, nullable=False, default=0)
    features = Column(Text, nullable=True)  # JSON-encoded list[str]
    api_access = Column(Boolean, nullable=False, default=False)
    stripe_price_id_monthly = Column(String(128), nullable=True)
    stripe_price_id_yearly = Column(String(128), nullable=True)

    created_at = Column(DateTime(timezone=True), server_default=func.now())
    updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())


class Pipeline(Base):
    """User-defined processing pipeline: an ordered set of steps.

    Pipelines are user-specific.  A pipeline with ``owner_id = NULL`` is a
    *system default* pipeline that only admins may create.  Regular users
    create pipelines under their own ``owner_id``.  When a file has no
    explicit pipeline assigned, the active system default is used.
    """

    __tablename__ = "pipelines"

    id = Column(Integer, primary_key=True, index=True)

    # Owner of this pipeline.  NULL = system/admin pipeline visible to everyone.
    owner_id = Column(String, nullable=True, index=True)

    # Human-readable name (unique per owner)
    name = Column(String(255), nullable=False)

    # Optional description
    description = Column(Text, nullable=True)

    # When True this pipeline is the default for new files belonging to the owner
    # (or the global default when owner_id is NULL).  Only one pipeline per
    # owner may be active default at a time — enforced at the application level.
    is_default = Column(Boolean, nullable=False, default=False)

    # Soft-disable without deleting
    is_active = Column(Boolean, nullable=False, default=True)

    created_at = Column(DateTime(timezone=True), server_default=func.now())
    updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())


class PipelineStep(Base):
    """A single step in a processing pipeline.

    Steps are executed in ascending ``position`` order.  Each step has a
    ``step_type`` that maps to a built-in processing action and an optional
    ``config`` JSON blob with step-specific parameters.
    """

    __tablename__ = "pipeline_steps"

    id = Column(Integer, primary_key=True, index=True)
    pipeline_id = Column(Integer, ForeignKey(_PIPELINES_ID_FK), nullable=False, index=True)

    # Execution order within the pipeline (lower = earlier)
    position = Column(Integer, nullable=False, default=0)

    # One of the recognised step types (see PIPELINE_STEP_TYPES in pipelines.py)
    step_type = Column(String(100), nullable=False)

    # Optional human-readable label override (defaults to step_type label)
    label = Column(String(255), nullable=True)

    # JSON-encoded step-specific configuration dict
    config = Column(Text, nullable=True)

    # When False this step is skipped during execution
    enabled = Column(Boolean, nullable=False, default=True)

    created_at = Column(DateTime(timezone=True), server_default=func.now())
    updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())


class ImapIngestionProfile(Base):
    """Named ingestion profile controlling which attachment types are accepted from IMAP emails.

    Profiles group file-type categories (e.g. "pdf", "office", "images") so users
    can precisely control what gets ingested from each mailbox.

    System-provided built-in profiles (``is_builtin=True``) are seeded by the
    migration and cannot be deleted or renamed.  Users may create their own profiles
    (``owner_id`` set to their identifier) or rely on the global system profiles
    (``owner_id=None``).

    ``allowed_categories`` stores a JSON list of category strings, e.g.::

        '["pdf", "office", "opendocument", "text", "web"]'

    Valid category names are defined in ``app.utils.allowed_types.FILE_TYPE_CATEGORIES``.
    """

    __tablename__ = "imap_ingestion_profiles"

    id = Column(Integer, primary_key=True, index=True)

    # Human-readable profile name (e.g. "Documents Only", "Documents + Images")
    name = Column(String(255), nullable=False)

    # Optional description shown in the UI
    description = Column(Text, nullable=True)

    # Owner of this profile. NULL = global/system profile available to all users.
    owner_id = Column(String, nullable=True, index=True)

    # JSON-encoded list of enabled category keys.  Example: '["pdf","office","text"]'
    # See FILE_TYPE_CATEGORIES in app/utils/allowed_types.py for valid values.
    allowed_categories = Column(Text, nullable=False, default='["pdf","office","opendocument","text","web"]')

    # Built-in system profiles that cannot be deleted or modified via the API.
    is_builtin = Column(Boolean, nullable=False, default=False)

    created_at = Column(DateTime(timezone=True), server_default=func.now())
    updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())


class UserImapAccount(Base):
    """Per-user IMAP ingestion account.

    Each row represents one IMAP mailbox that a user wants DocuElevate to
    poll for document attachments.  The periodic ``pull_all_inboxes`` Celery
    task iterates over all active accounts and processes any new emails.

    Quota enforcement: the user's subscription plan's ``max_mailboxes`` field
    controls how many accounts a user may configure (0 = unlimited for paid
    plans; free tier is not permitted any accounts).
    """

    __tablename__ = "user_imap_accounts"

    id = Column(Integer, primary_key=True, index=True)

    # Stable owner identifier — matches FileRecord.owner_id
    owner_id = Column(String, nullable=False, index=True)

    # Human-readable label chosen by the user (e.g. "Work Gmail", "Scanner mailbox")
    name = Column(String(255), nullable=False)

    # IMAP connection settings
    host = Column(String(255), nullable=False)
    port = Column(Integer, nullable=False, default=993)
    username = Column(String(255), nullable=False)
    # Password stored encrypted using Fernet symmetric encryption via
    # app.utils.encryption.encrypt_value / decrypt_value (keyed from SESSION_SECRET).
    # New records are always encrypted; legacy plaintext records are transparently
    # handled by decrypt_value which returns the value unchanged when no "enc:" prefix
    # is present.
    password = Column(String(1024), nullable=False)
    use_ssl = Column(Boolean, nullable=False, default=True)

    # Processing options
    # When True, emails are deleted from the mailbox after their attachments are processed
    delete_after_process = Column(Boolean, nullable=False, default=False)

    # Optional reference to an ImapIngestionProfile.
    # NULL means "use the global imap_attachment_filter setting" (system default).
    profile_id = Column(Integer, ForeignKey("imap_ingestion_profiles.id"), nullable=True)

    # When False the account is not polled by the periodic task (but not deleted)
    is_active = Column(Boolean, nullable=False, default=True)

    # Last time this mailbox was successfully polled
    last_checked_at = Column(DateTime(timezone=True), nullable=True)

    # Last error message if the most recent poll failed (NULL = last poll succeeded)
    last_error = Column(Text, nullable=True)

    created_at = Column(DateTime(timezone=True), server_default=func.now())
    updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())


class BackupRecord(Base):
    """Tracks database backup files and their retention metadata.

    Each row represents one backup archive (a gzipped SQLite dump).
    ``backup_type`` classifies the backup for retention purposes:
    - ``hourly``  – kept for up to 4 days (96 snapshots)
    - ``daily``   – kept for up to 3 weeks (21 snapshots)
    - ``weekly``  – kept for up to 13 weeks (≈ 90 days)
    ``local_path`` is the full filesystem path of the local copy (``None``
    once pruned).  ``remote_destination`` and ``remote_path`` describe the
    remote copy when one has been uploaded to a storage provider or e-mailed.
    """

    __tablename__ = "backup_records"

    id = Column(Integer, primary_key=True, index=True)

    # Human-readable archive filename (e.g. backup_hourly_2026-03-07T12-00-00.db.gz)
    filename = Column(String(255), nullable=False, unique=True)

    # Full path on the local filesystem (may be NULL for remote-only backups)
    local_path = Column(String(1024), nullable=True)

    # Classification used by the retention policy
    backup_type = Column(String(20), nullable=False, index=True)  # hourly | daily | weekly

    # Archive size in bytes (0 if unknown)
    size_bytes = Column(Integer, nullable=False, default=0)

    # Checksum of the archive for integrity verification (SHA-256 hex)
    checksum = Column(String(64), nullable=True)

    # Whether the backup was successfully created
    status = Column(String(20), nullable=False, default="ok")  # ok | failed

    # Storage destination where a remote copy was uploaded (e.g. "s3", "dropbox", "email")
    remote_destination = Column(String(50), nullable=True)

    # Path / key of the remote copy (bucket key, folder path, etc.)
    remote_path = Column(String(1024), nullable=True)

    created_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)


# ---------------------------------------------------------------------------
# Integration direction / type constants
# ---------------------------------------------------------------------------


class IntegrationDirection:
    """Direction of data flow for a UserIntegration."""

    SOURCE = "SOURCE"
    DESTINATION = "DESTINATION"

    ALL = {SOURCE, DESTINATION}


class IntegrationType:
    """Supported integration types for UserIntegration."""

    # Source integrations (ingestion)
    IMAP = "IMAP"
    WATCH_FOLDER = "WATCH_FOLDER"
    WEBHOOK = "WEBHOOK"

    # Destination integrations (storage / output)
    S3 = "S3"
    DROPBOX = "DROPBOX"
    GOOGLE_DRIVE = "GOOGLE_DRIVE"
    ONEDRIVE = "ONEDRIVE"
    WEBDAV = "WEBDAV"
    NEXTCLOUD = "NEXTCLOUD"
    FTP = "FTP"
    SFTP = "SFTP"
    EMAIL = "EMAIL"
    PAPERLESS = "PAPERLESS"
    RCLONE = "RCLONE"
    SHAREPOINT = "SHAREPOINT"
    ICLOUD = "ICLOUD"

    ALL = {
        IMAP,
        WATCH_FOLDER,
        WEBHOOK,
        S3,
        DROPBOX,
        GOOGLE_DRIVE,
        ONEDRIVE,
        WEBDAV,
        NEXTCLOUD,
        FTP,
        SFTP,
        EMAIL,
        PAPERLESS,
        RCLONE,
        SHAREPOINT,
        ICLOUD,
    }


class UserIntegration(Base):
    """Generic per-user integration record (source or destination).

    Replaces ad-hoc per-integration-type tables with a single, extensible
    model that supports any combination of ingestion sources and storage
    destinations without schema changes when new integrations are added.

    ``config`` holds non-sensitive connection settings as a JSON string
    (e.g. host, port, bucket name, folder path).

    ``credentials`` holds sensitive secrets (passwords, tokens, API keys)
    as a Fernet-encrypted JSON string.  Always use
    ``app.utils.encryption.encrypt_value`` / ``decrypt_value`` when
    writing / reading this field.

    Example config + credentials shapes by integration type:

    IMAP:
      config      = {"host": "imap.example.com", "port": 993,
                     "username": "user@example.com", "use_ssl": true,
                     "delete_after_process": false,
                     "gmail_apply_labels": true}
      credentials = {"password": "secret"}

    WATCH_FOLDER (local):
      config      = {"source_type": "local",
                     "folder_path": "/data/inbox",
                     "delete_after_process": false}

    WATCH_FOLDER (s3):
      config      = {"source_type": "s3", "bucket": "my-bucket",
                     "region": "us-east-1", "prefix": "inbox/",
                     "endpoint_url": null, "delete_after_process": false}
      credentials = {"access_key_id": "AKI…", "secret_access_key": "…"}

    WATCH_FOLDER (dropbox):
      config      = {"source_type": "dropbox",
                     "folder_path": "/Inbox/Scanner",
                     "delete_after_process": false}
      credentials = {"refresh_token": "…", "app_key": "…",
                     "app_secret": "…"}

    WATCH_FOLDER (google_drive):
      config      = {"source_type": "google_drive",
                     "folder_id": "1abc…",
                     "delete_after_process": false}
      credentials = {"credentials_json": "{…service-account…}"}

    WATCH_FOLDER (onedrive):
      config      = {"source_type": "onedrive",
                     "folder_path": "/Documents/Inbox",
                     "delete_after_process": false}
      credentials = {"refresh_token": "…", "client_id": "…",
                     "client_secret": "…"}

    WATCH_FOLDER (nextcloud):
      config      = {"source_type": "nextcloud",
                     "url": "https://cloud.example.com",
                     "folder_path": "/Documents/Inbox",
                     "delete_after_process": false}
      credentials = {"username": "user", "password": "secret"}

    WATCH_FOLDER (webdav):
      config      = {"source_type": "webdav",
                     "url": "https://webdav.example.com/dav/",
                     "folder_path": "/remote.php/webdav/Inbox",
                     "delete_after_process": false}
      credentials = {"username": "user", "password": "secret"}

    S3:
      config      = {"bucket": "my-bucket", "region": "us-east-1",
                     "endpoint_url": null, "folder_prefix": ""}
      credentials = {"access_key_id": "AKI…", "secret_access_key": "…"}

    DROPBOX:
      config      = {"folder": "/DocuElevate"}
      credentials = {"refresh_token": "…", "app_key": "…",
                     "app_secret": "…"}

    GOOGLE_DRIVE:
      config      = {"folder_id": "1abc…"}
      credentials = {"credentials_json": "{…service-account or OAuth…}"}

    WEBDAV / NEXTCLOUD:
      config      = {"url": "https://cloud.example.com/dav/",
                     "folder": "/Documents"}
      credentials = {"username": "user", "password": "secret"}
    """

    __tablename__ = "user_integrations"

    id = Column(Integer, primary_key=True, index=True)

    # Stable owner identifier — matches FileRecord.owner_id / UserImapAccount.owner_id
    owner_id = Column(String, nullable=False, index=True)

    # "SOURCE" or "DESTINATION"  (see IntegrationDirection)
    direction = Column(String(20), nullable=False, index=True)

    # One of the IntegrationType constants (e.g. "IMAP", "S3", "DROPBOX")
    integration_type = Column(String(50), nullable=False, index=True)

    # Human-readable label chosen by the user (e.g. "Work Gmail", "S3 Archive")
    name = Column(String(255), nullable=False)

    # Non-sensitive connection configuration (JSON string)
    config = Column(Text, nullable=True)

    # Sensitive credentials — always stored encrypted via encrypt_value()
    credentials = Column(Text, nullable=True)

    # When False the integration is not polled / used by background tasks
    is_active = Column(Boolean, nullable=False, default=True)

    # Timestamp of the last successful use of this integration
    last_used_at = Column(DateTime(timezone=True), nullable=True)

    # Last error message if the most recent operation failed (NULL = last op succeeded)
    last_error = Column(Text, nullable=True)

    created_at = Column(DateTime(timezone=True), server_default=func.now())
    updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())


class ApiToken(Base):
    """Personal API token for programmatic access.

    Users can create multiple tokens, each with a human-readable name.
    Only the SHA-256 hash of the token is stored; the plaintext is shown
    exactly once at creation time.  A short prefix (first 8 chars) is
    persisted for easy identification in the UI.

    Usage tracking records the timestamp and IP address of the most
    recent request that used the token.
    """

    __tablename__ = "api_tokens"

    id = Column(Integer, primary_key=True, index=True)

    # Stable owner identifier — matches FileRecord.owner_id / UserIntegration.owner_id
    owner_id = Column(String, nullable=False, index=True)

    # Human-readable label chosen by the user (e.g. "CI Pipeline", "Webhook Upload")
    name = Column(String(255), nullable=False)

    # SHA-256 hex digest of the full token value
    token_hash = Column(String(64), nullable=False, unique=True, index=True)

    # First 12 characters of the token for display (e.g. "de_Ab3xY7kL…")
    token_prefix = Column(String(16), nullable=False)

    # Usage tracking
    last_used_at = Column(DateTime(timezone=True), nullable=True)
    last_used_ip = Column(String(45), nullable=True)  # IPv6 max length

    is_active = Column(Boolean, nullable=False, default=True)
    created_at = Column(DateTime(timezone=True), server_default=func.now())
    revoked_at = Column(DateTime(timezone=True), nullable=True)

    # Optional expiry: if set, the token is rejected after this timestamp.
    expires_at = Column(DateTime(timezone=True), nullable=True)


class SharedLink(Base):
    """Shareable, time-limited or view-limited document link.

    A ``SharedLink`` grants unauthenticated access to one ``FileRecord``
    via a cryptographically random URL token.  The link may optionally
    expire after a given datetime, be limited to a fixed number of views,
    and require a password.  Only a PBKDF2-HMAC-SHA256 hash of the
    password is stored.

    Owners can view and revoke their active links from the management UI.
    """

    __tablename__ = "shared_links"

    id = Column(Integer, primary_key=True, index=True)

    # Unique URL-safe token — forms the public /share/<token> URL.
    token = Column(String(64), nullable=False, unique=True, index=True)

    # File this link grants access to.
    file_id = Column(Integer, ForeignKey(_FILES_ID_FK), nullable=False, index=True)

    # Owner who created the link (matches FileRecord.owner_id).
    owner_id = Column(String, nullable=False, index=True)

    # Optional human-readable description chosen by the creator.
    label = Column(String(255), nullable=True)

    # Time-based expiration (NULL = never expires).
    expires_at = Column(DateTime(timezone=True), nullable=True)

    # View-count limit (NULL = unlimited).
    max_views = Column(Integer, nullable=True)

    # Cumulative view count (incremented on every successful access).
    view_count = Column(Integer, nullable=False, default=0)

    # Optional password protection — stores PBKDF2-HMAC-SHA256 hex digest.
    password_hash = Column(String(128), nullable=True)

    # Whether the link is still valid (set to False to revoke immediately).
    is_active = Column(Boolean, nullable=False, default=True)

    created_at = Column(DateTime(timezone=True), server_default=func.now())
    revoked_at = Column(DateTime(timezone=True), nullable=True)


class UserNotificationTarget(Base):
    """Per-user notification target (email or webhook channel)."""

    __tablename__ = "user_notification_targets"

    id = Column(Integer, primary_key=True, index=True)
    owner_id = Column(String, nullable=False, index=True)
    channel_type = Column(String(20), nullable=False)  # "email" or "webhook"
    name = Column(String(255), nullable=False)  # Human-readable label
    config = Column(Text, nullable=True)  # JSON: smtp config or webhook url
    is_active = Column(Boolean, nullable=False, default=True)
    created_at = Column(DateTime(timezone=True), server_default=func.now())
    updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())


class UserNotificationPreference(Base):
    """Mapping: which user events trigger which notification channel."""

    __tablename__ = "user_notification_preferences"

    id = Column(Integer, primary_key=True, index=True)
    owner_id = Column(String, nullable=False, index=True)
    event_type = Column(String(50), nullable=False)  # "document.processed", "document.failed"
    channel_type = Column(String(20), nullable=False)  # "in_app", "email", "webhook"
    target_id = Column(Integer, nullable=True)  # NULL = in_app, else UserNotificationTarget.id
    is_enabled = Column(Boolean, nullable=False, default=True)

    __table_args__ = (UniqueConstraint("owner_id", "event_type", "channel_type", "target_id"),)
    created_at = Column(DateTime(timezone=True), server_default=func.now())
    updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())


class InAppNotification(Base):
    """In-app notification record for the bell icon / inbox."""

    __tablename__ = "in_app_notifications"

    id = Column(Integer, primary_key=True, index=True)
    owner_id = Column(String, nullable=False, index=True)
    event_type = Column(String(50), nullable=False)  # "document.processed", "document.failed"
    title = Column(String(255), nullable=False)
    message = Column(Text, nullable=True)
    is_read = Column(Boolean, nullable=False, default=False, index=True)
    file_id = Column(Integer, nullable=True)  # Optional link to FileRecord
    created_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)


class ScheduledJob(Base):
    """
    Configuration record for an admin-managed scheduled batch processing job.

    Each row represents one recurring job entry.  The Celery Beat schedule is
    built from these rows at worker startup; changes take effect after the
    worker process is restarted.

    Schedule types
    --------------
    - ``"cron"``     – standard cron expression fields (minute/hour/…)
    - ``"interval"`` – fixed interval in seconds (e.g. 3600 for hourly)
    """

    __tablename__ = "scheduled_jobs"

    id = Column(Integer, primary_key=True, index=True)

    # Unique machine-readable key used as the Celery Beat schedule entry name.
    name = Column(String(100), unique=True, nullable=False, index=True)

    # Human-readable display name shown in the admin UI.
    display_name = Column(String(255), nullable=False)

    # Short description of what the job does.
    description = Column(Text, nullable=True)

    # Fully-qualified Celery task name, e.g. "app.tasks.batch_tasks.process_new_documents".
    task_name = Column(String(255), nullable=False)

    # Whether the job is active.  Inactive jobs are excluded from the beat schedule.
    enabled = Column(Boolean, nullable=False, default=True)

    # Schedule type: "cron" or "interval".
    schedule_type = Column(String(20), nullable=False, default="cron")

    # --- Cron fields (used when schedule_type == "cron") ---
    cron_minute = Column(String(50), nullable=False, default="0")
    cron_hour = Column(String(50), nullable=False, default="*")
    cron_day_of_week = Column(String(50), nullable=False, default="*")
    cron_day_of_month = Column(String(50), nullable=False, default="*")
    cron_month_of_year = Column(String(50), nullable=False, default="*")

    # --- Interval field (used when schedule_type == "interval") ---
    # Interval in seconds; e.g. 3600 = hourly, 86400 = daily.
    interval_seconds = Column(Integer, nullable=True)

    # Timestamps populated by the worker after each execution.
    last_run_at = Column(DateTime(timezone=True), nullable=True)
    last_run_status = Column(String(20), nullable=True)  # "success", "failed", "running"
    last_run_detail = Column(Text, nullable=True)  # Brief result summary or error

    created_at = Column(DateTime(timezone=True), server_default=func.now())
    updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())


class ClassificationRuleModel(Base):
    """Custom document classification rule.

    Rules are evaluated during the ``classify`` pipeline step to assign a
    category to a document.  System-wide rules have ``owner_id IS NULL``;
    user-specific rules belong to a single owner.
    """

    __tablename__ = "classification_rules"

    id = Column(Integer, primary_key=True, index=True)

    # NULL = system-wide rule visible to all users.
    owner_id = Column(String, nullable=True, index=True)

    # Human-readable rule name (unique per owner).
    name = Column(String(255), nullable=False)

    # Target category (e.g. "invoice", "contract", "receipt").
    category = Column(String(100), nullable=False, index=True)

    # Rule type: "filename_pattern", "content_keyword", or "metadata_match".
    rule_type = Column(String(50), nullable=False)

    # The matching pattern:
    #   - filename_pattern: a regex
    #   - content_keyword: pipe-separated keywords
    #   - metadata_match: "field=value"
    pattern = Column(String(1000), nullable=False)

    # Higher priority rules are evaluated first (default 0).
    priority = Column(Integer, nullable=False, default=0)

    # Whether pattern matching is case-sensitive.
    case_sensitive = Column(Boolean, nullable=False, default=False)

    # Disabled rules are skipped during classification.
    enabled = Column(Boolean, nullable=False, default=True)

    created_at = Column(DateTime(timezone=True), server_default=func.now())
    updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())

    __table_args__ = (UniqueConstraint("owner_id", "name", name="uq_classification_rules_owner_name"),)


class MobileDevice(Base):
    """Registered mobile device for push notifications.

    Stores the push token (Expo push token, FCM token, or APNs token) for a
    specific user device so that document-processing events can be forwarded
    as push notifications to the native mobile app.
    """

    __tablename__ = "mobile_devices"

    id = Column(Integer, primary_key=True, index=True)

    # User that owns this device registration.
    owner_id = Column(String, nullable=False, index=True)

    # Human-readable name the user gave this device (e.g. "John's iPhone").
    device_name = Column(String(255), nullable=True)

    # Platform: "ios", "android", or "web".
    platform = Column(String(20), nullable=False, default="ios")

    # Expo push token (ExponentPushToken[…]) or raw FCM/APNs token.
    push_token = Column(String(512), nullable=False)

    # Whether push notifications are enabled for this device.
    is_active = Column(Boolean, nullable=False, default=True)

    # Timestamps.
    created_at = Column(DateTime(timezone=True), server_default=func.now())
    last_seen_at = Column(DateTime(timezone=True), nullable=True)

    __table_args__ = (UniqueConstraint("owner_id", "push_token", name="uq_mobile_device_owner_token"),)


class UserSession(Base):
    """Server-side session tracking for invalidation and device management.

    Each row represents an active browser or app session.  The ``session_token``
    is stored in the user's cookie and validated on every authenticated request.
    Revoking a row (``is_revoked=True``) immediately terminates that session
    on the next request.
    """

    __tablename__ = "user_sessions"

    id = Column(Integer, primary_key=True, index=True)

    # Cryptographically random token stored in the session cookie.
    session_token = Column(String(128), unique=True, nullable=False, index=True)

    # Stable owner identifier — matches FileRecord.owner_id.
    user_id = Column(String, nullable=False, index=True)

    # Client metadata for display in the session management UI.
    ip_address = Column(String(45), nullable=True)
    user_agent = Column(String(512), nullable=True)
    device_info = Column(String(255), nullable=True)

    is_revoked = Column(Boolean, nullable=False, default=False)
    created_at = Column(DateTime(timezone=True), server_default=func.now())
    last_active_at = Column(DateTime(timezone=True), server_default=func.now())
    expires_at = Column(DateTime(timezone=True), nullable=False)
    revoked_at = Column(DateTime(timezone=True), nullable=True)


class QRLoginChallenge(Base):
    """Time-limited QR code login challenge for mobile app authentication.

    A logged-in web user generates a challenge that produces a QR code.  The
    mobile app scans the QR code and calls the claim endpoint with the
    ``challenge_token``.  The server verifies the challenge is still valid,
    unclaimed, and unexpired, then issues an API token for the mobile app.

    Security properties:
    * Time-bound (default 2 minutes).
    * Single-use (``is_claimed`` prevents replay).
    * Cryptographically random 64-byte token.
    * Bound to the creating user — only that user's mobile device receives a
      token.
    """

    __tablename__ = "qr_login_challenges"

    id = Column(Integer, primary_key=True, index=True)

    # Cryptographically random token encoded in the QR code.
    challenge_token = Column(String(128), unique=True, nullable=False, index=True)

    # The user who created this challenge (from the web session).
    user_id = Column(String, nullable=False, index=True)

    # Whether the challenge has been successfully claimed by a mobile app.
    is_claimed = Column(Boolean, nullable=False, default=False)

    # Whether the challenge has been explicitly cancelled or expired.
    is_cancelled = Column(Boolean, nullable=False, default=False)

    # IP address of the web client that created the challenge.
    created_by_ip = Column(String(45), nullable=True)

    # IP address of the mobile client that claimed the challenge.
    claimed_by_ip = Column(String(45), nullable=True)

    # Device name provided by the mobile app when claiming.
    device_name = Column(String(255), nullable=True)

    # The API token ID that was issued to the mobile app (for audit trail).
    issued_token_id = Column(Integer, nullable=True)

    created_at = Column(DateTime(timezone=True), server_default=func.now())
    expires_at = Column(DateTime(timezone=True), nullable=False)
    claimed_at = Column(DateTime(timezone=True), nullable=True)


class ComplianceTemplate(Base):
    """Pre-built compliance configuration templates (GDPR, HIPAA, SOC2).

    Each row represents an applied compliance template.  The ``settings_json``
    column stores the concrete setting key/value pairs that were written when
    the template was applied.  ``status`` tracks the current compliance posture.
    """

    __tablename__ = "compliance_templates"

    id = Column(Integer, primary_key=True, index=True)
    name = Column(String(50), unique=True, nullable=False, index=True)  # GDPR, HIPAA, SOC2
    display_name = Column(String(100), nullable=False)
    description = Column(Text, nullable=True)
    settings_json = Column(Text, nullable=False, default="{}")  # JSON of applied settings
    enabled = Column(Boolean, nullable=False, default=False)
    status = Column(String(20), nullable=False, default="not_applied")  # not_applied, compliant, partial, non_compliant
    applied_at = Column(DateTime(timezone=True), nullable=True)
    applied_by = Column(String(255), nullable=True)
    created_at = Column(DateTime(timezone=True), server_default=func.now())
    updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())


class PipelineRoutingRule(Base):
    """Conditional routing rule that assigns documents to pipelines.

    Rules are evaluated in ascending ``position`` order for a given owner.
    The first rule whose condition matches the document properties wins and
    the document is routed to ``target_pipeline_id``.  If no rule matches,
    the caller falls back to the owner's (or system) default pipeline.

    Supported fields:
        file_type, document_type, category, filename, size, and any key
        inside the AI-extracted metadata JSON (prefixed ``metadata.``).

    Supported operators:
        equals, not_equals, contains, not_contains, regex, gt, lt, gte, lte.
    """

    __tablename__ = _ROUTING_RULES_TABLE

    id = Column(Integer, primary_key=True, index=True)

    # Owner of this rule.  NULL = system-wide rule (admin only).
    owner_id = Column(String, nullable=True, index=True)

    # Human-readable label for the rule.
    name = Column(String(255), nullable=False)

    # Evaluation order (lower = earlier).  First matching rule wins.
    position = Column(Integer, nullable=False, default=0)

    # The document property to evaluate.
    # Built-in: file_type, document_type, category, filename, size.
    # For AI metadata fields, use the "metadata.<key>" prefix.
    field = Column(String(255), nullable=False)

    # Comparison operator.
    operator = Column(String(50), nullable=False)

    # Value to compare against (always stored as text; cast as needed).
    value = Column(String(1024), nullable=False)

    # Target pipeline when the condition matches.
    target_pipeline_id = Column(Integer, ForeignKey(_PIPELINES_ID_FK), nullable=False, index=True)

    # Soft-disable without deleting.
    is_active = Column(Boolean, nullable=False, default=True)

    created_at = Column(DateTime(timezone=True), server_default=func.now())
    updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
