"""
Step 7 — Full Data Import/Export Tests
- test_full_export_import_roundtrip: Export all data then import back, verify all entities
- test_cross_entity_reference_integrity: After ID collision remapping, references stay valid
- test_new_id_propagation_to_combo: new_id UUID is propagated to combo exercise FK on collision
- test_per_entity_export_from_detail_views: Export single combo/session/collection from detail
- test_different_user_suffix_applies_to_all_types: Suffix applied to exercises, combos, sessions, collections
- test_same_user_collision_combo: Same-user combo collision with replace/skip
- test_same_user_collision_session: Same-user session collision with replace/skip
- test_same_user_collision_collection: Same-user collection collision with replace/skip
- test_export_filter_all_enabled: All checkboxes enabled in export filter dialog
- test_import_combo_list_view: Import button visible on combo list
- test_import_session_list_view: Import button visible on session list
- test_import_collection_list_view: Import button visible on collection list
- test_full_export_from_settings: Full export button in settings works
- test_import_as_mine_combo: Import-as-mine button works on combo list
- test_import_as_mine_session: Import-as-mine button works on session list
- test_execution_logs_same_user_roundtrip: Execution logs survive a same-user export/import cycle
- test_execution_logs_same_user_reimport_without_delete: Re-import without delete skips logs cleanly
- test_execution_log_id_remapping: Log item exercise_id is remapped when the exercise gets a new_id
- test_session_with_combo_item_remapping: Session combo item_id follows remap when combo gets new_id
- test_collection_session_remapping: Collection session ref follows remap when session gets new_id
- test_settings_import_roundtrip: Settings values are applied after import
- test_single_entity_export_buttons_session_collection: Export button visible on session/collection detail
- test_export_respects_active_search_filter: Exporting while a search filter is active only exports matching items
- test_single_export_bundles_dependencies: Exporting a combo/session/collection bundles its dependencies
- test_settings_export_filter_bundles_dependencies: Settings export filter bundles dependencies for a partial selection
"""
import json
import os
from playwright.sync_api import Page, expect


def _wait_for_db(page: Page, app_url: str):
    page.goto(app_url)
    page.wait_for_function(
        "document.documentElement.getAttribute('data-db-ready') === 'true'",
        timeout=15000,
    )


def _go_to(page: Page, app_url: str, hash_path: str):
    _wait_for_db(page, app_url)
    page.click(f'a[href="{hash_path}"]')
    page.wait_for_timeout(500)


def _clean_all(page: Page):
    page.evaluate(
        """async () => {
            const exercises = await window.__repos.exercises.getAll();
            for (const ex of exercises) await window.__repos.exercises.delete(ex.id);
            const combos = await window.__repos.combos.getAll();
            for (const c of combos) await window.__repos.combos.delete(c.id);
            const sessions = await window.__repos.sessions.getAll();
            for (const s of sessions) await window.__repos.sessions.delete(s.id);
            const collections = await window.__repos.collections.getAll();
            for (const c of collections) await window.__repos.collections.delete(c.id);
            const suffixes = await window.__repos.suffixes.getAll();
            for (const s of suffixes) await window.__repos.suffixes.delete(s.user_uuid);
        }"""
    )


def _create_exercise(page: Page, title, exercise_id=None, created_by=None):
    return page.evaluate(
        """async ([title, exerciseId, createdBy]) => {
            const uuid = await window.__repos.settings.get('user_uuid');
            const cb = createdBy || uuid;
            if (exerciseId) {
                await window.__repos.exercises.createWithId(exerciseId, {
                    title, description: '', asymmetric: false, alternate: false,
                    image_urls: [], video_urls: [], default_reps: 10, default_weight: 20,
                    created_by: cb,
                });
                return exerciseId;
            }
            return await window.__repos.exercises.create({
                title, description: '', asymmetric: false, alternate: false,
                image_urls: [], video_urls: [], default_reps: 10, default_weight: 20,
                created_by: cb,
            });
        }""",
        [title, exercise_id, created_by],
    )


def _create_combo(page: Page, title, exercise_ids, combo_id=None, created_by=None):
    return page.evaluate(
        """async ([title, exerciseIds, comboId, createdBy]) => {
            const uuid = await window.__repos.settings.get('user_uuid');
            const cb = createdBy || uuid;
            const id = comboId || crypto.randomUUID();
            await window.__repos.combos.createWithId(id, {
                title, description: '', type: 'NONE', timer_config: {}, created_by: cb,
            });
            const exercises = exerciseIds.map((eid, i) => ({
                exerciseId: eid, position: i, defaultReps: 5, defaultWeight: 10,
            }));
            await window.__repos.combos.setExercises(id, exercises);
            return id;
        }""",
        [title, exercise_ids, combo_id, created_by],
    )


def _create_session(page: Page, title, items, session_id=None, created_by=None):
    return page.evaluate(
        """async ([title, items, sessionId, createdBy]) => {
            const uuid = await window.__repos.settings.get('user_uuid');
            const cb = createdBy || uuid;
            const id = sessionId || crypto.randomUUID();
            await window.__repos.sessions.createWithId(id, {
                title, description: '', created_by: cb,
            });
            const sessionItems = items.map((item, i) => ({
                item_id: item.item_id,
                item_type: item.item_type,
                repetitions: item.repetitions || 1,
                weight: item.weight !== undefined ? item.weight : 0.0,
            }));
            await window.__repos.sessions.setItems(id, sessionItems);
            return id;
        }""",
        [title, items, session_id, created_by],
    )


def _create_collection(page: Page, label, session_ids, collection_id=None, created_by=None):
    return page.evaluate(
        """async ([label, sessionIds, collectionId, createdBy]) => {
            const uuid = await window.__repos.settings.get('user_uuid');
            const cb = createdBy || uuid;
            const id = collectionId || crypto.randomUUID();
            await window.__repos.collections.createWithId(id, { label, created_by: cb });
            await window.__repos.collections.setSessions(id, sessionIds);
            return id;
        }""",
        [label, session_ids, collection_id, created_by],
    )


