-- Consolidated Bookhoard Database Schema
-- This file contains the complete current schema for the Bookhoard media library management system

-- Create extensions for full-text search and fuzzy matching
CREATE EXTENSION IF NOT EXISTS pg_trgm;

-- Create library types table
CREATE TABLE IF NOT EXISTS library_types (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name VARCHAR(50) UNIQUE NOT NULL,
    description TEXT,
    allowed_extensions TEXT[] NOT NULL, -- Array of allowed file extensions for this type
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

-- Insert default library types
INSERT INTO library_types (name, description, allowed_extensions) VALUES
('ebooks', 'Ebook files including EPUB, PDF, MOBI, etc.', ARRAY['.epub', '.pdf', '.mobi', '.azw', '.azw3', '.txt', '.rtf', '.doc', '.docx', '.lit', '.fb2', '.pdb']),
('comics', 'Comic book archives and image formats', ARRAY['.cbz', '.cbr', '.cb7', '.cbt', '.epub', '.pdf']),
('manga', 'Manga files including archives and image folders', ARRAY['.cbz', '.cbr', '.epub', '.pdf', '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.avif', '.tiff', '.tif'])
ON CONFLICT (name) DO NOTHING;

-- Create users table
CREATE TABLE IF NOT EXISTS users (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    email VARCHAR(255) UNIQUE NOT NULL,
    username VARCHAR(255) UNIQUE NOT NULL,
    password_hash VARCHAR(255) NOT NULL,
    first_name VARCHAR(255),
    last_name VARCHAR(255),
    role VARCHAR(20) NOT NULL DEFAULT 'user' CHECK (role IN ('admin', 'user')),
    theme VARCHAR(50) DEFAULT 'tokyo-night',
    max_devices INTEGER DEFAULT 10,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    timezone VARCHAR(50) DEFAULT 'UTC',
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

-- Create system_settings table
CREATE TABLE IF NOT EXISTS system_settings (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    setting_key VARCHAR(100) UNIQUE NOT NULL,
    setting_value TEXT NOT NULL,
    description TEXT,
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

-- Extend system_settings with typed metadata so it can back the admin UI's
-- configurable tunables. All columns are nullable for backward compatibility
-- with the original three rows and any pre-existing data.
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS setting_type VARCHAR(20);
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS min_value TEXT;
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS max_value TEXT;
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS requires_restart BOOLEAN DEFAULT FALSE;
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS category VARCHAR(40);

-- Insert default system settings (original scan/timezone rows + tunables).
-- Values match the previous hardcoded literals, so behavior is unchanged on upgrade.
-- ON CONFLICT DO NOTHING preserves any admin-modified values.
INSERT INTO system_settings (setting_key, setting_value, description, setting_type, min_value, max_value, requires_restart, category) VALUES
('scan_poll_interval_seconds', '60', 'How often to scan all libraries (seconds)', 'int', '1', '3600', FALSE, 'scanner'),
('auto_scan_enabled', 'true', 'Whether auto-scanning is enabled system-wide', 'bool', NULL, NULL, FALSE, 'scanner'),
('default_timezone', 'UTC', 'System default timezone', 'string', NULL, NULL, FALSE, 'general'),
-- security / auth (live)
('session_duration_seconds', '604800', 'How long a login session stays valid', 'int', '300', '31536000', FALSE, 'security'),
('password_min_length', '8', 'Minimum password length', 'int', '1', '128', FALSE, 'security'),
('password_require_upper', 'true', 'Require at least one uppercase letter (A-Z)', 'bool', NULL, NULL, FALSE, 'security'),
('password_require_lower', 'true', 'Require at least one lowercase letter (a-z)', 'bool', NULL, NULL, FALSE, 'security'),
('password_require_number', 'true', 'Require at least one number (0-9)', 'bool', NULL, NULL, FALSE, 'security'),
('password_require_special', 'true', 'Require at least one special character', 'bool', NULL, NULL, FALSE, 'security'),
-- security / auth (restart required)
('auth_rate_limit_per_min', '10', 'Global auth API rate limit (requests per minute)', 'int', '1', '10000', TRUE, 'security'),
('login_max_attempts', '5', 'Failed login attempts before lockout', 'int', '1', '100', TRUE, 'security'),
('login_lockout_minutes', '15', 'Lockout duration after too many failed logins', 'int', '1', '10080', TRUE, 'security'),
-- api (live)
('opds_default_page_size', '50', 'Default OPDS page size', 'int', '1', '500', FALSE, 'api'),
('opds_max_page_size', '200', 'Maximum OPDS page size', 'int', '1', '1000', FALSE, 'api'),
('device_rate_sync_per_min', '60', 'Device sync requests per minute', 'int', '1', '10000', FALSE, 'api'),
('device_rate_progress_per_min', '120', 'Device progress requests per minute', 'int', '1', '10000', FALSE, 'api'),
('device_rate_metadata_per_min', '30', 'Device metadata requests per minute', 'int', '1', '10000', FALSE, 'api'),
-- sync / performance (live)
('annotation_tombstone_ttl_days', '30', 'How long deleted annotations are kept before purge', 'int', '1', '3650', FALSE, 'sync'),
('conversion_cache_ttl_hours', '24', 'How long converted (kepub) files are cached', 'int', '1', '720', FALSE, 'performance'),
-- sync / performance (restart required)
('sync_queue_interval_seconds', '5', 'How often the sync queue flushes', 'int', '1', '3600', TRUE, 'sync'),
('sync_queue_batch_size', '50', 'Maximum items processed per sync queue flush', 'int', '1', '10000', TRUE, 'sync'),
('worker_pool_size', '3', 'Number of background worker goroutines', 'int', '1', '100', TRUE, 'performance'),
('worker_queue_cap', '100', 'Background worker job queue capacity', 'int', '1', '10000', TRUE, 'performance')
ON CONFLICT (setting_key) DO NOTHING;

-- Create refresh_tokens table
CREATE TABLE IF NOT EXISTS refresh_tokens (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    token UUID UNIQUE NOT NULL DEFAULT gen_random_uuid(),
    expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    revoked_at TIMESTAMP WITH TIME ZONE
);

-- Create libraries table
CREATE TABLE IF NOT EXISTS libraries (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name VARCHAR(255) NOT NULL,
    description TEXT,
    library_type_id UUID NOT NULL REFERENCES library_types(id) ON DELETE RESTRICT,
    created_by_admin_id UUID REFERENCES users(id) ON DELETE SET NULL,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

-- Create library_folders table for multiple folders per library
CREATE TABLE IF NOT EXISTS library_folders (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    library_id UUID NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
    folder_path VARCHAR(500) NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    UNIQUE(library_id, folder_path)
);

-- Create library_visibility table for user-specific library visibility
CREATE TABLE IF NOT EXISTS library_visibility (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    library_id UUID NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
    is_visible BOOLEAN NOT NULL DEFAULT true,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    UNIQUE(user_id, library_id)
);

-- Create media_items table
CREATE TABLE IF NOT EXISTS media_items (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    library_id UUID NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
    title VARCHAR(255) NOT NULL,
    author VARCHAR(255),
    isbn VARCHAR(13), -- Still relevant for ebooks
    description TEXT,
    file_path VARCHAR(500) NOT NULL,
    file_size BIGINT,
    mime_type VARCHAR(100),
    cover_image_path VARCHAR(500),
    series VARCHAR(255),
    series_number INTEGER,
    tags TEXT[],
    asin VARCHAR(20), -- Still relevant for ebooks
    date_published DATE,
    publisher VARCHAR(255),
    contributors TEXT[],
    -- Enhanced fields for better metadata and functionality
    language VARCHAR(10) DEFAULT 'en', -- Language code (ISO 639-1)
    edition VARCHAR(100), -- Edition information
    page_count INTEGER, -- Total page count
    genre VARCHAR(100), -- Genre classification
    copyright_year INTEGER, -- Copyright/publication year
    goodreads_id VARCHAR(50), -- Goodreads identifier
    openlibrary_id VARCHAR(50), -- Open Library identifier
    google_books_id VARCHAR(100), -- Google Books identifier
    added_by_admin_id UUID REFERENCES users(id) ON DELETE SET NULL,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    imported_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    -- Universal Sync Format Detection
    format_group VARCHAR(20) NOT NULL DEFAULT 'reflowable',
    format_mimetype VARCHAR(100),
    is_reflowable BOOLEAN DEFAULT TRUE,
    has_fixed_layout BOOLEAN DEFAULT FALSE,
    total_characters BIGINT,
    chapter_count INTEGER,
    -- Kobo-Specific Metadata
    entitlement_id VARCHAR(255) UNIQUE,
    revision_number INTEGER DEFAULT 1,
    kobo_content_id VARCHAR(255),
    kobo_metadata JSONB,
    -- Manga and comic reading direction support
    -- Stores raw Manga field from ComicInfo.xml
    manga_type VARCHAR(30) DEFAULT 'unknown',
    reading_direction VARCHAR(20) DEFAULT 'auto',
    CHECK (manga_type IN ('unknown', 'no', 'yes', 'yes_and_right_to_left')),
    CHECK (reading_direction IN ('auto', 'ltr', 'rtl', 'vertical')),

    -- Universal series information (applies to ALL formats: ebooks, audiobooks, comics)
    series_count INTEGER,             -- Total items in series (from ComicInfo Count field, or book series count)
    volume INTEGER,                   -- Volume/omnibus number for collected editions

    -- Universal publisher and classification (applies to ALL formats)
    imprint VARCHAR(255),             -- Publisher imprint (e.g., Vertigo, HarperCollinsEpic)
    age_rating VARCHAR(20),           -- Age rating: Everyone, Teen, Mature, Adult (applies to all formats)
    web_url VARCHAR(500),             -- URL to info page (Goodreads, ComicVine, MangaUpdates, Audible, etc.)

    -- Comic-specific fields
    story_arc VARCHAR(255),           -- Story arc name (e.g., "The Dark Phoenix Saga", "Civil War")
    is_black_and_white BOOLEAN,       -- Black and white flag (mostly comics, some illustrated books)

    -- Additional metadata (applies to all formats)
    metadata_notes TEXT,              -- Notes from metadata files (ComicInfo.xml, EPUB, PDF) - distinct from user notes
    community_rating DOUBLE PRECISION,    -- Pre-existing community rating from metadata (0.0-10.0) - distinct from user ratings

    -- Alternate series information (JSONB for flexible schema - comic-specific)
    alternate_info JSONB,             -- Stores AlternateSeries, AlternateNumber, AlternateCount
    -- Example: {"alternate_series": "Ultimate X-Men", "alternate_number": 1, "alternate_count": 12}

    -- Scan and publication metadata (comic-specific)
    scan_information TEXT,            -- Scan information (scanner group, resolution, etc.)

    -- Summary (distinct from description - may merge with Calibre description)
    summary TEXT,                     -- Summary from ComicInfo.xml (may be merged with description from Calibre)

    -- Chapter metadata for reader navigation and progress tracking
        -- Caches detected chapter structure to avoid re-parsing files
        -- Populated by ReaderService.DetectChapters() on first read
        -- JSONB structure:
        -- {
        --   "chapters": [
        --     {"id": "chap1", "title": "Chapter 1", "start_page": 1, "page_count": 20, "level": 1},
        --     {"id": "chap2", "title": "Chapter 2", "start_page": 21, "page_count": 25, "level": 1}
        --   ],
        --   "detected_at": "2024-01-15T10:30:00Z",
        --   "detection_method": "epub-toc" | "pdf-outline" | "page-breaks"
        -- }
    chapter_metadata JSONB,
    -- Need library_type_name for checks
    library_type_name VARCHAR(50)
);

-- Add GIN indexes for fast array searches
CREATE INDEX IF NOT EXISTS idx_media_items_tags_gin ON media_items USING GIN (tags);
CREATE INDEX IF NOT EXISTS idx_media_items_contributors_gin ON media_items USING GIN (contributors);

-- Add search fields for case-insensitive, punctuation-free searching
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS tags_search TEXT[];
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS contributors_search TEXT[];

-- Create GIN indexes for fast search field searches
CREATE INDEX IF NOT EXISTS idx_media_items_tags_search ON media_items USING GIN (tags_search);
CREATE INDEX IF NOT EXISTS idx_media_items_contributors_search ON media_items USING GIN (contributors_search);

-- Drop existing trigger if exists
DROP TRIGGER IF EXISTS set_library_type_name_on_insert ON media_items;
-- Drop existing function if exists
DROP FUNCTION IF EXISTS set_library_type_name();

-- Add trigger to populate library_type_name on insert
CREATE OR REPLACE FUNCTION set_library_type_name() RETURNS TRIGGER AS $$
BEGIN
  SELECT lt.name INTO NEW.library_type_name
  FROM libraries l
  JOIN library_types lt ON l.library_type_id = lt.id
  WHERE l.id = NEW.library_id;
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER set_library_type_name_on_insert
  BEFORE INSERT ON media_items
  FOR EACH ROW
  EXECUTE FUNCTION set_library_type_name();

-- Add GIN indexes for pg_trgm fuzzy search performance
CREATE INDEX IF NOT EXISTS idx_media_items_author_trgm
  ON media_items USING GIN (author gin_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_media_items_title_trgm
  ON media_items USING GIN (title gin_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_media_items_series_trgm
  ON media_items USING GIN (series gin_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_media_items_genre_trgm
  ON media_items USING GIN (genre gin_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_media_items_language_trgm
  ON media_items USING GIN (language gin_trgm_ops);

-- Create reading_progress table
CREATE TABLE IF NOT EXISTS reading_progress (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    current_page INTEGER DEFAULT 0,
    total_pages INTEGER,
    last_read_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    UNIQUE(media_item_id, user_id),
    -- Universal Progress Tracking
    percentage FLOAT CHECK (percentage >= 0 AND percentage <= 1),
    character_offset BIGINT,
    epubcfi TEXT,
    context_text TEXT,
    chapter INTEGER,
    chapter_progress FLOAT CHECK (chapter_progress >= 0 AND chapter_progress <= 1),
    viewport_x FLOAT DEFAULT 0,
    viewport_y FLOAT DEFAULT 0,
    zoom_level FLOAT DEFAULT 1.0,
    scroll_position_x FLOAT DEFAULT 0,
    scroll_position_y FLOAT DEFAULT 0,
    panel_number INTEGER,
    reading_mode VARCHAR(20),
    -- Device Sync Metadata
    last_sync_device VARCHAR(50),
    last_sync_source VARCHAR(20),
    last_sync_timestamp TIMESTAMP WITH TIME ZONE,
    conflict_detected BOOLEAN DEFAULT FALSE,
    conflict_resolved BOOLEAN DEFAULT TRUE
);

-- Create media_ratings table
CREATE TABLE IF NOT EXISTS media_ratings (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 10), -- 10-point scale for half-star precision
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    UNIQUE(media_item_id, user_id)
);

-- Create media_notes table for user notes on media items
CREATE TABLE IF NOT EXISTS media_notes (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    content TEXT NOT NULL,
    position VARCHAR(100), -- optional position (page:offset or CFI) for standalone notes
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    -- Location Enhancements
    percentage_location FLOAT,
    character_start INTEGER,
    character_end INTEGER,
    epubcfi_location TEXT,
    chapter_reference INTEGER,
    paragraph_reference INTEGER,
    device_sync_data JSONB
);

-- Create media_highlights table for user highlights on media items
CREATE TABLE IF NOT EXISTS media_highlights (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    selection_text TEXT NOT NULL,
    start_position VARCHAR(100), -- position (page:offset or CFI) where highlight starts
    end_position VARCHAR(100),   -- position (page:offset or CFI) where highlight ends
    color VARCHAR(7) DEFAULT '#ffff00', -- hex color code for highlight
    note_id UUID REFERENCES media_notes(id) ON DELETE SET NULL, -- optional associated note
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    -- Location Enhancements
    percentage_start FLOAT,
    percentage_end FLOAT,
    character_start INTEGER,
    character_end INTEGER,
    epubcfi_start TEXT,
    epubcfi_end TEXT,
    chapter_reference INTEGER,
    paragraph_start INTEGER,
    paragraph_end INTEGER,
    panel_number INTEGER,
    device_sync_data JSONB
);

-- ============================================
-- DEVICE REGISTRY
-- ============================================
CREATE TABLE IF NOT EXISTS devices (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    device_name VARCHAR(100) NOT NULL,
    device_type VARCHAR(20) NOT NULL,
    device_identifier VARCHAR(255) UNIQUE NOT NULL,
    auth_token VARCHAR(500) UNIQUE NOT NULL,
    last_sync TIMESTAMP WITH TIME ZONE,
    last_seen TIMESTAMP WITH TIME ZONE,
    sync_enabled BOOLEAN DEFAULT TRUE,
    auto_sync BOOLEAN DEFAULT TRUE,
    sync_frequency_minutes INTEGER DEFAULT 5,
    device_metadata JSONB,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

-- ============================================
-- SYNC QUEUE FOR OFFLINE SUPPORT
-- ============================================
CREATE TABLE IF NOT EXISTS sync_queue (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
    media_item_id UUID REFERENCES media_items(id) ON DELETE CASCADE,
    sync_type VARCHAR(20) NOT NULL,
    sync_data JSONB NOT NULL,
    priority INTEGER DEFAULT 5,
    attempts INTEGER DEFAULT 0,
    max_attempts INTEGER DEFAULT 3,
    status VARCHAR(20) DEFAULT 'pending',
    error_message TEXT,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    processed_at TIMESTAMP WITH TIME ZONE
);

-- ============================================
-- CONFLICT RESOLUTION
-- ============================================
CREATE TABLE IF NOT EXISTS sync_conflicts (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    conflict_type VARCHAR(20) NOT NULL,
    conflict_data JSONB NOT NULL,
    resolution_status VARCHAR(20) DEFAULT 'unresolved',
    resolution_data JSONB,
    resolved_by UUID REFERENCES users(id),
    resolved_at TIMESTAMP WITH TIME ZONE,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

-- ============================================
-- KOBO SHELF MANAGEMENT
-- ============================================
CREATE TABLE IF NOT EXISTS kobo_shelves (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
    media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
    shelf_name VARCHAR(100) DEFAULT 'Default',
    shelf_position INTEGER DEFAULT 0,
    added_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    last_synced_at TIMESTAMP WITH TIME ZONE,
    UNIQUE(device_id, media_item_id)
);

CREATE INDEX IF NOT EXISTS idx_kobo_shelves_device_id ON kobo_shelves(device_id);
CREATE INDEX IF NOT EXISTS idx_kobo_shelves_media_item_id ON kobo_shelves(media_item_id);
CREATE INDEX IF NOT EXISTS idx_kobo_shelves_shelf_name ON kobo_shelves(shelf_name);

-- Table to track items that can't be processed in their library
CREATE TABLE IF NOT EXISTS processing_issues (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
    library_id UUID NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
    issue_type VARCHAR(50) NOT NULL,
    issue_description TEXT NOT NULL,
    severity VARCHAR(20) NOT NULL DEFAULT 'warning',
    resolved BOOLEAN DEFAULT FALSE,
    resolved_at TIMESTAMP WITH TIME ZONE,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    UNIQUE(media_item_id, issue_type)
);

-- Indexes for querying problem items
CREATE INDEX IF NOT EXISTS idx_processing_issues_library 
  ON processing_issues(library_id, resolved);
CREATE INDEX IF NOT EXISTS idx_processing_issues_severity 
  ON processing_issues(severity, resolved);

-- Add comment for documentation
COMMENT ON TABLE processing_issues IS 'Tracks media items that cannot be properly processed in their assigned library due to format mismatches or other issues';


-- ============================================
-- KOBO ENTITLEMENTS TRACKING
-- ============================================
CREATE TABLE IF NOT EXISTS kobo_entitlements (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
    media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
    entitlement_id VARCHAR(255) NOT NULL,
    content_id VARCHAR(255) NOT NULL,
    revision_number INTEGER DEFAULT 1,
    purchase_date TIMESTAMP WITH TIME ZONE,
    accession_date TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    book_status VARCHAR(20) DEFAULT 'installed',
    sync_status VARCHAR(20) DEFAULT 'synced',
    kobo_metadata JSONB,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    UNIQUE(device_id, entitlement_id)
);

CREATE INDEX IF NOT EXISTS idx_kobo_entitlements_device_id ON kobo_entitlements(device_id);
CREATE INDEX IF NOT EXISTS idx_kobo_entitlements_media_item_id ON kobo_entitlements(media_item_id);
CREATE INDEX IF NOT EXISTS idx_kobo_entitlements_entitlement_id ON kobo_entitlements(entitlement_id);
CREATE INDEX IF NOT EXISTS idx_kobo_entitlements_content_id ON kobo_entitlements(content_id);

-- ============================================
-- READING HISTORY
-- ============================================
CREATE TABLE IF NOT EXISTS reading_history (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
    device_id UUID REFERENCES devices(id),
    progress_percentage FLOAT,
    reading_session_start TIMESTAMP WITH TIME ZONE,
    reading_session_end TIMESTAMP WITH TIME ZONE,
    pages_read INTEGER,
    time_spent_seconds INTEGER,
    device_metadata JSONB,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);


-- Create indexes for better query performance
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
CREATE INDEX IF NOT EXISTS idx_users_timezone ON users(timezone);
CREATE INDEX IF NOT EXISTS idx_library_types_name ON library_types(name);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_token ON refresh_tokens(token);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens(user_id);

-- Library indexes
CREATE INDEX IF NOT EXISTS idx_libraries_name ON libraries(name);
CREATE INDEX IF NOT EXISTS idx_libraries_library_type_id ON libraries(library_type_id);
CREATE INDEX IF NOT EXISTS idx_libraries_created_by_admin_id ON libraries(created_by_admin_id);
CREATE INDEX IF NOT EXISTS idx_library_folders_library_id ON library_folders(library_id);
CREATE INDEX IF NOT EXISTS idx_library_visibility_user_id ON library_visibility(user_id);
CREATE INDEX IF NOT EXISTS idx_library_visibility_library_id ON library_visibility(library_id);

-- Media items indexes
CREATE INDEX IF NOT EXISTS idx_media_items_title ON media_items(title);
CREATE INDEX IF NOT EXISTS idx_media_items_author ON media_items(author);
CREATE INDEX IF NOT EXISTS idx_media_items_library_id ON media_items(library_id);
CREATE INDEX IF NOT EXISTS idx_media_items_added_by_admin_id ON media_items(added_by_admin_id);
CREATE INDEX IF NOT EXISTS idx_media_items_genre ON media_items(genre);
CREATE INDEX IF NOT EXISTS idx_media_items_language ON media_items(language);
CREATE INDEX IF NOT EXISTS idx_media_items_copyright_year ON media_items(copyright_year);
CREATE INDEX IF NOT EXISTS idx_media_items_page_count ON media_items(page_count);
CREATE INDEX IF NOT EXISTS idx_media_items_series ON media_items(series);

-- Index for filtering by reading direction (for manga/comic libraries)
CREATE INDEX IF NOT EXISTS idx_media_items_reading_direction
ON media_items(reading_direction)
WHERE reading_direction IS NOT NULL;

-- Index for filtering by story arc (comic-specific)
CREATE INDEX IF NOT EXISTS idx_media_items_story_arc
ON media_items(story_arc)
WHERE story_arc IS NOT NULL;

-- Index for filtering by imprint (universal - all formats)
CREATE INDEX IF NOT EXISTS idx_media_items_imprint
ON media_items(imprint)
WHERE imprint IS NOT NULL;

-- Index for filtering by age rating (universal - all formats)
CREATE INDEX IF NOT EXISTS idx_media_items_age_rating
ON media_items(age_rating)
WHERE age_rating IS NOT NULL;

-- Index for filtering by manga type (comic-specific)
CREATE INDEX IF NOT EXISTS idx_media_items_manga_type
ON media_items(manga_type)
WHERE manga_type IS NOT NULL;

-- Index for filtering by series count (universal - all formats)
CREATE INDEX IF NOT EXISTS idx_media_items_series_count
ON media_items(series_count)
WHERE series_count IS NOT NULL;

-- Index for filtering by volume (universal - all formats)
CREATE INDEX IF NOT EXISTS idx_media_items_volume
ON media_items(volume)
WHERE volume IS NOT NULL;

-- GIN index for alternate_info JSONB queries (comic-specific)
CREATE INDEX IF NOT EXISTS idx_media_items_alternate_info_gin
ON media_items USING GIN (alternate_info)
WHERE alternate_info IS NOT NULL;

-- Progress and ratings indexes
CREATE INDEX IF NOT EXISTS idx_reading_progress_media_item_id ON reading_progress(media_item_id);
CREATE INDEX IF NOT EXISTS idx_reading_progress_user_id ON reading_progress(user_id);
CREATE INDEX IF NOT EXISTS idx_media_ratings_media_item_id ON media_ratings(media_item_id);
CREATE INDEX IF NOT EXISTS idx_media_ratings_user_id ON media_ratings(user_id);

-- Notes and highlights indexes
CREATE INDEX IF NOT EXISTS idx_media_notes_media_item_id ON media_notes(media_item_id);
CREATE INDEX IF NOT EXISTS idx_media_notes_user_id ON media_notes(user_id);
CREATE INDEX IF NOT EXISTS idx_media_highlights_media_item_id ON media_highlights(media_item_id);
CREATE INDEX IF NOT EXISTS idx_media_highlights_user_id ON media_highlights(user_id);
CREATE INDEX IF NOT EXISTS idx_media_highlights_note_id ON media_highlights(note_id);

-- ============================================
-- NEW TABLE INDEXES
-- ============================================

-- Device registry indexes
CREATE INDEX IF NOT EXISTS idx_devices_user_id ON devices(user_id);
CREATE INDEX IF NOT EXISTS idx_devices_device_type ON devices(device_type);
CREATE INDEX IF NOT EXISTS idx_devices_device_identifier ON devices(device_identifier);

-- Sync queue indexes
CREATE INDEX IF NOT EXISTS idx_sync_queue_device_id ON sync_queue(device_id);
CREATE INDEX IF NOT EXISTS idx_sync_queue_status ON sync_queue(status);
CREATE INDEX IF NOT EXISTS idx_sync_queue_priority ON sync_queue(priority);

-- Sync conflicts indexes
CREATE INDEX IF NOT EXISTS idx_sync_conflicts_media_item_id ON sync_conflicts(media_item_id);
CREATE INDEX IF NOT EXISTS idx_sync_conflicts_user_id ON sync_conflicts(user_id);
CREATE INDEX IF NOT EXISTS idx_sync_conflicts_status ON sync_conflicts(resolution_status);

-- Composite indexes for sync queue performance
CREATE INDEX IF NOT EXISTS idx_sync_queue_device_status_priority ON sync_queue(device_id, status, priority ASC, created_at ASC);
CREATE INDEX IF NOT EXISTS idx_sync_queue_status_priority_created ON sync_queue(status, priority ASC, created_at ASC);
CREATE INDEX IF NOT EXISTS idx_sync_queue_device_created_at ON sync_queue(device_id, created_at DESC);

-- Composite indexes for reading progress performance
CREATE INDEX IF NOT EXISTS idx_reading_progress_user_last_sync ON reading_progress(user_id, last_sync_timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_reading_progress_media_last_sync ON reading_progress(media_item_id, last_sync_timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_reading_progress_user_sync_source ON reading_progress(user_id, last_sync_source, last_sync_timestamp DESC);

-- Composite indexes for devices
CREATE INDEX IF NOT EXISTS idx_devices_user_sync_enabled ON devices(user_id, sync_enabled, auto_sync);
CREATE INDEX IF NOT EXISTS idx_devices_user_type ON devices(user_id, device_type);
CREATE INDEX IF NOT EXISTS idx_devices_last_seen ON devices(last_seen DESC) WHERE sync_enabled = true;

-- Composite indexes for annotations
CREATE INDEX IF NOT EXISTS idx_media_notes_user_media ON media_notes(user_id, media_item_id);
CREATE INDEX IF NOT EXISTS idx_media_highlights_user_media ON media_highlights(user_id, media_item_id);

-- Reading history indexes
CREATE INDEX IF NOT EXISTS idx_reading_history_user_id ON reading_history(user_id);
CREATE INDEX IF NOT EXISTS idx_reading_history_media_item_id ON reading_history(media_item_id);
CREATE INDEX IF NOT EXISTS idx_reading_history_created_at ON reading_history(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_reading_history_user_device_created ON reading_history(user_id, device_id, created_at DESC);

-- ============================================
-- TRIGGER FUNCTIONS
-- ============================================

-- Update updated_at timestamp for devices
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
  NEW.updated_at = NOW();
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

-- Trigger for devices table
DROP TRIGGER IF EXISTS update_devices_updated_at ON devices;
CREATE TRIGGER update_devices_updated_at
  BEFORE UPDATE ON devices
  FOR EACH ROW
  EXECUTE FUNCTION update_updated_at_column();

-- Add comment explaining the rating system
COMMENT ON COLUMN media_ratings.rating IS 'Rating scale 1-10 (odd numbers = half-stars: 1,3,5,7,9 = 0.5,1.5,2.5,3.5,4.5 stars)';

-- Enhanced metadata comments
COMMENT ON COLUMN media_items.language IS 'ISO 639-1 language code (e.g., en, es, fr)';
COMMENT ON COLUMN media_items.copyright_year IS 'Copyright or publication year for filtering/sorting';
COMMENT ON COLUMN media_items.page_count IS 'Total page count for progress tracking and sorting';
COMMENT ON COLUMN media_items.genre IS 'Genre classification for filtering and discovery';
COMMENT ON COLUMN media_items.edition IS 'Edition information (e.g., "First Edition", "Revised Edition")';
COMMENT ON COLUMN media_items.goodreads_id IS 'Goodreads book identifier for integration';
COMMENT ON COLUMN media_items.openlibrary_id IS 'Open Library identifier for integration';
COMMENT ON COLUMN media_items.google_books_id IS 'Google Books identifier for integration';

-- Comic-specific fields
COMMENT ON COLUMN media_items.manga_type IS 'Raw Manga field from ComicInfo.xml: unknown, no, yes, yes_and_right_to_left';
COMMENT ON COLUMN media_items.reading_direction IS 'Computed reading direction: auto, ltr (left-to-right), rtl (right-to-left), vertical (webtoons/manhwa)';
COMMENT ON COLUMN media_items.story_arc IS 'Story arc name for grouping related issues (e.g., "The Dark Phoenix Saga", "Civil War")';
COMMENT ON COLUMN media_items.is_black_and_white IS 'Black and white comic flag from ComicInfo.xml';
COMMENT ON COLUMN media_items.alternate_info IS 'Alternate series information as JSONB: {alternate_series, alternate_number, alternate_count}';
COMMENT ON COLUMN media_items.scan_information IS 'Scan information from ComicInfo.xml (scanner group, resolution, etc.)';

-- Universal fields (apply to ebooks, audiobooks, comics)
COMMENT ON COLUMN media_items.series_count IS 'Total items in series (from ComicInfo.xml Count field, or book series count)';
COMMENT ON COLUMN media_items.volume IS 'Volume/omnibus number for collected editions';
COMMENT ON COLUMN media_items.imprint IS 'Publisher imprint/subdivision (e.g., Vertigo, HarperCollinsEpic, DC Black Label)';
COMMENT ON COLUMN media_items.age_rating IS 'Age rating from metadata: Everyone, Teen, Mature, Adult (applies to all formats)';
COMMENT ON COLUMN media_items.web_url IS 'URL to info page (Goodreads, ComicVine, MangaUpdates, Audible, etc.)';

-- Additional metadata (applies to all formats)
COMMENT ON COLUMN media_items.metadata_notes IS 'Notes from metadata files (ComicInfo.xml, EPUB, PDF) - distinct from user notes in media_notes table';
COMMENT ON COLUMN media_items.community_rating IS 'Pre-existing community rating from metadata files (scale 0.0-10.0, DOUBLE PRECISION) - distinct from user ratings in media_ratings table';
COMMENT ON COLUMN media_items.summary IS 'Summary from ComicInfo.xml (may be merged with description from Calibre)';

-- Library Type File Extensions Notes:
-- - Ebooks: .epub, .pdf, .mobi, .azw, .azw3, .txt, .rtf, .doc, .docx, .lit, .fb2, .pdb
-- - Comics: .cbz, .cbr, .cb7, .cbt, .pdf
-- - Manga: .cbz, .cbr, .png, .jpg, .jpeg, .gif, .bmp, .webp (note: manga includes image folders)

-- Role System Notes:
-- - All users default to 'user' role
-- - Admin users can: create/manage libraries and folders, scan media, modify media metadata, delete media, control library visibility
-- - Regular users can: view visible libraries, rate media, track reading progress, create notes and highlights, manage their profile
-- - Library visibility is controlled through library_visibility table - admins can hide/show libraries per user
-- - Notes and highlights support position data (page:offset or CFI format) for precise location tracking
-- - Highlights can have associated notes for detailed annotations

-- ============================================
-- PERFORMANCE OPTIMIZATION
-- ============================================
-- NOTE: ALTER SYSTEM commands are commented out for sqlc compatibility.
-- These should be run manually by DBA or via init script:
--
-- Concurrency settings for high-throughput sync operations
-- ALTER SYSTEM SET max_connections = 200;
-- ALTER SYSTEM SET shared_buffers = '256MB';
-- ALTER SYSTEM SET effective_cache_size = '1GB';
-- ALTER SYSTEM SET maintenance_work_mem = '64MB';
-- ALTER SYSTEM SET checkpoint_completion_target = 0.9;
-- ALTER SYSTEM SET wal_buffers = '16MB';
-- ALTER SYSTEM SET default_statistics_target = 100;
--
-- Enable parallel query processing for sync operations
-- ALTER SYSTEM SET max_parallel_workers_per_gather = 4;
-- ALTER SYSTEM SET max_parallel_workers = 8;
-- ALTER SYSTEM SET parallel_setup_cost = 100;
-- ALTER SYSTEM SET parallel_tuple_cost = 0.1;
--
-- Work memory for complex sync queries
-- ALTER SYSTEM SET work_mem = '16MB';
--
-- Background writer optimization for sync queue
-- ALTER SYSTEM SET bgwriter_delay = 200ms;
-- ALTER SYSTEM SET bgwriter_lru_maxpages = 200;
-- ALTER SYSTEM SET bgwriter_lru_multiplier = 2.0;

-- Vacuum and analyze schedule for sync tables (run via cron)
-- VACUUM ANALYZE sync_queue;
-- VACUUM ANALYZE reading_progress;
-- VACUUM ANALYZE sync_conflicts;
-- VACUUM ANALYZE devices;
-- REINDEX TABLE CONCURRENTLY sync_queue;
-- REINDEX TABLE CONCURRENTLY reading_progress;

-- ============================================
-- SYNC HELPER FUNCTIONS
-- ============================================

-- Detect format group based on mimetype and file path
CREATE OR REPLACE FUNCTION detect_format_group(p_mimetype VARCHAR, p_file_path VARCHAR)
RETURNS VARCHAR AS $$
BEGIN
  CASE
    -- Reflowable formats
    WHEN p_mimetype = 'application/epub+zip' THEN
      RETURN 'reflowable';
    WHEN p_mimetype = 'application/x-mobipocket-ebook' THEN
      RETURN 'reflowable';
    WHEN p_mimetype = 'application/vnd.amazon.mobi8-ebook' THEN
      RETURN 'reflowable';
    WHEN p_file_path LIKE '%.epub' THEN
      RETURN 'reflowable';
    WHEN p_file_path LIKE '%.mobi' THEN
      RETURN 'reflowable';
    WHEN p_file_path LIKE '%.azw3' THEN
      RETURN 'reflowable';
    WHEN p_file_path LIKE '%.fb2' THEN
      RETURN 'reflowable';
    WHEN p_file_path LIKE '%.txt' THEN
      RETURN 'reflowable';

    -- Fixed layout formats
    WHEN p_mimetype = 'application/pdf' THEN
      RETURN 'fixed_layout';
    WHEN p_file_path LIKE '%.pdf' THEN
      RETURN 'fixed_layout';
    WHEN p_file_path LIKE '%.djvu' THEN
      RETURN 'fixed_layout';

    -- Comic archive formats
    WHEN p_mimetype = 'application/x-cbr' THEN
      RETURN 'comic_archive';
    WHEN p_mimetype = 'application/x-cbz' THEN
      RETURN 'comic_archive';
    WHEN p_file_path LIKE '%.cbz' THEN
      RETURN 'comic_archive';
    WHEN p_file_path LIKE '%.cbr' THEN
      RETURN 'comic_archive';
    WHEN p_file_path LIKE '%.cbt' THEN
      RETURN 'comic_archive';
    WHEN p_file_path LIKE '%.cb7' THEN
      RETURN 'comic_archive';

    ELSE
      RETURN 'unknown';
    END CASE;
END;
$$ LANGUAGE plpgsql;

-- Function to detect fixed-layout EPUBs from OPF content
CREATE OR REPLACE FUNCTION detect_fixed_layout_epub(opf_content TEXT)
RETURNS BOOLEAN AS $$
BEGIN
  -- Check for pre-paginated metadata
  IF opf_content LIKE '%rendition:layout">pre-paginated<%' THEN
    RETURN TRUE;
  END IF;
  
  IF opf_content LIKE '%rendition:layout="pre-paginated"%' THEN
    RETURN TRUE;
  END IF;
  
  -- Check for RTL page progression (manga indicator)
  IF opf_content LIKE '%page-progression-direction="rtl"%' THEN
    RETURN TRUE;
  END IF;
  
  -- Check for image-heavy content (count <img> tags)
  -- Threshold of 50 images suggests manga/comic vs novel
  IF array_length(string_to_array(opf_content, '<img'), 1) - 1 > 50 THEN
    RETURN TRUE;
  END IF;
  
  RETURN FALSE;
END;
$$ LANGUAGE plpgsql;


-- Convert progress between format groups
CREATE OR REPLACE FUNCTION convert_progress(
  source_format VARCHAR,
  target_format VARCHAR,
  source_progress JSONB
) RETURNS JSONB AS $$
DECLARE
  result JSONB;
  percentage FLOAT;
BEGIN
  -- Extract percentage from source
  CASE source_format
    WHEN 'reflowable' THEN
      percentage := (source_progress->>'percentage')::FLOAT;
    WHEN 'fixed_layout' THEN
      percentage := ((source_progress->>'page')::FLOAT /
                     (source_progress->>'total_pages')::FLOAT);
    WHEN 'comic_archive' THEN
      percentage := ((source_progress->>'page')::FLOAT /
                     (source_progress->>'total_pages')::FLOAT);
    ELSE
      percentage := 0.0;
  END CASE;

  -- Build target format progress
  CASE target_format
    WHEN 'reflowable' THEN
      result := jsonb_build_object(
        'percentage', percentage,
        'epubcfi', 'epubcfi(/6/4/2:' || CAST(percentage * 100 AS INTEGER) || ')'
      );
    WHEN 'fixed_layout' THEN
      result := jsonb_build_object(
        'page', CAST(percentage * 200 AS INTEGER),
        'total_pages', 200,
        'percentage', percentage
      );
    WHEN 'comic_archive' THEN
      result := jsonb_build_object(
        'page', CAST(percentage * 32 AS INTEGER),
        'total_pages', 32,
        'percentage', percentage
      );
    ELSE
      result := '{}'::jsonb;
  END CASE;

  RETURN result;
END;
$$ LANGUAGE plpgsql;

-- Detect conflicts in sync progress
CREATE OR REPLACE FUNCTION detect_conflict(
  p_media_item_id UUID,
  new_progress JSONB,
  device_type VARCHAR
) RETURNS BOOLEAN AS $$
DECLARE
  existing_progress JSONB;
  time_diff INTERVAL;
BEGIN
  -- Get most recent progress from different device
  SELECT jsonb_build_object(
    'percentage', percentage,
    'timestamp', last_sync_timestamp
  ) INTO existing_progress
  FROM reading_progress
  WHERE media_item_id = p_media_item_id
    AND last_sync_source != device_type
  ORDER BY last_sync_timestamp DESC
  LIMIT 1;

  -- If no existing progress, no conflict
  IF existing_progress IS NULL THEN
    RETURN FALSE;
  END IF;

  -- Check time difference (within 5 minutes = potential conflict)
  time_diff := NOW() - (existing_progress->>'timestamp')::TIMESTAMP WITH TIME ZONE;

  IF time_diff < INTERVAL '5 minutes' THEN
    -- Check if progress significantly different
    IF ABS((new_progress->>'percentage')::FLOAT -
           (existing_progress->>'percentage')::FLOAT) > 0.01 THEN
      RETURN TRUE;
    END IF;
  END IF;

  RETURN FALSE;
END;
$$ LANGUAGE plpgsql;

-- Merge progress from two sources
CREATE OR REPLACE FUNCTION merge_progress(
  progress_a JSONB,
  progress_b JSONB
) RETURNS JSONB AS $$
BEGIN
  RETURN jsonb_build_object(
    'percentage', GREATEST(
      (progress_a->>'percentage')::FLOAT,
      (progress_b->>'percentage')::FLOAT
    ),
    'merged_from', ARRAY[
      (progress_a->>'source'),
      (progress_b->>'source')
    ],
    'merge_timestamp', NOW()
  );
END;
$$ LANGUAGE plpgsql;

-- ============================================
-- KOREADER SYNC FUNCTIONS
-- ============================================

-- Bulk update progress from KOReader sync data
CREATE OR REPLACE FUNCTION bulk_update_progress_from_koreader(
  p_user_id UUID,
  p_sync_data JSONB
) RETURNS TABLE (
  media_item_id UUID,
  success BOOLEAN,
  message TEXT
) AS $$
DECLARE
  book_record JSONB;
  media_uuid UUID;
  existing_progress reading_progress%ROWTYPE;
  conflict_detected BOOLEAN;
BEGIN
  FOR book_record IN SELECT * FROM jsonb_array_elements(p_sync_data->'books')
  LOOP
    -- Try to find media item by UUID
    media_uuid := NULL;
    SELECT id INTO media_uuid FROM media_items WHERE id = (book_record->>'uuid')::uuid;

    -- If not found by UUID, try to match by file path
    IF media_uuid IS NULL AND book_record ? 'file_path' THEN
      SELECT id INTO media_uuid FROM media_items WHERE file_path = book_record->>'file_path';
    END IF;

    -- If still not found, try to match by title + author
    IF media_uuid IS NULL THEN
      SELECT id INTO media_uuid FROM media_items
      WHERE title = book_record->>'title'
        AND author = book_record->>'author'
      LIMIT 1;
    END IF;

    -- If no match found, return failure
    IF media_uuid IS NULL THEN
      RETURN QUERY SELECT NULL::uuid, FALSE, 'media item not found';
      CONTINUE;
    END IF;

    -- Check for conflicts
    conflict_detected := detect_conflict(media_uuid, book_record, 'koreader');

    -- Check if progress exists
    SELECT * INTO existing_progress FROM reading_progress
    WHERE media_item_id = media_uuid AND user_id = p_user_id;

    -- Update or insert progress
    IF existing_progress.media_item_id IS NOT NULL THEN
      UPDATE reading_progress SET
        percentage = (book_record->>'percentage')::FLOAT,
        character_offset = CASE WHEN book_record ? 'character' THEN (book_record->>'character')::BIGINT ELSE existing_progress.character_offset END,
        epubcfi = CASE WHEN book_record ? 'epubcfi' THEN (book_record->>'epubcfi')::TEXT ELSE existing_progress.epubcfi END,
        context_text = CASE WHEN book_record ? 'context_text' THEN (book_record->>'context_text')::TEXT ELSE existing_progress.context_text END,
        chapter = CASE WHEN book_record ? 'chapter' THEN (book_record->>'chapter')::INTEGER ELSE existing_progress.chapter END,
        chapter_progress = (book_record->>'percentage')::FLOAT,
        last_sync_device = 'koreader',
        last_sync_source = 'koreader',
        last_sync_timestamp = NOW(),
        conflict_detected = conflict_detected,
        conflict_resolved = NOT conflict_detected,
        last_read_at = CASE WHEN book_record ? 'last_read' THEN (book_record->>'last_read')::TIMESTAMP WITH TIME ZONE ELSE NOW() END,
        current_page = CASE WHEN book_record ? 'page' THEN (book_record->>'page')::INTEGER ELSE existing_progress.current_page END,
        total_pages = CASE WHEN book_record ? 'total_pages' THEN (book_record->>'total_pages')::INTEGER ELSE existing_progress.total_pages END
      WHERE media_item_id = media_uuid AND user_id = p_user_id;
    ELSE
      INSERT INTO reading_progress (
        media_item_id,
        user_id,
        percentage,
        character_offset,
        epubcfi,
        context_text,
        chapter,
        chapter_progress,
        last_sync_device,
        last_sync_source,
        last_sync_timestamp,
        conflict_detected,
        conflict_resolved,
        last_read_at,
        current_page,
        total_pages
      ) VALUES (
        media_uuid,
        p_user_id,
        (book_record->>'percentage')::FLOAT,
        CASE WHEN book_record ? 'character' THEN (book_record->>'character')::BIGINT ELSE NULL END,
        CASE WHEN book_record ? 'epubcfi' THEN (book_record->>'epubcfi')::TEXT ELSE NULL END,
        CASE WHEN book_record ? 'context_text' THEN (book_record->>'context_text')::TEXT ELSE NULL END,
        CASE WHEN book_record ? 'chapter' THEN (book_record->>'chapter')::INTEGER ELSE NULL END,
        (book_record->>'percentage')::FLOAT,
        'koreader',
        'koreader',
        NOW(),
        conflict_detected,
        NOT conflict_detected,
        CASE WHEN book_record ? 'last_read' THEN (book_record->>'last_read')::TIMESTAMP WITH TIME ZONE ELSE NOW() END,
        CASE WHEN book_record ? 'page' THEN (book_record->>'page')::INTEGER ELSE NULL END,
        CASE WHEN book_record ? 'total_pages' THEN (book_record->>'total_pages')::INTEGER ELSE NULL END
      );
    END IF;

    -- Create conflict record if detected
    IF conflict_detected THEN
      INSERT INTO sync_conflicts (media_item_id, user_id, conflict_type, conflict_data)
      VALUES (media_uuid, p_user_id, 'progress', jsonb_build_object(
        'koreader', book_record,
        'timestamp', NOW()
      ));
    END IF;

    RETURN QUERY SELECT media_uuid, TRUE,
      CASE WHEN conflict_detected THEN 'conflict detected' ELSE 'success' END;
  END LOOP;
END;
$$ LANGUAGE plpgsql;

-- ============================================
--: UNIVERSAL BOOK IDENTIFIERS
-- ============================================

-- Add universal identifier columns to media_items table
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS file_sha256 CHAR(64);
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS opf_identifier VARCHAR(255);
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS opf_uuid VARCHAR(255);
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS hash_confidence VARCHAR(20);

-- Create indexes for fast lookup
CREATE INDEX IF NOT EXISTS idx_media_items_sha256 ON media_items(file_sha256);
CREATE INDEX IF NOT EXISTS idx_media_items_opf_identifier ON media_items(opf_identifier);

-- Create media_item_formats table (track all format versions with their hashes)
CREATE TABLE IF NOT EXISTS media_item_formats (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
    format_type VARCHAR(10) NOT NULL,
    file_path VARCHAR(500),
    file_sha256 CHAR(64),
    file_size_bytes BIGINT,
    mime_type VARCHAR(100),
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    converted_from_format_id UUID REFERENCES media_item_formats(id),
    UNIQUE(media_item_id, format_type)
);

CREATE INDEX IF NOT EXISTS idx_media_item_formats_media ON media_item_formats(media_item_id, format_type);
CREATE INDEX IF NOT EXISTS idx_media_item_formats_sha256 ON media_item_formats(file_sha256);

-- Create device_file_aliases table (track file paths per device for cross-device matching)
CREATE TABLE IF NOT EXISTS device_file_aliases (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
    device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
    file_path VARCHAR(500) NOT NULL,
    file_sha256 CHAR(64),
    confidence_score FLOAT DEFAULT 1.0,
    last_seen_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    UNIQUE(device_id, file_path)
);

CREATE INDEX IF NOT EXISTS idx_device_file_aliases_media_device ON device_file_aliases(media_item_id, device_id);
CREATE INDEX IF NOT EXISTS idx_device_file_aliases_sha256 ON device_file_aliases(file_sha256);

-- Create collections table (device-neutral collections)
CREATE TABLE IF NOT EXISTS collections (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID REFERENCES users(id) ON DELETE CASCADE,
    name VARCHAR(100) NOT NULL,
    description TEXT,
    color VARCHAR(7),
    icon VARCHAR(50),
    auto_assign_rules JSONB,
    view_settings JSONB,
    show_on_dashboard BOOLEAN DEFAULT false,
    query_type TEXT DEFAULT 'filter',
    priority INT DEFAULT 100,
    is_system_collection BOOLEAN DEFAULT false,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    UNIQUE(user_id, name)
);

CREATE INDEX IF NOT EXISTS idx_collections_user_id ON collections(user_id);

-- Index for dashboard queries
CREATE INDEX IF NOT EXISTS idx_collections_dashboard ON collections(user_id, show_on_dashboard, priority)
    WHERE show_on_dashboard = true;

-- Create user_dashboard_preferences table
CREATE TABLE IF NOT EXISTS user_dashboard_preferences (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    library_id UUID REFERENCES libraries(id) ON DELETE CASCADE,
    hidden_collections TEXT[] DEFAULT '{}',
    collection_order TEXT[] DEFAULT '{}',
    items_per_section INT DEFAULT 20,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    UNIQUE(user_id, library_id)
);

-- Index for fast lookups
CREATE INDEX IF NOT EXISTS idx_dashboard_prefs_user_library ON user_dashboard_preferences(user_id, library_id);

-- Create collection_items table (which books belong to each collection)
CREATE TABLE IF NOT EXISTS collection_items (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    collection_id UUID NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
    media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
    added_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    added_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
    excluded BOOLEAN DEFAULT false,
    UNIQUE(collection_id, media_item_id)
);

CREATE INDEX IF NOT EXISTS idx_collection_items_collection ON collection_items(collection_id);
CREATE INDEX IF NOT EXISTS idx_collection_items_media ON collection_items(media_item_id);

-- Index for excluding auto-assigned items
CREATE INDEX IF NOT EXISTS idx_collection_items_excluded ON collection_items(collection_id, excluded)
    WHERE excluded = true;

-- System collections are now created per-user upon registration
-- See CreateDefaultCollectionsForUser in auth.go

-- Create device_shelf_mappings table (map Bookhoard collections to device-specific shelf names)
CREATE TABLE IF NOT EXISTS device_shelf_mappings (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    collection_id UUID NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
    device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
    device_shelf_name VARCHAR(100),
    sync_direction VARCHAR(20),
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    UNIQUE(collection_id, device_id)
);

CREATE INDEX IF NOT EXISTS idx_device_shelf_mappings_collection ON device_shelf_mappings(collection_id);
CREATE INDEX IF NOT EXISTS idx_device_shelf_mappings_device ON device_shelf_mappings(device_id);

-- Create device_catalogs table (track OPDS downloads and map Bookhoard UUIDs to device ContentIds)
CREATE TABLE IF NOT EXISTS device_catalogs (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
    media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
    bookhoard_uuid UUID NOT NULL,
    kobo_content_id VARCHAR(255) NOT NULL,
    content_id_type VARCHAR(20),
    available BOOLEAN DEFAULT TRUE,
    delivery_date TIMESTAMP WITH TIME ZONE,
    delivery_method VARCHAR(20),
    UNIQUE(device_id, kobo_content_id)
);

CREATE INDEX IF NOT EXISTS idx_device_catalogs_bookhoard ON device_catalogs(bookhoard_uuid);
CREATE INDEX IF NOT EXISTS idx_device_catalogs_kobo ON device_catalogs(kobo_content_id);

-- Create system_config table (system-wide configuration)
CREATE TABLE IF NOT EXISTS system_config (
    key VARCHAR(100) PRIMARY KEY,
    value TEXT NOT NULL,
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    updated_by UUID REFERENCES users(id)
);

-- One-time cleanup: clear the old placeholder seed so the startup logic
-- can re-seed from the BASE_URL env var (or the setup wizard can set it).
UPDATE system_config SET value = ''
WHERE key = 'base_url' AND value = 'https://bookhoard.example.com';
UPDATE system_config SET value = ''
WHERE key = 'opds_base_url' AND value = 'https://bookhoard.example.com/opds';
UPDATE system_config SET value = ''
WHERE key = 'api_base_url' AND value = 'https://bookhoard.example.com/api';

-- Create opds_tokens table (device-specific OPDS access tokens)
CREATE TABLE IF NOT EXISTS opds_tokens (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
    token VARCHAR(64) UNIQUE NOT NULL,
    token_type VARCHAR(20),
    expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_opds_tokens_device ON opds_tokens(device_id);
CREATE INDEX IF NOT EXISTS idx_opds_tokens_token ON opds_tokens(token);

-- Modify kobo_shelves table (reference collections instead of media_items directly)
ALTER TABLE kobo_shelves ADD COLUMN IF NOT EXISTS collection_id UUID REFERENCES collections(id);
ALTER TABLE kobo_shelves ADD COLUMN IF NOT EXISTS position_in_collection INTEGER;

-- ============================================
--: UNLINKED BOOKS TRACKING
-- ============================================

-- Create unlinked_books table to track books that couldn't be auto-matched
CREATE TABLE IF NOT EXISTS unlinked_books (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
    content_id VARCHAR(255) NOT NULL,
    file_path VARCHAR(500),
    title VARCHAR(255),
    author VARCHAR(255),
    confidence_score FLOAT DEFAULT 0.5,
    resolved BOOLEAN DEFAULT FALSE,
    media_item_id UUID REFERENCES media_items(id) ON DELETE SET NULL,
    resolved_at TIMESTAMP WITH TIME ZONE,
    resolution_method VARCHAR(50),
    last_seen_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_unlinked_books_device ON unlinked_books(device_id);
CREATE INDEX IF NOT EXISTS idx_unlinked_books_content_id ON unlinked_books(content_id);
CREATE INDEX IF NOT EXISTS idx_unlinked_books_resolved ON unlinked_books(resolved);

-- Create saved_filters for users to be able to filter different parts of the application
CREATE TABLE IF NOT EXISTS saved_filters (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    name TEXT NOT NULL,
    resource_type TEXT NOT NULL, -- 'media-items', 'collections', 'devices', etc.
    filters JSONB NOT NULL, -- {search: "", author_filter: "", genre: "", ...}
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW()
);

-- Index for efficient user+resource lookups
CREATE INDEX IF NOT EXISTS idx_saved_filters_user_resource ON saved_filters(user_id, resource_type);

-- Index for name searches (future feature)
CREATE INDEX IF NOT EXISTS idx_saved_filters_name ON saved_filters(user_id, name);

-- Unique constraint: One filter name per user per resource type
-- Prevents duplicate filter names while allowing:
-- - Same name for different users
-- - Same name for different resources (media-items vs collections)
CREATE UNIQUE INDEX IF NOT EXISTS idx_saved_filters_user_name_resource
ON saved_filters(user_id, name, resource_type);

-- Trigger to auto-update updated_at timestamp
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
    NEW.updated_at = NOW();
    RETURN NEW;
END;
$$ language 'plpgsql';

DROP TRIGGER IF EXISTS update_saved_filters_updated_at ON saved_filters;

CREATE TRIGGER update_saved_filters_updated_at
    BEFORE UPDATE ON saved_filters
    FOR EACH ROW
    EXECUTE FUNCTION update_updated_at_column();

-- Panel detection data
CREATE TABLE IF NOT EXISTS panel_data (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
  page_number INTEGER NOT NULL,
  detection_method VARCHAR(20) NOT NULL, -- 'grid', 'ml', 'manual'
  panels JSONB NOT NULL,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW(),
  UNIQUE(media_item_id, page_number)
);

CREATE INDEX IF NOT EXISTS idx_panel_data_media_item ON panel_data(media_item_id);

-- Reading speed tracking
CREATE TABLE IF NOT EXISTS reading_speed (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
  words_per_minute REAL,
  pages_per_minute REAL,
  pages_read INTEGER DEFAULT 0,
  total_reading_minutes REAL DEFAULT 0,
  last_read_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW(),
  UNIQUE(user_id, media_item_id)
);

CREATE INDEX IF NOT EXISTS idx_reading_speed_user ON reading_speed(user_id);
CREATE INDEX IF NOT EXISTS idx_reading_speed_item ON reading_speed(media_item_id);

-- Dictionary cache (for offline use)
CREATE TABLE IF NOT EXISTS dictionary_cache (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  word VARCHAR(100) NOT NULL UNIQUE,
  definition TEXT NOT NULL,
  part_of_speech VARCHAR(20),
  example TEXT,
  etymology TEXT,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  accessed_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_dictionary_word ON dictionary_cache(word);

-- Reader settings (per-user preferences)
CREATE TABLE IF NOT EXISTS reader_settings (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  setting_key VARCHAR(50) NOT NULL,
  setting_value JSONB NOT NULL,
  updated_at TIMESTAMPTZ DEFAULT NOW(),
  UNIQUE(user_id, setting_key)
);

CREATE INDEX IF NOT EXISTS idx_reader_settings_user ON reader_settings(user_id);

-- PDF bookmarks (custom user bookmarks)
CREATE TABLE IF NOT EXISTS media_bookmarks (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
  user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  page_number INTEGER,
  chapter_number INTEGER,
  cfi_position VARCHAR(255), -- For ebooks: EPUB CFI position
  title VARCHAR(255) NOT NULL,
  position VARCHAR(100), -- 'pdf:page:45', 'comic:page:12', 'chapter:3' for consistency
  notes TEXT,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  UNIQUE(media_item_id, user_id, title)
);

CREATE INDEX IF NOT EXISTS idx_media_bookmarks_media ON media_bookmarks(media_item_id);
CREATE INDEX IF NOT EXISTS idx_media_bookmarks_user ON media_bookmarks(user_id);

-- ============================================
-- ANNOTATION SYNC MIGRATIONS
-- Adds dedup_key, LWW timestamps, soft-delete,
-- and device_sync_data to annotation tables.
-- ============================================

ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS dedup_key VARCHAR(40);
ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS last_modified_at TIMESTAMPTZ;
ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS last_modified_source VARCHAR(30);
ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS note_text TEXT;
ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS deleted BOOLEAN DEFAULT FALSE;
ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;

ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS dedup_key VARCHAR(40);
ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS last_modified_at TIMESTAMPTZ;
ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS last_modified_source VARCHAR(30);
ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS deleted BOOLEAN DEFAULT FALSE;
ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;

ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS dedup_key VARCHAR(40);
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS last_modified_at TIMESTAMPTZ;
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS last_modified_source VARCHAR(30);
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS device_sync_data JSONB;
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS percentage_location FLOAT;
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS epubcfi_location TEXT;
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS chapter_reference INTEGER;
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS deleted BOOLEAN DEFAULT FALSE;
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;

CREATE UNIQUE INDEX IF NOT EXISTS idx_media_highlights_dedup
    ON media_highlights (user_id, media_item_id, dedup_key)
    WHERE dedup_key IS NOT NULL AND deleted = FALSE;

CREATE UNIQUE INDEX IF NOT EXISTS idx_media_notes_dedup
    ON media_notes (user_id, media_item_id, dedup_key)
    WHERE dedup_key IS NOT NULL AND deleted = FALSE;

CREATE UNIQUE INDEX IF NOT EXISTS idx_media_bookmarks_dedup
    ON media_bookmarks (user_id, media_item_id, dedup_key)
    WHERE dedup_key IS NOT NULL AND deleted = FALSE;

CREATE INDEX IF NOT EXISTS idx_media_highlights_deleted_at ON media_highlights(deleted_at) WHERE deleted = TRUE;
CREATE INDEX IF NOT EXISTS idx_media_notes_deleted_at ON media_notes(deleted_at) WHERE deleted = TRUE;
CREATE INDEX IF NOT EXISTS idx_media_bookmarks_deleted_at ON media_bookmarks(deleted_at) WHERE deleted = TRUE;

-- ============================================
--: MEDIA ITEM DEDUPLICATION + PATH UNIQUENESS
-- ============================================
-- A read-then-write race in the scanner historically allowed the same
-- (library_id, file_path) to be inserted twice. This block is self-healing:
-- it collapses any existing path-duplicates (re-parenting child rows onto a
-- survivor so no reading history is lost), then enforces uniqueness going
-- forward. Idempotent — safe to re-run on every startup.

-- Move every child row that points at p_source so it points at p_target,
-- deleting source rows that would violate a UNIQUE constraint on the target.
CREATE OR REPLACE FUNCTION reparent_media_item_children(p_target UUID, p_source UUID)
RETURNS void
LANGUAGE plpgsql
AS $$
BEGIN
    IF p_target IS NULL OR p_source IS NULL OR p_target = p_source THEN
        RETURN;
    END IF;

    DELETE FROM reading_progress
    WHERE media_item_id = p_source
      AND user_id IN (SELECT user_id FROM reading_progress WHERE media_item_id = p_target);
    UPDATE reading_progress SET media_item_id = p_target WHERE media_item_id = p_source;

    DELETE FROM reading_speed
    WHERE media_item_id = p_source
      AND user_id IN (SELECT user_id FROM reading_speed WHERE media_item_id = p_target);
    UPDATE reading_speed SET media_item_id = p_target WHERE media_item_id = p_source;

    DELETE FROM media_ratings
    WHERE media_item_id = p_source
      AND user_id IN (SELECT user_id FROM media_ratings WHERE media_item_id = p_target);
    UPDATE media_ratings SET media_item_id = p_target WHERE media_item_id = p_source;

    DELETE FROM media_bookmarks
    WHERE media_item_id = p_source
      AND (user_id, title) IN (SELECT user_id, title FROM media_bookmarks WHERE media_item_id = p_target);
    UPDATE media_bookmarks SET media_item_id = p_target WHERE media_item_id = p_source;

    DELETE FROM media_item_formats
    WHERE media_item_id = p_source
      AND format_type IN (SELECT format_type FROM media_item_formats WHERE media_item_id = p_target);
    UPDATE media_item_formats SET media_item_id = p_target WHERE media_item_id = p_source;

    DELETE FROM collection_items
    WHERE media_item_id = p_source
      AND collection_id IN (SELECT collection_id FROM collection_items WHERE media_item_id = p_target);
    UPDATE collection_items SET media_item_id = p_target WHERE media_item_id = p_source;

    DELETE FROM kobo_shelves
    WHERE media_item_id = p_source
      AND device_id IN (SELECT device_id FROM kobo_shelves WHERE media_item_id = p_target);
    UPDATE kobo_shelves SET media_item_id = p_target WHERE media_item_id = p_source;

    DELETE FROM panel_data
    WHERE media_item_id = p_source
      AND page_number IN (SELECT page_number FROM panel_data WHERE media_item_id = p_target);
    UPDATE panel_data SET media_item_id = p_target WHERE media_item_id = p_source;

    DELETE FROM processing_issues
    WHERE media_item_id = p_source
      AND issue_type IN (SELECT issue_type FROM processing_issues WHERE media_item_id = p_target);
    UPDATE processing_issues SET media_item_id = p_target WHERE media_item_id = p_source;

    DELETE FROM device_file_aliases
    WHERE media_item_id = p_source
      AND (device_id, file_path) IN (SELECT device_id, file_path FROM device_file_aliases WHERE media_item_id = p_target);
    UPDATE device_file_aliases SET media_item_id = p_target WHERE media_item_id = p_source;

    -- Tables whose UNIQUE keys do not include media_item_id.
    UPDATE device_catalogs   SET media_item_id = p_target WHERE media_item_id = p_source;
    UPDATE kobo_entitlements SET media_item_id = p_target WHERE media_item_id = p_source;
    UPDATE media_highlights  SET media_item_id = p_target WHERE media_item_id = p_source;
    UPDATE media_notes       SET media_item_id = p_target WHERE media_item_id = p_source;
    UPDATE reading_history   SET media_item_id = p_target WHERE media_item_id = p_source;
    UPDATE sync_conflicts    SET media_item_id = p_target WHERE media_item_id = p_source;
    UPDATE sync_queue        SET media_item_id = p_target WHERE media_item_id = p_source;
END;
$$;

-- Collapse every (library_id, file_path) group into a single row.
-- Survivor = the row with the most user data; ties broken by lowest id.
CREATE OR REPLACE FUNCTION dedup_media_items_by_path() RETURNS void
LANGUAGE plpgsql
AS $$
DECLARE
    g        RECORD;
    v_surv   UUID;
    v_loser  UUID;
BEGIN
    FOR g IN
        SELECT library_id, file_path
        FROM media_items
        GROUP BY library_id, file_path
        HAVING COUNT(*) > 1
    LOOP
        SELECT mi.id INTO v_surv
        FROM media_items mi
        WHERE mi.library_id = g.library_id AND mi.file_path = g.file_path
        ORDER BY
            ((SELECT COUNT(*) FROM reading_progress  rp WHERE rp.media_item_id = mi.id)
           + (SELECT COUNT(*) FROM media_highlights   mh WHERE mh.media_item_id = mi.id)
           + (SELECT COUNT(*) FROM media_bookmarks    mb WHERE mb.media_item_id = mi.id)
           + (SELECT COUNT(*) FROM media_notes        mn WHERE mn.media_item_id = mi.id)
           + (SELECT COUNT(*) FROM reading_history    rh WHERE rh.media_item_id = mi.id)
           + (SELECT COUNT(*) FROM collection_items   ci WHERE ci.media_item_id = mi.id)) DESC,
            mi.id ASC
        LIMIT 1;

        FOR v_loser IN
            SELECT id FROM media_items
            WHERE library_id = g.library_id AND file_path = g.file_path AND id <> v_surv
            ORDER BY id
        LOOP
            PERFORM reparent_media_item_children(v_surv, v_loser);
            DELETE FROM media_items WHERE id = v_loser;
        END LOOP;
    END LOOP;
END;
$$;

-- Collapse any existing path-duplicates so the constraint below can be created.
SELECT dedup_media_items_by_path();

-- Enforce path uniqueness going forward (guarded so re-runs don't error).
DO $$
BEGIN
    IF NOT EXISTS (
        SELECT 1 FROM pg_constraint
        WHERE conname = 'media_items_library_id_file_path_key'
          AND conrelid = 'media_items'::regclass
    ) THEN
        ALTER TABLE media_items
            ADD CONSTRAINT media_items_library_id_file_path_key UNIQUE (library_id, file_path);
    END IF;
END $$;

-- ============================================
--: HASH CONFLICTS
-- ============================================
-- Records content-duplicate groups discovered during hash backfill or rescan:
-- two or more media_items in the same library share a file_sha256 but live at
-- different file paths (e.g. the same book imported twice under two names on
-- a preexisting database). Unlike path duplicates these cannot be auto-collapsed
-- (keeping both copies may be intentional), so each group is surfaced on the
-- admin Hash Conflicts page for the user to resolve:
--   keep_all      - both copies are intentional; just stop flagging
--   kept:<uuid>   - merge every other copy's child rows into the kept item
--                   (via reparent_media_item_children) and delete the losers
CREATE TABLE IF NOT EXISTS hash_conflicts (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    library_id UUID NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
    file_sha256 CHAR(64) NOT NULL,
    status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','resolved')),
    resolution VARCHAR(50),        -- 'keep_all' or 'kept:<media_item_uuid>' (41 chars)
    resolved_by UUID REFERENCES users(id) ON DELETE SET NULL,
    created_at TIMESTAMPTZ DEFAULT NOW(),
    resolved_at TIMESTAMPTZ,
    UNIQUE(library_id, file_sha256)
);

CREATE INDEX IF NOT EXISTS idx_hash_conflicts_status ON hash_conflicts(status);

-- Widen for databases created before the resolution format settled (no-op otherwise)
ALTER TABLE hash_conflicts ALTER COLUMN resolution TYPE VARCHAR(50);