def _build_full_export_json(page: Page):
    """Build a full export JSON envelope from current database state."""
    return page.evaluate(
        """async () => {
            const exercises = await window.__repos.exercises.getAll();
            const combos = await window.__repos.combos.getAllWithExercises();
            const sessions = await window.__repos.sessions.getAllWithItems();
            const collections = await window.__repos.collections.getAllWithSessions();
            const executionLogs = await window.__repos.executionLogs.getAllWithItems();
            const userName = await window.__repos.settings.get('user_name');
            const userUuid = await window.__repos.settings.get('user_uuid');

            return JSON.stringify({
                metadata: {
                    appName: 'trainus',
                    appVersion: '1.0.0',
                    schemaVersion: 1,
                    exportDate: new Date().toISOString(),
                    exportedBy: { name: userName || 'User', uuid: userUuid },
                },
                data: {
                    exercises, combos, sessions, collections, executionLogs,
                },
            });
        }"""
    )


def _build_different_user_export_json(page: Page):
    """Build an export JSON with a different user UUID."""
    return page.evaluate(
        """async () => {
            const exercises = await window.__repos.exercises.getAll();
            const combos = await window.__repos.combos.getAllWithExercises();
            const sessions = await window.__repos.sessions.getAllWithItems();
            const collections = await window.__repos.collections.getAllWithSessions();

            return JSON.stringify({
                metadata: {
                    appName: 'trainus',
                    appVersion: '1.0.0',
                    schemaVersion: 1,
                    exportDate: new Date().toISOString(),
                    exportedBy: { name: 'Other User', uuid: 'other-user-uuid-789' },
                },
                data: {
                    exercises, combos, sessions, collections,
                },
            });
        }"""
    )


def _load_fixture(filename: str) -> str:
    """Read a static JSON fixture from tests/fixtures/."""
    path = os.path.join(os.path.dirname(__file__), "fixtures", filename)
    with open(path, "r", encoding="utf-8") as f:
        return f.read()


def _create_execution_log(page: Page, session_id: str, exercise_id: str) -> str:
    """Create an execution log with one completed item; returns the log ID."""
    return page.evaluate(
        """async ([sessionId, exerciseId]) => {
            const logId = await window.__repos.executionLogs.create({
                session_id: sessionId,
                started_at: new Date().toISOString(),
                finished_at: new Date().toISOString(),
            });
            await window.__repos.executionLogs.addItem({
                log_id: logId,
                exercise_id: exerciseId,
                round_number: 1,
                reps_done: 10,
                weight_used: 50,
                side: null,
                completed: true,
                timestamp: new Date().toISOString(),
            });
            return logId;
        }""",
        [session_id, exercise_id],
    )


def _trigger_import_with_json(page: Page, json_str: str, test_id: str = "exercise-import-btn"):
    """Trigger import by intercepting file chooser."""
    with page.expect_file_chooser() as fc_info:
        page.click(f'[data-testid="{test_id}"]')
    file_chooser = fc_info.value
    file_chooser.set_files(
        [
            {
                "name": "test-import.json",
                "mimeType": "application/json",
                "buffer": json_str.encode("utf-8"),
            }
        ]
    )


def test_full_export_import_roundtrip(page: Page, app_url: str):
    """Export all data then import back, verify all entities are restored."""
    _go_to(page, app_url, "#/exercises")
    _clean_all(page)

    exercise_id = _create_exercise(page, "Push-ups")
    combo_id = _create_combo(page, "Upper Body", [exercise_id])
    session_id = _create_session(
        page, "Day 1", [{"item_id": exercise_id, "item_type": "exercise", "repetitions": 10, "weight": 0}]
    )
    _create_collection(page, "Week 1", [session_id])

    json_str = _build_full_export_json(page)

    page.evaluate(
        """async () => {
            const exercises = await window.__repos.exercises.getAll();
            for (const ex of exercises) await window.__repos.exercises.delete(ex.id);
            const combos = await window.__repos.combos.getAll();
            for (const c of combos) await window.__repos.combos.delete(c.id);
            const sessions = await window.__repos.sessions.getAll();
            for (const s of sessions) await window.__repos.sessions.delete(s.id);
            const collections = await window.__repos.collections.getAll();
            for (const c of collections) await window.__repos.collections.delete(c.id);
        }"""
    )

    page.click('a[href="#/exercises"]')
    page.wait_for_timeout(500)
    expect(page.locator('[data-testid="exercise-empty"]')).to_be_visible()

    _trigger_import_with_json(page, json_str)

    # Wait for the import dialog to finish parsing and land on a known step.
    # Under parallel load, 500ms fixed wait is unreliable — wait for the actual
    # apply button to become visible instead (present in both 'preview' and
    # 'collisions' steps).
    expect(page.locator('[data-testid="import-apply"]')).to_be_visible(timeout=10000)

    page.locator('[data-testid="import-apply"]').click()

    # Wait for the report step rather than using a fixed timeout.
    expect(page.locator('[data-testid="import-report-step"]')).to_be_visible(timeout=15000)
    page.click('[data-testid="import-done"]')
    page.wait_for_timeout(500)

    exercise_count = page.evaluate("async () => (await window.__repos.exercises.getAll()).length")
    combo_count = page.evaluate("async () => (await window.__repos.combos.getAll()).length")
    session_count = page.evaluate("async () => (await window.__repos.sessions.getAll()).length")
    collection_count = page.evaluate("async () => (await window.__repos.collections.getAll()).length")

    assert exercise_count == 1, f"Expected 1 exercise, got {exercise_count}"
    assert combo_count == 1, f"Expected 1 combo, got {combo_count}"
    assert session_count == 1, f"Expected 1 session, got {session_count}"
    assert collection_count == 1, f"Expected 1 collection, got {collection_count}"


def test_cross_entity_reference_integrity(page: Page, app_url: str):
    """After ID collision remapping, combo exercises still reference correctly."""
    _go_to(page, app_url, "#/exercises")
    _clean_all(page)

    other_uuid = "other-user-uuid-123"
    ex_id = _create_exercise(page, "Squats", created_by=other_uuid)
    _create_combo(page, "Leg Day", [ex_id], created_by=other_uuid)

    json_str = _build_different_user_export_json(page)

    page.evaluate(
        """async () => {
            const exercises = await window.__repos.exercises.getAll();
            for (const ex of exercises) await window.__repos.exercises.delete(ex.id);
            const combos = await window.__repos.combos.getAll();
            for (const c of combos) await window.__repos.combos.delete(c.id);
        }"""
    )

    page.click('a[href="#/exercises"]')
    page.wait_for_timeout(500)

    _trigger_import_with_json(page, json_str)
    page.wait_for_timeout(500)

    page.locator('[data-testid="import-suffix-input"]').fill("OD")
    page.locator('[data-testid="import-suffix-confirm"]').click()
    page.wait_for_timeout(500)

    page.locator('[data-testid="import-apply"]').click()
    page.wait_for_timeout(1000)
    page.click('[data-testid="import-done"]')
    page.wait_for_timeout(500)

    combo = page.evaluate(
        """async () => {
            const combos = await window.__repos.combos.getAll();
            if (combos.length === 0) return null;
            return await window.__repos.combos.getById(combos[0].id);
        }"""
    )

    assert combo is not None, "Combo should exist after import"
    assert len(combo.get("exercises", [])) == 1, "Combo should have 1 exercise"
    assert combo["exercises"][0]["title"] == "Squats [OD]", f"Exercise title should have suffix: {combo['exercises'][0]['title']}"


def test_per_entity_export_from_detail_views(page: Page, app_url: str):
    """Export single combo/session/collection from their detail views."""
    _go_to(page, app_url, "#/exercises")
    _clean_all(page)

    ex_id = _create_exercise(page, "Pull-ups")
    combo_id = _create_combo(page, "Back Blast", [ex_id])

    page.click('a[href="#/combos"]')
    page.wait_for_timeout(500)
    page.click(f'[data-testid="combo-card-{combo_id}"]')
    page.wait_for_timeout(500)

    expect(page.locator('[data-testid="combo-export-single-btn"]')).to_be_visible()


def test_different_user_suffix_applies_to_all_types(page: Page, app_url: str):
    """Suffix is applied to exercises, combos, sessions, and collections from different user."""
    _go_to(page, app_url, "#/exercises")
    _clean_all(page)

    other_uuid = "other-user-456"
    ex_id = _create_exercise(page, "Burpees", created_by=other_uuid)
    _create_combo(page, "HIIT", [ex_id], created_by=other_uuid)
    _create_session(
        page, "Cardio Day", [{"item_id": ex_id, "item_type": "exercise", "repetitions": 15, "weight": 0}],
        created_by=other_uuid,
    )
    _create_collection(page, "Cardio Week", [], created_by=other_uuid)

    json_str = _build_different_user_export_json(page)

    page.evaluate(
        """async () => {
            const exercises = await window.__repos.exercises.getAll();
            for (const ex of exercises) await window.__repos.exercises.delete(ex.id);
            const combos = await window.__repos.combos.getAll();
            for (const c of combos) await window.__repos.combos.delete(c.id);
            const sessions = await window.__repos.sessions.getAll();
            for (const s of sessions) await window.__repos.sessions.delete(s.id);
            const collections = await window.__repos.collections.getAll();
            for (const c of collections) await window.__repos.collections.delete(c.id);
        }"""
    )

    page.click('a[href="#/exercises"]')
    page.wait_for_timeout(500)

    _trigger_import_with_json(page, json_str)
    page.wait_for_timeout(500)

    page.locator('[data-testid="import-suffix-input"]').fill("HI")
    page.locator('[data-testid="import-suffix-confirm"]').click()
    page.wait_for_timeout(500)
    page.locator('[data-testid="import-apply"]').click()
    page.wait_for_timeout(1000)
    page.click('[data-testid="import-done"]')
    page.wait_for_timeout(500)

    exercise_title = page.evaluate(
        """async () => {
            const exercises = await window.__repos.exercises.getAll();
            return exercises[0]?.title;
        }"""
    )
    combo_title = page.evaluate(
        """async () => {
            const combos = await window.__repos.combos.getAll();
            return combos[0]?.title;
        }"""
    )
    session_title = page.evaluate(
        """async () => {
            const sessions = await window.__repos.sessions.getAll();
            return sessions[0]?.title;
        }"""
    )
    collection_label = page.evaluate(
        """async () => {
            const collections = await window.__repos.collections.getAll();
            return collections[0]?.label;
        }"""
    )

    assert "[HI]" in exercise_title, f"Exercise title should have suffix: {exercise_title}"
    assert "[HI]" in combo_title, f"Combo title should have suffix: {combo_title}"
    assert "[HI]" in session_title, f"Session title should have suffix: {session_title}"
    assert "[HI]" in collection_label, f"Collection label should have suffix: {collection_label}"


def test_same_user_collision_combo(page: Page, app_url: str):
    """Same-user combo collision shows replace/skip options."""
    _go_to(page, app_url, "#/exercises")
    _clean_all(page)

    local_uuid = page.evaluate("async () => await window.__repos.settings.get('user_uuid')")
    ex_id = _create_exercise(page, "Deadlifts")
    _create_combo(page, "Power", [ex_id], created_by=local_uuid)

    json_str = _build_full_export_json(page)

    page.click('a[href="#/combos"]')
    page.wait_for_timeout(500)

    _trigger_import_with_json(page, json_str, "combo-import-btn")
    page.wait_for_timeout(500)

    expect(page.locator('[data-testid="collision-group-combos"]')).to_be_visible()


def test_same_user_collision_session(page: Page, app_url: str):
    """Same-user session collision shows replace/skip options."""
    _go_to(page, app_url, "#/exercises")
    _clean_all(page)

    local_uuid = page.evaluate("async () => await window.__repos.settings.get('user_uuid')")
    ex_id = _create_exercise(page, "Lunges")
    _create_session(
        page, "Leg Day", [{"item_id": ex_id, "item_type": "exercise", "repetitions": 12, "weight": 0}],
        created_by=local_uuid,
    )

    json_str = _build_full_export_json(page)

    page.click('a[href="#/sessions"]')
    page.wait_for_timeout(500)

    _trigger_import_with_json(page, json_str, "session-import-btn")
    page.wait_for_timeout(500)

    expect(page.locator('[data-testid="collision-group-sessions"]')).to_be_visible()


def test_same_user_collision_collection(page: Page, app_url: str):
    """Same-user collection collision shows replace/skip options."""
    _go_to(page, app_url, "#/exercises")
    _clean_all(page)

    local_uuid = page.evaluate("async () => await window.__repos.settings.get('user_uuid')")
    ex_id = _create_exercise(page, "Plank")
    session_id = _create_session(
        page, "Core", [{"item_id": ex_id, "item_type": "exercise", "repetitions": 3, "weight": 0}],
        created_by=local_uuid,
    )
    _create_collection(page, "Core Week", [session_id], created_by=local_uuid)

    json_str = _build_full_export_json(page)

    page.click('a[href="#/collections"]')
    page.wait_for_timeout(500)

    _trigger_import_with_json(page, json_str, "collection-import-btn")
    page.wait_for_timeout(500)

    expect(page.locator('[data-testid="collision-group-collections"]')).to_be_visible()


def test_export_filter_all_enabled(page: Page, app_url: str):
    """All checkboxes are enabled in the export filter dialog."""
    _go_to(page, app_url, "#/settings")
    _clean_all(page)

    page.click('a[href="#/settings"]')
    page.wait_for_timeout(500)
    page.click('[data-testid="settings-export"]')
    page.wait_for_timeout(500)

    for test_id in [
        "export-filter-exercises",
        "export-filter-combos",
        "export-filter-sessions",
        "export-filter-collections",
        "export-filter-execution-logs",
        "export-filter-settings",
    ]:
        checkbox = page.locator(f'[data-testid="{test_id}"] input')
        expect(checkbox).to_be_enabled()

    page.click('[data-testid="export-filter-cancel"]')


def test_import_combo_list_view(page: Page, app_url: str):
    """Import button is visible on combo list view."""
    _go_to(page, app_url, "#/combos")
    expect(page.locator('[data-testid="combo-import-btn"]')).to_be_visible()
    expect(page.locator('[data-testid="combo-export-btn"]')).to_be_visible()


def test_import_session_list_view(page: Page, app_url: str):
    """Import button is visible on session list view."""
    _go_to(page, app_url, "#/sessions")
    expect(page.locator('[data-testid="session-import-btn"]')).to_be_visible()
    expect(page.locator('[data-testid="session-export-btn"]')).to_be_visible()


def test_import_collection_list_view(page: Page, app_url: str):
    """Import button is visible on collection list view."""
    _go_to(page, app_url, "#/collections")
    expect(page.locator('[data-testid="collection-import-btn"]')).to_be_visible()
    expect(page.locator('[data-testid="collection-export-btn"]')).to_be_visible()


def test_full_export_from_settings(page: Page, app_url: str):
    """Full export button in settings is visible."""
    _go_to(page, app_url, "#/settings")
    _clean_all(page)
    _create_exercise(page, "Test Exercise")

    page.click('a[href="#/settings"]')
    page.wait_for_timeout(500)
    expect(page.locator('[data-testid="settings-export-all"]')).to_be_visible()

    # Clean up so subsequent tests (chromium variants) start with an empty DB.
    _clean_all(page)


def test_import_as_mine_combo(page: Page, app_url: str):
    """Import-as-mine button is visible on combo list."""
    _go_to(page, app_url, "#/combos")
    expect(page.locator('[data-testid="combo-import-as-mine-btn"]')).to_be_visible()


def test_import_as_mine_session(page: Page, app_url: str):
    """Import-as-mine button is visible on session list."""
    _go_to(page, app_url, "#/sessions")
    expect(page.locator('[data-testid="session-import-as-mine-btn"]')).to_be_visible()


def test_new_id_propagation_to_combo(page: Page, app_url: str):
    """When a different-user exercise collides with a local exercise (same ID, different created_by),
    a new UUID is generated and the combo's exercise FK is remapped to that new UUID."""
    _go_to(page, app_url, "#/exercises")
    _clean_all(page)

    # Create a local exercise with a known, fixed UUID owned by the local user.
    # This will collide with the imported exercise below (same ID, different created_by).
    conflicting_id = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
    _create_exercise(page, "Local Exercise", exercise_id=conflicting_id)

    other_uuid = "other-user-uuid-collision-999"
    combo_id = "11111111-2222-3333-4444-555555555555"

    import_json = _load_fixture("cross_user_id_collision.json")

    _trigger_import_with_json(page, import_json)
    page.wait_for_timeout(500)

    page.locator('[data-testid="import-suffix-input"]').fill("OT")
    page.locator('[data-testid="import-suffix-confirm"]').click()
    page.wait_for_timeout(500)

    page.locator('[data-testid="import-apply"]').click()
    expect(page.locator('[data-testid="import-report-step"]')).to_be_visible(timeout=15000)
    page.click('[data-testid="import-done"]')
    page.wait_for_timeout(500)

    result = page.evaluate(
        """async ([conflictingId, otherUuid]) => {
            const allExercises = await window.__repos.exercises.getAll();
            const localEx = allExercises.find(e => e.id === conflictingId);
            const importedEx = allExercises.find(e => e.created_by === otherUuid);

            const allCombos = await window.__repos.combos.getAllWithExercises();
            const importedCombo = allCombos.find(c => c.created_by === otherUuid);

            return {
                localExExists: !!localEx,
                importedExId: importedEx?.id ?? null,
                comboExerciseRef: importedCombo?.exercises?.[0]?.exercise_id ?? null,
            };
        }""",
        [conflicting_id, other_uuid],
    )

    assert result["localExExists"], "Local exercise must still exist with its original ID"
    assert result["importedExId"] is not None, "Imported exercise must exist"
    assert result["importedExId"] != conflicting_id, (
        f"Imported exercise must have a new UUID, not the conflicting one ({conflicting_id})"
    )
    assert result["comboExerciseRef"] == result["importedExId"], (
        f"Combo exercise FK ({result['comboExerciseRef']}) must point to the "
        f"new exercise UUID ({result['importedExId']}), not the original conflicting ID"
    )


# ---------------------------------------------------------------------------
# New coverage tests added to complete all import/export paths
# ---------------------------------------------------------------------------

FIXTURE_USER_UUID = "facade00-0000-4000-a000-000000000001"
FIXTURE_EX1_ID = "facade00-0000-4000-a000-e00000000001"
FIXTURE_COMBO_ID = "facade00-0000-4000-a000-c00000000001"
FIXTURE_SESSION_ID = "facade00-0000-4000-a000-500000000001"
FIXTURE_LOG_ID = "facade00-0000-4000-a000-100000000001"


def test_execution_logs_same_user_roundtrip(page: Page, app_url: str):
    """Execution log (with one item) survives a same-user export → clear → reimport cycle."""
    _go_to(page, app_url, "#/exercises")
    _clean_all(page)

    ex_id = _create_exercise(page, "Log Roundtrip Exercise")
    session_id = _create_session(
        page,
        "Log Roundtrip Session",
        [{"item_id": ex_id, "item_type": "exercise", "repetitions": 10, "weight": 0}],
    )
    _create_execution_log(page, session_id, ex_id)

    json_str = _build_full_export_json(page)

    # Delete everything — execution_log cascades from session delete
    page.evaluate(
        """async () => {
            const exercises = await window.__repos.exercises.getAll();
            for (const ex of exercises) await window.__repos.exercises.delete(ex.id);
            const sessions = await window.__repos.sessions.getAll();
            for (const s of sessions) await window.__repos.sessions.delete(s.id);
        }"""
    )

    page.click('a[href="#/exercises"]')
    page.wait_for_timeout(500)

    _trigger_import_with_json(page, json_str)
    expect(page.locator('[data-testid="import-apply"]')).to_be_visible(timeout=10000)
    page.locator('[data-testid="import-apply"]').click()
    expect(page.locator('[data-testid="import-report-step"]')).to_be_visible(timeout=15000)
    page.click('[data-testid="import-done"]')
    page.wait_for_timeout(500)

    result = page.evaluate(
        """async () => {
            const logs = await window.__repos.executionLogs.getAllWithItems();
            if (logs.length === 0) return { count: 0 };
            const log = logs[0];
            const sessionExists = !!(await window.__repos.sessions.getById(log.session_id));
            return { count: logs.length, itemCount: log.items.length, sessionExists };
        }"""
    )

    assert result["count"] == 1, f"Expected 1 execution log after reimport, got {result['count']}"
    assert result["itemCount"] == 1, f"Expected 1 log item, got {result['itemCount']}"
    assert result["sessionExists"], "Log session_id must reference an existing session after reimport"


def test_execution_logs_same_user_reimport_without_delete(page: Page, app_url: str):
    """Re-importing a full export without deleting anything reports execution
    logs as skipped (no constraint errors, no duplicate logs)."""
    _go_to(page, app_url, "#/exercises")
    _clean_all(page)

    ex_id = _create_exercise(page, "Reimport Exercise")
    session_id = _create_session(
        page,
        "Reimport Session",
        [{"item_id": ex_id, "item_type": "exercise", "repetitions": 10, "weight": 0}],
    )
    _create_execution_log(page, session_id, ex_id)

    json_str = _build_full_export_json(page)

    # Re-import immediately — everything still exists locally
    _trigger_import_with_json(page, json_str)
    expect(page.locator('[data-testid="import-apply"]')).to_be_visible(timeout=10000)
    page.locator('[data-testid="import-apply"]').click()
    expect(page.locator('[data-testid="import-report-step"]')).to_be_visible(timeout=15000)

    # No constraint errors in the report
    error_count = page.locator('[data-testid^="import-report-error-"]').count()
    assert error_count == 0, f"Expected no import errors, got {error_count}"

    # The colliding log is reported as skipped, not imported
    expect(page.locator('[data-testid="import-report-skipped"]')).to_be_visible()
    assert page.locator('[data-testid="import-report-logs-imported"]').count() == 0, (
        "Existing execution log must be skipped, not re-imported"
    )

    page.click('[data-testid="import-done"]')
    page.wait_for_timeout(500)

    # No duplicate logs in the database
    log_count = page.evaluate("async () => (await window.__repos.executionLogs.getAll()).length")
    assert log_count == 1, f"Expected 1 execution log after reimport, got {log_count}"


def test_execution_log_id_remapping(page: Page, app_url: str):
    """When an exercise gets new_id on collision, the execution log item's exercise_id is remapped."""
    _go_to(page, app_url, "#/exercises")
    _clean_all(page)

    # Collide with fixture's exercise 1: same ID, owned by local user → triggers new_id
    _create_exercise(page, "Local Colliding Exercise", exercise_id=FIXTURE_EX1_ID)

    _trigger_import_with_json(page, _load_fixture("cross_user_full.json"))
    page.wait_for_timeout(500)

    page.locator('[data-testid="import-suffix-input"]').fill("FX")
    page.locator('[data-testid="import-suffix-confirm"]').click()
    page.wait_for_timeout(500)

    page.locator('[data-testid="import-apply"]').click()
    expect(page.locator('[data-testid="import-report-step"]')).to_be_visible(timeout=15000)
    page.click('[data-testid="import-done"]')
    page.wait_for_timeout(500)

    result = page.evaluate(
        """async ([fixedExId, exporterUuid, logId]) => {
            const importedEx = (await window.__repos.exercises.getAll())
                .find(e => e.created_by === exporterUuid && e.id !== fixedExId);
            const log = await window.__repos.executionLogs.getById(logId);
            return {
                importedExId: importedEx?.id ?? null,
                logItemExerciseId: log?.items?.[0]?.exercise_id ?? null,
            };
        }""",
        [FIXTURE_EX1_ID, FIXTURE_USER_UUID, FIXTURE_LOG_ID],
    )

    assert result["importedExId"] is not None, "Imported exercise must exist with a new UUID"
    assert result["importedExId"] != FIXTURE_EX1_ID, (
        f"Imported exercise must have a new UUID, not the colliding one ({FIXTURE_EX1_ID})"
    )
    assert result["logItemExerciseId"] == result["importedExId"], (
        f"Log item exercise_id ({result['logItemExerciseId']}) must be remapped "
        f"to the new exercise UUID ({result['importedExId']})"
    )


def test_session_with_combo_item_remapping(page: Page, app_url: str):
    """Session item with item_type='combo' has its item_id remapped when the combo gets new_id."""
    _go_to(page, app_url, "#/exercises")
    _clean_all(page)

    # Collide with fixture's combo: same ID, owned by local user → triggers new_id for imported combo
    page.evaluate(
        """async () => {
            const uuid = await window.__repos.settings.get('user_uuid');
            await window.__repos.combos.createWithId(
                'facade00-0000-4000-a000-c00000000001',
                { title: 'Local Combo', description: '', type: 'NONE',
                  timer_config: {}, created_by: uuid }
            );
        }"""
    )

    _trigger_import_with_json(page, _load_fixture("cross_user_full.json"))
    page.wait_for_timeout(500)

    page.locator('[data-testid="import-suffix-input"]').fill("FX")
    page.locator('[data-testid="import-suffix-confirm"]').click()
    page.wait_for_timeout(500)

    page.locator('[data-testid="import-apply"]').click()
    expect(page.locator('[data-testid="import-report-step"]')).to_be_visible(timeout=15000)
    page.click('[data-testid="import-done"]')
    page.wait_for_timeout(500)

    result = page.evaluate(
        """async ([fixedComboId, exporterUuid, sessionId]) => {
            const importedCombo = (await window.__repos.combos.getAll())
                .find(c => c.created_by === exporterUuid);
            const sess = await window.__repos.sessions.getById(sessionId);
            const comboItem = sess?.items?.find(i => i.item_type === 'combo');
            return {
                importedComboId: importedCombo?.id ?? null,
                sessionComboItemId: comboItem?.item_id ?? null,
            };
        }""",
        [FIXTURE_COMBO_ID, FIXTURE_USER_UUID, FIXTURE_SESSION_ID],
    )

    assert result["importedComboId"] is not None, "Imported combo must exist with a new UUID"
    assert result["importedComboId"] != FIXTURE_COMBO_ID, (
        f"Imported combo must have a new UUID, not the colliding one ({FIXTURE_COMBO_ID})"
    )
    assert result["sessionComboItemId"] == result["importedComboId"], (
        f"Session combo item_id ({result['sessionComboItemId']}) must be remapped "
        f"to the new combo UUID ({result['importedComboId']})"
    )


def test_collection_session_remapping(page: Page, app_url: str):
    """Collection session reference is remapped when the imported session gets new_id on collision."""
    _go_to(page, app_url, "#/exercises")
    _clean_all(page)

    # Collide with fixture's session: same ID, owned by local user → triggers new_id for imported session
    page.evaluate(
        """async () => {
            const uuid = await window.__repos.settings.get('user_uuid');
            await window.__repos.sessions.createWithId(
                'facade00-0000-4000-a000-500000000001',
                { title: 'Local Session', description: '', created_by: uuid }
            );
        }"""
    )

    _trigger_import_with_json(page, _load_fixture("cross_user_full.json"))
    page.wait_for_timeout(500)

    page.locator('[data-testid="import-suffix-input"]').fill("FX")
    page.locator('[data-testid="import-suffix-confirm"]').click()
    page.wait_for_timeout(500)

    page.locator('[data-testid="import-apply"]').click()
    expect(page.locator('[data-testid="import-report-step"]')).to_be_visible(timeout=15000)
    page.click('[data-testid="import-done"]')
    page.wait_for_timeout(500)

    result = page.evaluate(
        """async (exporterUuid) => {
            const importedSession = (await window.__repos.sessions.getAll())
                .find(s => s.created_by === exporterUuid);
            const allCollections = await window.__repos.collections.getAll();
            const importedColl = allCollections.find(c => c.created_by === exporterUuid);
            const collDetail = importedColl
                ? await window.__repos.collections.getById(importedColl.id)
                : null;
            return {
                importedSessionId: importedSession?.id ?? null,
                collectionSessionRef: collDetail?.sessions?.[0]?.session_id ?? null,
            };
        }""",
        FIXTURE_USER_UUID,
    )

    assert result["importedSessionId"] is not None, "Imported session must exist with a new UUID"
    assert result["importedSessionId"] != FIXTURE_SESSION_ID, (
        f"Imported session must have a new UUID, not the colliding one ({FIXTURE_SESSION_ID})"
    )
    assert result["collectionSessionRef"] == result["importedSessionId"], (
        f"Collection session ref ({result['collectionSessionRef']}) must be remapped "
        f"to the new session UUID ({result['importedSessionId']})"
    )


def test_settings_import_roundtrip(page: Page, app_url: str):
    """Settings values from an import envelope are applied to the database."""
    _go_to(page, app_url, "#/exercises")

    original_lang = page.evaluate(
        "async () => await window.__repos.settings.get('language')"
    )

    # Build a settings-only envelope using the local UUID to avoid the suffix prompt
    json_str = page.evaluate(
        """async () => {
            const uuid = await window.__repos.settings.get('user_uuid');
            return JSON.stringify({
                metadata: {
                    appName: 'trainus', appVersion: '1.0.0', schemaVersion: 1,
                    exportDate: new Date().toISOString(),
                    exportedBy: { name: 'Me', uuid },
                },
                data: { settings: { language: 'fr', theme: 'dark' } },
            });
        }"""
    )

    _trigger_import_with_json(page, json_str)
    expect(page.locator('[data-testid="import-apply"]')).to_be_visible(timeout=10000)
    page.locator('[data-testid="import-apply"]').click()
    expect(page.locator('[data-testid="import-report-step"]')).to_be_visible(timeout=15000)
    page.click('[data-testid="import-done"]')
    page.wait_for_timeout(500)

    lang = page.evaluate("async () => await window.__repos.settings.get('language')")
    theme = page.evaluate("async () => await window.__repos.settings.get('theme')")

    # Restore original settings so subsequent tests are not affected
    page.evaluate(
        """async ([lang]) => {
            await window.__repos.settings.set('language', lang);
            await window.__repos.settings.set('theme', 'light');
        }""",
        [original_lang or "en"],
    )

    assert lang == "fr", f"language should be 'fr' after import, got '{lang}'"
    assert theme == "dark", f"theme should be 'dark' after import, got '{theme}'"


def test_single_entity_export_buttons_session_collection(page: Page, app_url: str):
    """Single-entity export button is visible on both session and collection detail views."""
    _go_to(page, app_url, "#/exercises")
    _clean_all(page)

    ex_id = _create_exercise(page, "Detail Export Exercise")
    session_id = _create_session(
        page,
        "Detail Export Session",
        [{"item_id": ex_id, "item_type": "exercise", "repetitions": 5, "weight": 0}],
    )
    collection_id = _create_collection(page, "Detail Export Collection", [session_id])

    # Session detail
    page.click('a[href="#/sessions"]')
    page.wait_for_timeout(500)
    page.click(f'[data-testid="session-card-{session_id}"]')
    page.wait_for_timeout(500)
    expect(page.locator('[data-testid="session-export-single-btn"]')).to_be_visible()

    # Collection detail
    page.click('a[href="#/collections"]')
    page.wait_for_timeout(500)
    page.click(f'[data-testid="collection-card-{collection_id}"]')
    page.wait_for_timeout(500)
    expect(page.locator('[data-testid="collection-export-single-btn"]')).to_be_visible()


def _export_via_button(page: Page, export_btn_selector: str, tmp_dir):
    """Click an export button, confirm the save-file prompt, and return the parsed JSON."""
    page.click(export_btn_selector)
    page.wait_for_selector('[data-testid="save-file-input"]')
    with page.expect_download() as download_info:
        page.click('[data-testid="save-file-confirm"]')
    out_path = str(tmp_dir / "export.json")
    download_info.value.save_as(out_path)
    with open(out_path, "r", encoding="utf-8") as f:
        return json.load(f)


def test_export_respects_active_search_filter(page: Page, app_url: str, tmp_path_factory):
    """Exporting from a list view while a search filter is active only exports the filtered items."""
    _go_to(page, app_url, "#/exercises")
    _clean_all(page)

    ex_a = _create_exercise(page, "Squats")
    ex_b = _create_exercise(page, "Burpees")
    _create_combo(page, "Squat Combo", [ex_a])
    _create_combo(page, "Burpee Combo", [ex_b])
    session_a = _create_session(
        page, "Squat Session", [{"item_id": ex_a, "item_type": "exercise"}]
    )
    _create_session(page, "Burpee Session", [{"item_id": ex_b, "item_type": "exercise"}])
    _create_collection(page, "Squat Week", [session_a])
    _create_collection(page, "Burpee Week", [])

    tmp_dir = tmp_path_factory.mktemp("filtered_export")

    page.click('a[href="#/exercises"]')
    page.wait_for_timeout(300)
    page.click('[data-testid="exercise-search-toggle"]')
    page.fill('[data-testid="exercise-search-input"]', "Squat")
    page.wait_for_timeout(400)
    data = _export_via_button(page, '[data-testid="exercise-export-btn"]', tmp_dir)
    titles = [e["title"] for e in data["data"]["exercises"]]
    assert titles == ["Squats"], f"Expected only the filtered exercise, got: {titles}"

    page.click('a[href="#/combos"]')
    page.wait_for_timeout(300)
    page.click('[data-testid="combo-search-toggle"]')
    page.fill('[data-testid="combo-search-input"]', "Squat")
    page.wait_for_timeout(400)
    data = _export_via_button(page, '[data-testid="combo-export-btn"]', tmp_dir)
    titles = [c["title"] for c in data["data"]["combos"]]
    assert titles == ["Squat Combo"], f"Expected only the filtered combo, got: {titles}"

    page.click('a[href="#/sessions"]')
    page.wait_for_timeout(300)
    page.click('[data-testid="session-search-toggle"]')
    page.fill('[data-testid="session-search-input"]', "Squat")
    page.wait_for_timeout(400)
    data = _export_via_button(page, '[data-testid="session-export-btn"]', tmp_dir)
    titles = [s["title"] for s in data["data"]["sessions"]]
    assert titles == ["Squat Session"], f"Expected only the filtered session, got: {titles}"

    page.click('a[href="#/collections"]')
    page.wait_for_timeout(300)
    page.click('[data-testid="collection-search-toggle"]')
    page.fill('[data-testid="collection-search-input"]', "Squat")
    page.wait_for_timeout(400)
    data = _export_via_button(page, '[data-testid="collection-export-btn"]', tmp_dir)
    labels = [c["label"] for c in data["data"]["collections"]]
    assert labels == ["Squat Week"], f"Expected only the filtered collection, got: {labels}"


def test_single_export_bundles_dependencies(page: Page, app_url: str, tmp_path_factory):
    """Exporting a combo/session/collection bundles the exercises/combos/sessions it depends on."""
    _go_to(page, app_url, "#/exercises")
    _clean_all(page)

    ex_a = _create_exercise(page, "Squats")
    ex_b = _create_exercise(page, "Lunges")
    combo_id = _create_combo(page, "Leg Combo", [ex_a, ex_b])
    session_id = _create_session(
        page,
        "Leg Day",
        [
            {"item_id": ex_a, "item_type": "exercise"},
            {"item_id": combo_id, "item_type": "combo"},
        ],
    )
    collection_id = _create_collection(page, "Leg Week", [session_id])

    tmp_dir = tmp_path_factory.mktemp("dependency_export")

    page.click('a[href="#/combos"]')
    page.wait_for_timeout(500)
    page.click(f'[data-testid="combo-card-{combo_id}"]')
    page.wait_for_timeout(500)
    data = _export_via_button(page, '[data-testid="combo-export-single-btn"]', tmp_dir)
    exercise_titles = {e["title"] for e in data["data"].get("exercises", [])}
    assert exercise_titles == {"Squats", "Lunges"}, (
        f"Combo export should bundle its exercises, got: {exercise_titles}"
    )

    page.click('a[href="#/sessions"]')
    page.wait_for_timeout(500)
    page.click(f'[data-testid="session-card-{session_id}"]')
    page.wait_for_timeout(500)
    data = _export_via_button(page, '[data-testid="session-export-single-btn"]', tmp_dir)
    exercise_titles = {e["title"] for e in data["data"].get("exercises", [])}
    combo_titles = {c["title"] for c in data["data"].get("combos", [])}
    assert exercise_titles == {"Squats", "Lunges"}, (
        f"Session export should bundle all referenced exercises, got: {exercise_titles}"
    )
    assert combo_titles == {"Leg Combo"}, (
        f"Session export should bundle its referenced combo, got: {combo_titles}"
    )

    page.click('a[href="#/collections"]')
    page.wait_for_timeout(500)
    page.click(f'[data-testid="collection-card-{collection_id}"]')
    page.wait_for_timeout(500)
    data = _export_via_button(page, '[data-testid="collection-export-single-btn"]', tmp_dir)
    session_titles = {s["title"] for s in data["data"].get("sessions", [])}
    combo_titles = {c["title"] for c in data["data"].get("combos", [])}
    exercise_titles = {e["title"] for e in data["data"].get("exercises", [])}
    assert session_titles == {"Leg Day"}, (
        f"Collection export should bundle its session, got: {session_titles}"
    )
    assert combo_titles == {"Leg Combo"}, (
        f"Collection export should bundle the session's combo, got: {combo_titles}"
    )
    assert exercise_titles == {"Squats", "Lunges"}, (
        f"Collection export should bundle all transitive exercises, got: {exercise_titles}"
    )


def test_settings_export_filter_bundles_dependencies(page: Page, app_url: str, tmp_path_factory):
    """Settings export filter dialog bundles dependencies even when only sessions is selected."""
    _go_to(page, app_url, "#/exercises")
    _clean_all(page)

    ex_a = _create_exercise(page, "Squats")
    combo_id = _create_combo(page, "Leg Combo", [ex_a])
    _create_session(
        page,
        "Leg Day",
        [{"item_id": combo_id, "item_type": "combo"}],
    )

    tmp_dir = tmp_path_factory.mktemp("settings_filter_export")

    page.click('a[href="#/settings"]')
    page.wait_for_timeout(500)
    page.click('[data-testid="settings-export"]')
    page.wait_for_selector('[data-testid="export-filter-sessions"]')

    # Uncheck everything except sessions
    for testid in ["export-filter-exercises", "export-filter-combos", "export-filter-collections"]:
        page.locator(f'[data-testid="{testid}"] input[type="checkbox"]').uncheck()

    page.click('[data-testid="export-filter-confirm"]')
    page.wait_for_selector('[data-testid="save-file-input"]')
    with page.expect_download() as download_info:
        page.click('[data-testid="save-file-confirm"]')
    out_path = str(tmp_dir / "export.json")
    download_info.value.save_as(out_path)
    with open(out_path, "r", encoding="utf-8") as f:
        data = json.load(f)

    exercise_titles = {e["title"] for e in data["data"].get("exercises", [])}
    combo_titles = {c["title"] for c in data["data"].get("combos", [])}
    assert combo_titles == {"Leg Combo"}, (
        f"Sessions-only export should bundle the referenced combo, got: {combo_titles}"
    )
    assert exercise_titles == {"Squats"}, (
        f"Sessions-only export should bundle the transitively referenced exercise, got: {exercise_titles}"
    )
