/// SQLite repository implementations backed by [SchedulerDb].
library;

import 'dart:convert';

import 'package:drift/drift.dart' as drift;
import 'package:scheduler_core/scheduler_core.dart' as core;
import 'package:scheduler_persistence/persistence.dart';

import 'scheduler_db.dart';

const _snapshotRetention = Duration(days: 30);

final _epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);

/// Drift-backed task repository.
final class SqliteTaskRepository implements TaskRepository {
  /// Creates a task repository using [db].
  SqliteTaskRepository(this.db);

  /// Shared SQLite database handle.
  final SchedulerDb db;

  @override
  Future<RepoResult<core.Task?>> findById({
    required core.OwnerId ownerId,
    required String taskId,
  }) async {
    final row = await _taskById(taskId);
    if (row == null || row.ownerId != ownerId.value) {
      return const Right(null);
    }
    return Right(_taskFromRow(row));
  }

  @override
  Future<RepoResult<core.Page<core.Task>>> findByOwner({
    required core.OwnerId ownerId,
    core.PageRequest page = const core.PageRequest(),
  }) async {
    return Right(await _pageTaskRows(
      page: page,
      where: (table) => table.ownerId.equals(ownerId.value),
    ));
  }

  @override
  Future<RepoResult<core.Page<core.Task>>> findByParentTaskId({
    required core.OwnerId ownerId,
    required String parentTaskId,
    core.PageRequest page = const core.PageRequest(),
  }) async {
    return Right(await _pageTaskRows(
      page: page,
      where: (table) =>
          table.ownerId.equals(ownerId.value) &
          table.parentId.equals(parentTaskId),
    ));
  }

  @override
  Future<RepoResult<core.Page<core.Task>>> findByProjectId({
    required core.OwnerId ownerId,
    required String projectId,
    core.PageRequest page = const core.PageRequest(),
  }) async {
    return Right(await _pageTaskRows(
      page: page,
      where: (table) =>
          table.ownerId.equals(ownerId.value) &
          table.projectId.equals(projectId),
    ));
  }

  @override
  Future<RepoResult<core.Page<core.Task>>> findByStatus({
    required core.OwnerId ownerId,
    required core.TaskStatus status,
    core.PageRequest page = const core.PageRequest(),
  }) async {
    return Right(await _pageTaskRows(
      page: page,
      where: (table) =>
          table.ownerId.equals(ownerId.value) &
          table.status.equals(core.PersistenceEnumMapping.encodeTaskStatus(
            status,
          )),
    ));
  }

  @override
  Future<RepoResult<core.Revision>> insert({
    required core.OwnerId ownerId,
    required core.Task task,
  }) async {
    if (await _taskById(task.id) != null) {
      return Left(RepositoryDuplicateId(entityId: task.id));
    }
    await db.into(db.tasks).insert(_taskCompanion(
          task,
          ownerId: ownerId,
          revision: core.Revision.initial,
        ));
    return const Right(core.Revision.initial);
  }

  @override
  Future<RepoResult<core.Revision>> save({
    required core.OwnerId ownerId,
    required core.Task task,
    required core.Revision expectedRevision,
  }) async {
    final checked = await _checkTaskWrite(
      task.id,
      ownerId,
      expectedRevision,
    );
    if (checked is Left<RepositoryFailure, TaskRow>) {
      return Left(checked.value);
    }
    final nextRevision = checked.right.revision.next();
    final updates = _taskCompanion(
      task,
      ownerId: ownerId,
      revision: nextRevision,
    );
    final updated = await (db.update(db.tasks)
          ..where(
            (table) =>
                table.id.equals(task.id) &
                table.ownerId.equals(ownerId.value) &
                table.revision.equals(expectedRevision.value),
          ))
        .write(updates);
    if (updated != 1) {
      return Left(await _staleTaskFailure(task.id, expectedRevision));
    }
    return Right(nextRevision);
  }

  @override
  Future<RepoResult<core.Revision>> delete({
    required core.OwnerId ownerId,
    required String taskId,
    required core.Revision expectedRevision,
  }) async {
    final checked = await _checkTaskWrite(taskId, ownerId, expectedRevision);
    if (checked is Left<RepositoryFailure, TaskRow>) {
      return Left(checked.value);
    }
    final nextRevision = checked.right.revision.next();
    final deleted = await (db.delete(db.tasks)
          ..where(
            (table) =>
                table.id.equals(taskId) &
                table.ownerId.equals(ownerId.value) &
                table.revision.equals(expectedRevision.value),
          ))
        .go();
    if (deleted != 1) {
      return Left(await _staleTaskFailure(taskId, expectedRevision));
    }
    return Right(nextRevision);
  }

  Future<core.Page<core.Task>> _pageTaskRows({
    required core.PageRequest page,
    required drift.Expression<bool> Function($TasksTable table) where,
  }) async {
    final offset = _cursorOffset(page.cursor);
    final query = db.select(db.tasks)
      ..where(where)
      ..orderBy([(table) => drift.OrderingTerm.asc(table.id)])
      ..limit(page.limit + 1, offset: offset);
    final rows = await query.get();
    final pageRows = rows.take(page.limit).toList(growable: false);
    return core.Page(
      items: pageRows.map(_taskFromRow),
      nextCursor: rows.length > page.limit ? '${offset + page.limit}' : null,
    );
  }

  Future<TaskRow?> _taskById(String id) {
    return (db.select(db.tasks)..where((table) => table.id.equals(id)))
        .getSingleOrNull();
  }

  Future<Either<RepositoryFailure, TaskRow>> _checkTaskWrite(
    String id,
    core.OwnerId ownerId,
    core.Revision expectedRevision,
  ) async {
    if (expectedRevision.value <= 0) {
      return Left(RepositoryInvalidRevision(
        entityId: id,
        expectedRevision: expectedRevision,
      ));
    }
    final row = await _taskById(id);
    if (row == null) {
      return Left(RepositoryNotFound(entityId: id));
    }
    if (row.ownerId != ownerId.value) {
      return Left(RepositoryOwnerMismatch(entityId: id));
    }
    if (row.revision != expectedRevision.value) {
      return Left(RepositoryStaleRevision(
        entityId: id,
        expectedRevision: expectedRevision,
        actualRevision: core.Revision(row.revision),
      ));
    }
    return Right(row);
  }

  Future<RepositoryStaleRevision> _staleTaskFailure(
    String id,
    core.Revision expectedRevision,
  ) async {
    final row = await _taskById(id);
    return RepositoryStaleRevision(
      entityId: id,
      expectedRevision: expectedRevision,
      actualRevision:
          row == null ? expectedRevision.next() : core.Revision(row.revision),
    );
  }
}

/// Drift-backed project repository.
final class SqliteProjectRepository implements ProjectRepository {
  /// Creates a project repository using [db].
  SqliteProjectRepository(this.db);

  /// Shared SQLite database handle.
  final SchedulerDb db;

  @override
  Future<RepoResult<core.ProjectProfile?>> findById({
    required core.OwnerId ownerId,
    required String projectId,
  }) async {
    final row = await _projectById(projectId);
    if (row == null || row.ownerId != ownerId.value) {
      return const Right(null);
    }
    return Right(_projectFromRow(row));
  }

  @override
  Future<RepoResult<core.Page<core.ProjectProfile>>> findByOwner({
    required core.OwnerId ownerId,
    bool includeArchived = false,
    core.PageRequest page = const core.PageRequest(),
  }) async {
    final offset = _cursorOffset(page.cursor);
    final query = db.select(db.projects)
      ..where((table) {
        final ownerClause = table.ownerId.equals(ownerId.value);
        return includeArchived
            ? ownerClause
            : ownerClause & table.archivedAtUtc.isNull();
      })
      ..orderBy([(table) => drift.OrderingTerm.asc(table.id)])
      ..limit(page.limit + 1, offset: offset);
    final rows = await query.get();
    final pageRows = rows.take(page.limit).toList(growable: false);
    return Right(core.Page(
      items: pageRows.map(_projectFromRow),
      nextCursor: rows.length > page.limit ? '${offset + page.limit}' : null,
    ));
  }

  @override
  Future<RepoResult<core.Revision>> insert({
    required core.OwnerId ownerId,
    required core.ProjectProfile project,
  }) async {
    if (await _projectById(project.id) != null) {
      return Left(RepositoryDuplicateId(entityId: project.id));
    }
    await db.into(db.projects).insert(_projectCompanion(
          project,
          ownerId: ownerId,
          revision: core.Revision.initial,
          createdAt: _epoch,
          updatedAt: _epoch,
        ));
    return const Right(core.Revision.initial);
  }

  @override
  Future<RepoResult<core.Revision>> save({
    required core.OwnerId ownerId,
    required core.ProjectProfile project,
    required core.Revision expectedRevision,
  }) async {
    final checked = await _checkProjectWrite(
      project.id,
      ownerId,
      expectedRevision,
    );
    if (checked is Left<RepositoryFailure, ProjectRow>) {
      return Left(checked.value);
    }
    final row = checked.right;
    final nextRevision = core.Revision(row.revision).next();
    final updated = await (db.update(db.projects)
          ..where(
            (table) =>
                table.id.equals(project.id) &
                table.ownerId.equals(ownerId.value) &
                table.revision.equals(expectedRevision.value),
          ))
        .write(_projectCompanion(
      project,
      ownerId: ownerId,
      revision: nextRevision,
      createdAt: row.createdAtUtc,
      updatedAt: _epoch,
    ));
    if (updated != 1) {
      return Left(await _staleProjectFailure(project.id, expectedRevision));
    }
    return Right(nextRevision);
  }

  @override
  Future<RepoResult<core.Revision>> archive({
    required core.OwnerId ownerId,
    required String projectId,
    required core.Revision expectedRevision,
    required DateTime archivedAtUtc,
  }) async {
    final checked = await _checkProjectWrite(
      projectId,
      ownerId,
      expectedRevision,
    );
    if (checked is Left<RepositoryFailure, ProjectRow>) {
      return Left(checked.value);
    }
    final row = checked.right;
    final nextRevision = core.Revision(row.revision).next();
    final archived = _projectFromRow(row).copyWith(archivedAt: archivedAtUtc);
    final updated = await (db.update(db.projects)
          ..where(
            (table) =>
                table.id.equals(projectId) &
                table.ownerId.equals(ownerId.value) &
                table.revision.equals(expectedRevision.value),
          ))
        .write(_projectCompanion(
      archived,
      ownerId: ownerId,
      revision: nextRevision,
      createdAt: row.createdAtUtc,
      updatedAt: archivedAtUtc,
    ));
    if (updated != 1) {
      return Left(await _staleProjectFailure(projectId, expectedRevision));
    }
    return Right(nextRevision);
  }

  Future<ProjectRow?> _projectById(String id) {
    return (db.select(db.projects)..where((table) => table.id.equals(id)))
        .getSingleOrNull();
  }

  Future<Either<RepositoryFailure, ProjectRow>> _checkProjectWrite(
    String id,
    core.OwnerId ownerId,
    core.Revision expectedRevision,
  ) async {
    if (expectedRevision.value <= 0) {
      return Left(RepositoryInvalidRevision(
        entityId: id,
        expectedRevision: expectedRevision,
      ));
    }
    final row = await _projectById(id);
    if (row == null) {
      return Left(RepositoryNotFound(entityId: id));
    }
    if (row.ownerId != ownerId.value) {
      return Left(RepositoryOwnerMismatch(entityId: id));
    }
    if (row.revision != expectedRevision.value) {
      return Left(RepositoryStaleRevision(
        entityId: id,
        expectedRevision: expectedRevision,
        actualRevision: core.Revision(row.revision),
      ));
    }
    return Right(row);
  }

  Future<RepositoryStaleRevision> _staleProjectFailure(
    String id,
    core.Revision expectedRevision,
  ) async {
    final row = await _projectById(id);
    return RepositoryStaleRevision(
      entityId: id,
      expectedRevision: expectedRevision,
      actualRevision:
          row == null ? expectedRevision.next() : core.Revision(row.revision),
    );
  }
}

/// Drift-backed locked-block and override repository.
final class SqliteLockedBlockRepository implements LockedBlockRepository {
  /// Creates a locked-time repository using [db].
  SqliteLockedBlockRepository(this.db);

  /// Shared SQLite database handle.
  final SchedulerDb db;

  @override
  Future<RepoResult<core.LockedBlock?>> findBlockById({
    required core.OwnerId ownerId,
    required String blockId,
  }) async {
    final row = await _blockById(blockId);
    if (row == null || row.ownerId != ownerId.value) {
      return const Right(null);
    }
    return Right(_lockedBlockFromRow(row));
  }

  @override
  Future<RepoResult<core.Page<core.LockedBlock>>> findBlocksByOwner({
    required core.OwnerId ownerId,
    bool includeArchived = false,
    core.PageRequest page = const core.PageRequest(),
  }) async {
    final offset = _cursorOffset(page.cursor);
    final query = db.select(db.lockedBlocks)
      ..where((table) {
        final ownerClause = table.ownerId.equals(ownerId.value);
        return includeArchived
            ? ownerClause
            : ownerClause & table.archivedAtUtc.isNull();
      })
      ..orderBy([(table) => drift.OrderingTerm.asc(table.id)])
      ..limit(page.limit + 1, offset: offset);
    final rows = await query.get();
    final pageRows = rows.take(page.limit).toList(growable: false);
    return Right(core.Page(
      items: pageRows.map(_lockedBlockFromRow),
      nextCursor: rows.length > page.limit ? '${offset + page.limit}' : null,
    ));
  }

  @override
  Future<RepoResult<core.LockedBlockOverride?>> findOverrideById({
    required core.OwnerId ownerId,
    required String overrideId,
  }) async {
    final row = await _overrideById(overrideId);
    if (row == null || row.ownerId != ownerId.value) {
      return const Right(null);
    }
    return Right(_lockedOverrideFromRow(row));
  }

  @override
  Future<RepoResult<core.Page<core.LockedBlockOverride>>> findOverridesForDate({
    required core.OwnerId ownerId,
    required core.CivilDate date,
    core.PageRequest page = const core.PageRequest(),
  }) async {
    final offset = _cursorOffset(page.cursor);
    final query = db.select(db.lockedOverrides)
      ..where(
        (table) =>
            table.ownerId.equals(ownerId.value) &
            table.date.equals(date.toIsoString()),
      )
      ..orderBy([(table) => drift.OrderingTerm.asc(table.id)])
      ..limit(page.limit + 1, offset: offset);
    final rows = await query.get();
    final pageRows = rows.take(page.limit).toList(growable: false);
    return Right(core.Page(
      items: pageRows.map(_lockedOverrideFromRow),
      nextCursor: rows.length > page.limit ? '${offset + page.limit}' : null,
    ));
  }

  @override
  Future<RepoResult<core.Revision>> insertBlock({
    required core.OwnerId ownerId,
    required core.LockedBlock block,
  }) async {
    if (await _blockById(block.id) != null) {
      return Left(RepositoryDuplicateId(entityId: block.id));
    }
    await db.into(db.lockedBlocks).insert(_lockedBlockCompanion(
          block,
          ownerId: ownerId,
          revision: core.Revision.initial,
        ));
    return const Right(core.Revision.initial);
  }

  @override
  Future<RepoResult<core.Revision>> saveBlock({
    required core.OwnerId ownerId,
    required core.LockedBlock block,
    required core.Revision expectedRevision,
  }) async {
    final checked = await _checkBlockWrite(
      block.id,
      ownerId,
      expectedRevision,
    );
    if (checked is Left<RepositoryFailure, LockedBlockRow>) {
      return Left(checked.value);
    }
    final nextRevision = core.Revision(checked.right.revision).next();
    final updated = await (db.update(db.lockedBlocks)
          ..where(
            (table) =>
                table.id.equals(block.id) &
                table.ownerId.equals(ownerId.value) &
                table.revision.equals(expectedRevision.value),
          ))
        .write(_lockedBlockCompanion(
      block,
      ownerId: ownerId,
      revision: nextRevision,
    ));
    if (updated != 1) {
      return Left(await _staleBlockFailure(block.id, expectedRevision));
    }
    return Right(nextRevision);
  }

  @override
  Future<RepoResult<core.Revision>> archiveBlock({
    required core.OwnerId ownerId,
    required String blockId,
    required core.Revision expectedRevision,
    required DateTime archivedAtUtc,
  }) async {
    final checked = await _checkBlockWrite(blockId, ownerId, expectedRevision);
    if (checked is Left<RepositoryFailure, LockedBlockRow>) {
      return Left(checked.value);
    }
    final row = checked.right;
    final nextRevision = core.Revision(row.revision).next();
    final archived =
        _lockedBlockFromRow(row).copyWith(archivedAt: archivedAtUtc);
    final updated = await (db.update(db.lockedBlocks)
          ..where(
            (table) =>
                table.id.equals(blockId) &
                table.ownerId.equals(ownerId.value) &
                table.revision.equals(expectedRevision.value),
          ))
        .write(_lockedBlockCompanion(
      archived,
      ownerId: ownerId,
      revision: nextRevision,
    ));
    if (updated != 1) {
      return Left(await _staleBlockFailure(blockId, expectedRevision));
    }
    return Right(nextRevision);
  }

  @override
  Future<RepoResult<core.Revision>> insertOverride({
    required core.OwnerId ownerId,
    required core.LockedBlockOverride override,
  }) async {
    if (await _overrideById(override.id) != null) {
      return Left(RepositoryDuplicateId(entityId: override.id));
    }
    await db.into(db.lockedOverrides).insert(_lockedOverrideCompanion(
          override,
          ownerId: ownerId,
          revision: core.Revision.initial,
        ));
    return const Right(core.Revision.initial);
  }

  @override
  Future<RepoResult<core.Revision>> saveOverride({
    required core.OwnerId ownerId,
    required core.LockedBlockOverride override,
    required core.Revision expectedRevision,
  }) async {
    final checked = await _checkOverrideWrite(
      override.id,
      ownerId,
      expectedRevision,
    );
    if (checked is Left<RepositoryFailure, LockedOverrideRow>) {
      return Left(checked.value);
    }
    final nextRevision = core.Revision(checked.right.revision).next();
    final updated = await (db.update(db.lockedOverrides)
          ..where(
            (table) =>
                table.id.equals(override.id) &
                table.ownerId.equals(ownerId.value) &
                table.revision.equals(expectedRevision.value),
          ))
        .write(_lockedOverrideCompanion(
      override,
      ownerId: ownerId,
      revision: nextRevision,
    ));
    if (updated != 1) {
      return Left(await _staleOverrideFailure(
        override.id,
        expectedRevision,
      ));
    }
    return Right(nextRevision);
  }

  Future<LockedBlockRow?> _blockById(String id) {
    return (db.select(db.lockedBlocks)..where((table) => table.id.equals(id)))
        .getSingleOrNull();
  }

  Future<LockedOverrideRow?> _overrideById(String id) {
    return (db.select(db.lockedOverrides)
          ..where((table) => table.id.equals(id)))
        .getSingleOrNull();
  }

  Future<Either<RepositoryFailure, LockedBlockRow>> _checkBlockWrite(
    String id,
    core.OwnerId ownerId,
    core.Revision expectedRevision,
  ) async {
    if (expectedRevision.value <= 0) {
      return Left(RepositoryInvalidRevision(
        entityId: id,
        expectedRevision: expectedRevision,
      ));
    }
    final row = await _blockById(id);
    if (row == null) return Left(RepositoryNotFound(entityId: id));
    if (row.ownerId != ownerId.value) {
      return Left(RepositoryOwnerMismatch(entityId: id));
    }
    if (row.revision != expectedRevision.value) {
      return Left(RepositoryStaleRevision(
        entityId: id,
        expectedRevision: expectedRevision,
        actualRevision: core.Revision(row.revision),
      ));
    }
    return Right(row);
  }

  Future<Either<RepositoryFailure, LockedOverrideRow>> _checkOverrideWrite(
    String id,
    core.OwnerId ownerId,
    core.Revision expectedRevision,
  ) async {
    if (expectedRevision.value <= 0) {
      return Left(RepositoryInvalidRevision(
        entityId: id,
        expectedRevision: expectedRevision,
      ));
    }
    final row = await _overrideById(id);
    if (row == null) return Left(RepositoryNotFound(entityId: id));
    if (row.ownerId != ownerId.value) {
      return Left(RepositoryOwnerMismatch(entityId: id));
    }
    if (row.revision != expectedRevision.value) {
      return Left(RepositoryStaleRevision(
        entityId: id,
        expectedRevision: expectedRevision,
        actualRevision: core.Revision(row.revision),
      ));
    }
    return Right(row);
  }

  Future<RepositoryStaleRevision> _staleBlockFailure(
    String id,
    core.Revision expectedRevision,
  ) async {
    final row = await _blockById(id);
    return RepositoryStaleRevision(
      entityId: id,
      expectedRevision: expectedRevision,
      actualRevision:
          row == null ? expectedRevision.next() : core.Revision(row.revision),
    );
  }

  Future<RepositoryStaleRevision> _staleOverrideFailure(
    String id,
    core.Revision expectedRevision,
  ) async {
    final row = await _overrideById(id);
    return RepositoryStaleRevision(
      entityId: id,
      expectedRevision: expectedRevision,
      actualRevision:
          row == null ? expectedRevision.next() : core.Revision(row.revision),
    );
  }
}

/// Drift-backed owner settings repository.
final class SqliteSettingsRepository implements SettingsRepository {
  /// Creates a settings repository using [db].
  SqliteSettingsRepository(this.db);

  /// Shared SQLite database handle.
  final SchedulerDb db;

  @override
  Future<RepoResult<core.OwnerSettings?>> findByOwner({
    required core.OwnerId ownerId,
  }) async {
    final row = await _settingsByOwner(ownerId.value);
    if (row == null) return const Right(null);
    return Right(_settingsFromRow(row));
  }

  @override
  Future<RepoResult<core.Revision>> insert({
    required core.OwnerId ownerId,
    required core.OwnerSettings settings,
  }) async {
    if (await _settingsByOwner(ownerId.value) != null) {
      return Left(RepositoryDuplicateId(entityId: ownerId.value));
    }
    final ownedSettings = settings.ownerId == ownerId.value
        ? settings
        : settings.copyWith(ownerId: ownerId.value);
    await db.into(db.settingsTable).insert(_settingsCompanion(
          ownedSettings,
          revision: core.Revision.initial,
          createdAt: _epoch,
          updatedAt: _epoch,
        ));
    return const Right(core.Revision.initial);
  }

  @override
  Future<RepoResult<core.Revision>> save({
    required core.OwnerId ownerId,
    required core.OwnerSettings settings,
    required core.Revision expectedRevision,
  }) async {
    final checked = await _checkSettingsWrite(ownerId, expectedRevision);
    if (checked is Left<RepositoryFailure, SettingsRow>) {
      return Left(checked.value);
    }
    final row = checked.right;
    final nextRevision = core.Revision(row.revision).next();
    final ownedSettings = settings.ownerId == ownerId.value
        ? settings
        : settings.copyWith(ownerId: ownerId.value);
    final updated = await (db.update(db.settingsTable)
          ..where(
            (table) =>
                table.ownerId.equals(ownerId.value) &
                table.revision.equals(expectedRevision.value),
          ))
        .write(_settingsCompanion(
      ownedSettings,
      revision: nextRevision,
      createdAt: row.createdAtUtc,
      updatedAt: _epoch,
    ));
    if (updated != 1) {
      return Left(await _staleSettingsFailure(ownerId, expectedRevision));
    }
    return Right(nextRevision);
  }

  Future<SettingsRow?> _settingsByOwner(String ownerId) {
    return (db.select(db.settingsTable)
          ..where((table) => table.ownerId.equals(ownerId)))
        .getSingleOrNull();
  }

  Future<Either<RepositoryFailure, SettingsRow>> _checkSettingsWrite(
    core.OwnerId ownerId,
    core.Revision expectedRevision,
  ) async {
    if (expectedRevision.value <= 0) {
      return Left(RepositoryInvalidRevision(
        entityId: ownerId.value,
        expectedRevision: expectedRevision,
      ));
    }
    final row = await _settingsByOwner(ownerId.value);
    if (row == null) return Left(RepositoryNotFound(entityId: ownerId.value));
    if (row.revision != expectedRevision.value) {
      return Left(RepositoryStaleRevision(
        entityId: ownerId.value,
        expectedRevision: expectedRevision,
        actualRevision: core.Revision(row.revision),
      ));
    }
    return Right(row);
  }

  Future<RepositoryStaleRevision> _staleSettingsFailure(
    core.OwnerId ownerId,
    core.Revision expectedRevision,
  ) async {
    final row = await _settingsByOwner(ownerId.value);
    return RepositoryStaleRevision(
      entityId: ownerId.value,
      expectedRevision: expectedRevision,
      actualRevision:
          row == null ? expectedRevision.next() : core.Revision(row.revision),
    );
  }
}

/// Drift-backed schedule snapshot repository.
final class SqliteScheduleSnapshotRepository
    implements ScheduleSnapshotRepository {
  /// Creates a snapshot repository using [db].
  SqliteScheduleSnapshotRepository(this.db);

  /// Shared SQLite database handle.
  final SchedulerDb db;

  @override
  Future<RepoResult<core.SchedulingStateSnapshot?>> findById({
    required core.OwnerId ownerId,
    required String snapshotId,
  }) async {
    final row = await _snapshotById(snapshotId);
    if (row == null || row.ownerId != ownerId.value) {
      return const Right(null);
    }
    return Right(_snapshotFromRow(row));
  }

  @override
  Future<RepoResult<core.Page<core.SchedulingStateSnapshot>>> findInWindow({
    required core.OwnerId ownerId,
    required core.SchedulingWindow window,
    core.PageRequest page = const core.PageRequest(),
  }) async {
    final rows = await (db.select(db.snapshots)
          ..where((table) => table.ownerId.equals(ownerId.value))
          ..orderBy([(table) => drift.OrderingTerm.asc(table.id)]))
        .get();
    final matching = rows
        .map(_snapshotFromRow)
        .where((snapshot) => _windowsOverlap(snapshot.window, window))
        .toList(growable: false);
    final offset = _cursorOffset(page.cursor);
    final pageItems = matching.skip(offset).take(page.limit).toList();
    final nextOffset = offset + page.limit;
    return Right(core.Page(
      items: pageItems,
      nextCursor: nextOffset < matching.length ? '$nextOffset' : null,
    ));
  }

  @override
  Future<RepoResult<core.Revision>> insert({
    required core.OwnerId ownerId,
    required core.SchedulingStateSnapshot snapshot,
  }) async {
    if (await _snapshotById(snapshot.id) != null) {
      return Left(RepositoryDuplicateId(entityId: snapshot.id));
    }
    await db.into(db.snapshots).insert(_snapshotCompanion(
          snapshot,
          ownerId: ownerId,
          revision: core.Revision.initial,
        ));
    return const Right(core.Revision.initial);
  }

  @override
  Future<RepoResult<int>> deleteExpired({
    required core.OwnerId ownerId,
    required DateTime nowUtc,
  }) async {
    final deleted = await (db.delete(db.snapshots)
          ..where(
            (table) =>
                table.ownerId.equals(ownerId.value) &
                table.retentionExpiresUtc.isSmallerThanValue(nowUtc.toUtc()),
          ))
        .go();
    return Right(deleted);
  }

  Future<SnapshotRow?> _snapshotById(String id) {
    return (db.select(db.snapshots)..where((table) => table.id.equals(id)))
        .getSingleOrNull();
  }
}

TasksCompanion _taskCompanion(
  core.Task task, {
  required core.OwnerId ownerId,
  required core.Revision revision,
}) {
  final doc = core.TaskDocumentMapping.toDocument(
    task,
    ownerId: ownerId.value,
    revision: revision.value,
  );
  return TasksCompanion.insert(
    id: task.id,
    ownerId: ownerId.value,
    title: _string(doc, core.TaskDocumentFields.title),
    projectId: _string(doc, core.TaskDocumentFields.projectId),
    parentId: drift.Value(_nullableString(
      doc,
      core.TaskDocumentFields.parentTaskId,
    )),
    type: _string(doc, core.TaskDocumentFields.type),
    status: _string(doc, core.TaskDocumentFields.status),
    priority: drift.Value(_nullableString(
      doc,
      core.TaskDocumentFields.priority,
    )),
    reward: _string(doc, core.TaskDocumentFields.reward),
    difficulty: _string(doc, core.TaskDocumentFields.difficulty),
    durationMinutes: drift.Value(_nullableInt(
      doc,
      core.TaskDocumentFields.durationMinutes,
    )),
    scheduledStartUtc: drift.Value(_nullableDateTime(
      doc,
      core.TaskDocumentFields.scheduledStart,
    )),
    scheduledEndUtc: drift.Value(_nullableDateTime(
      doc,
      core.TaskDocumentFields.scheduledEnd,
    )),
    actualStartUtc: drift.Value(_nullableDateTime(
      doc,
      core.TaskDocumentFields.actualStart,
    )),
    actualEndUtc: drift.Value(_nullableDateTime(
      doc,
      core.TaskDocumentFields.actualEnd,
    )),
    completedAtUtc: drift.Value(_nullableDateTime(
      doc,
      core.TaskDocumentFields.completedAt,
    )),
    backlogTagsJson: jsonEncode(doc[core.TaskDocumentFields.backlogTags]),
    reminderOverride: drift.Value(_nullableString(
      doc,
      core.TaskDocumentFields.reminderOverride,
    )),
    statsJson: jsonEncode(doc[core.TaskDocumentFields.stats]),
    backlogEnteredAtUtc: drift.Value(_nullableDateTime(
      doc,
      core.TaskDocumentFields.backlogEnteredAt,
    )),
    backlogEnteredProvenance: drift.Value(_nullableString(
      doc,
      core.TaskDocumentFields.backlogEnteredAtProvenance,
    )),
    revision: revision.value,
    createdAtUtc: task.createdAt.toUtc(),
    updatedAtUtc: task.updatedAt.toUtc(),
  );
}

core.Task _taskFromRow(TaskRow row) {
  return core.TaskDocumentMapping.fromDocument({
    ..._commonDocumentFields(
        row.id, row.ownerId, row.revision, row.createdAtUtc, row.updatedAtUtc),
    core.TaskDocumentFields.title: row.title,
    core.TaskDocumentFields.projectId: row.projectId,
    core.TaskDocumentFields.type: row.type,
    core.TaskDocumentFields.status: row.status,
    core.TaskDocumentFields.priority: row.priority,
    core.TaskDocumentFields.reward: row.reward,
    core.TaskDocumentFields.difficulty: row.difficulty,
    core.TaskDocumentFields.durationMinutes: row.durationMinutes,
    core.TaskDocumentFields.scheduledStart:
        _storedDateTimeOrNull(row.scheduledStartUtc),
    core.TaskDocumentFields.scheduledEnd:
        _storedDateTimeOrNull(row.scheduledEndUtc),
    core.TaskDocumentFields.actualStart:
        _storedDateTimeOrNull(row.actualStartUtc),
    core.TaskDocumentFields.actualEnd: _storedDateTimeOrNull(row.actualEndUtc),
    core.TaskDocumentFields.completedAt:
        _storedDateTimeOrNull(row.completedAtUtc),
    core.TaskDocumentFields.parentTaskId: row.parentId,
    core.TaskDocumentFields.backlogTags: _jsonList(row.backlogTagsJson),
    core.TaskDocumentFields.reminderOverride: row.reminderOverride,
    core.TaskDocumentFields.stats: _jsonMap(row.statsJson),
    core.TaskDocumentFields.backlogEnteredAt:
        _storedDateTimeOrNull(row.backlogEnteredAtUtc),
    core.TaskDocumentFields.backlogEnteredAtProvenance:
        row.backlogEnteredProvenance,
  });
}

ProjectsCompanion _projectCompanion(
  core.ProjectProfile project, {
  required core.OwnerId ownerId,
  required core.Revision revision,
  required DateTime createdAt,
  required DateTime updatedAt,
}) {
  final doc = core.ProjectDocumentMapping.toDocument(
    project,
    ownerId: ownerId.value,
    revision: revision.value,
    createdAt: createdAt,
    updatedAt: updatedAt,
  );
  return ProjectsCompanion.insert(
    id: project.id,
    ownerId: ownerId.value,
    name: _string(doc, core.ProjectDocumentFields.name),
    colorKey: _string(doc, core.ProjectDocumentFields.colorKey),
    defaultPriority: _string(doc, core.ProjectDocumentFields.defaultPriority),
    defaultReward: _string(doc, core.ProjectDocumentFields.defaultReward),
    defaultDifficulty:
        _string(doc, core.ProjectDocumentFields.defaultDifficulty),
    defaultReminderProfile:
        _string(doc, core.ProjectDocumentFields.defaultReminderProfile),
    defaultDurationMinutes: drift.Value(_nullableInt(
      doc,
      core.ProjectDocumentFields.defaultDurationMinutes,
    )),
    archivedAtUtc: drift.Value(_nullableDateTime(
      doc,
      core.ProjectDocumentFields.archivedAt,
    )),
    revision: revision.value,
    createdAtUtc: createdAt.toUtc(),
    updatedAtUtc: updatedAt.toUtc(),
  );
}

core.ProjectProfile _projectFromRow(ProjectRow row) {
  return core.ProjectDocumentMapping.fromDocument({
    ..._commonDocumentFields(
        row.id, row.ownerId, row.revision, row.createdAtUtc, row.updatedAtUtc),
    core.ProjectDocumentFields.name: row.name,
    core.ProjectDocumentFields.colorKey: row.colorKey,
    core.ProjectDocumentFields.defaultPriority: row.defaultPriority,
    core.ProjectDocumentFields.defaultReward: row.defaultReward,
    core.ProjectDocumentFields.defaultDifficulty: row.defaultDifficulty,
    core.ProjectDocumentFields.defaultReminderProfile:
        row.defaultReminderProfile,
    core.ProjectDocumentFields.defaultDurationMinutes:
        row.defaultDurationMinutes,
    core.ProjectDocumentFields.archivedAt: _storedDateTimeOrNull(
      row.archivedAtUtc,
    ),
  });
}

LockedBlocksCompanion _lockedBlockCompanion(
  core.LockedBlock block, {
  required core.OwnerId ownerId,
  required core.Revision revision,
}) {
  final doc = core.LockedBlockDocumentMapping.toDocument(
    block,
    ownerId: ownerId.value,
    revision: revision.value,
  );
  return LockedBlocksCompanion.insert(
    id: block.id,
    ownerId: ownerId.value,
    name: _string(doc, core.LockedBlockDocumentFields.name),
    date:
        drift.Value(_nullableString(doc, core.LockedBlockDocumentFields.date)),
    startTime: _string(doc, core.LockedBlockDocumentFields.startTime),
    endTime: _string(doc, core.LockedBlockDocumentFields.endTime),
    recurrenceJson: drift.Value(_jsonEncodeNullable(
      doc[core.LockedBlockDocumentFields.recurrence],
    )),
    hiddenByDefault: _bool(doc, core.LockedBlockDocumentFields.hiddenByDefault),
    projectId: drift.Value(_nullableString(
      doc,
      core.LockedBlockDocumentFields.projectId,
    )),
    archivedAtUtc: drift.Value(_nullableDateTime(
      doc,
      core.LockedBlockDocumentFields.archivedAt,
    )),
    revision: revision.value,
    createdAtUtc: block.createdAt.toUtc(),
    updatedAtUtc: block.updatedAt.toUtc(),
  );
}

core.LockedBlock _lockedBlockFromRow(LockedBlockRow row) {
  return core.LockedBlockDocumentMapping.fromDocument({
    ..._commonDocumentFields(
        row.id, row.ownerId, row.revision, row.createdAtUtc, row.updatedAtUtc),
    core.LockedBlockDocumentFields.name: row.name,
    core.LockedBlockDocumentFields.startTime: row.startTime,
    core.LockedBlockDocumentFields.endTime: row.endTime,
    core.LockedBlockDocumentFields.date: row.date,
    core.LockedBlockDocumentFields.recurrence:
        row.recurrenceJson == null ? null : _jsonMap(row.recurrenceJson!),
    core.LockedBlockDocumentFields.hiddenByDefault: row.hiddenByDefault,
    core.LockedBlockDocumentFields.projectId: row.projectId,
    core.LockedBlockDocumentFields.archivedAt:
        _storedDateTimeOrNull(row.archivedAtUtc),
  });
}

LockedOverridesCompanion _lockedOverrideCompanion(
  core.LockedBlockOverride override, {
  required core.OwnerId ownerId,
  required core.Revision revision,
}) {
  final doc = core.LockedBlockOverrideDocumentMapping.toDocument(
    override,
    ownerId: ownerId.value,
    revision: revision.value,
  );
  return LockedOverridesCompanion.insert(
    id: override.id,
    ownerId: ownerId.value,
    lockedBlockId: drift.Value(_nullableString(
      doc,
      core.LockedBlockOverrideDocumentFields.lockedBlockId,
    )),
    date: _string(doc, core.LockedBlockOverrideDocumentFields.date),
    type: _string(doc, core.LockedBlockOverrideDocumentFields.type),
    name: drift.Value(
      _nullableString(doc, core.LockedBlockOverrideDocumentFields.name),
    ),
    startTime: drift.Value(_nullableString(
      doc,
      core.LockedBlockOverrideDocumentFields.startTime,
    )),
    endTime: drift.Value(_nullableString(
      doc,
      core.LockedBlockOverrideDocumentFields.endTime,
    )),
    hiddenByDefault:
        _bool(doc, core.LockedBlockOverrideDocumentFields.hiddenByDefault),
    projectId: drift.Value(_nullableString(
      doc,
      core.LockedBlockOverrideDocumentFields.projectId,
    )),
    revision: revision.value,
    createdAtUtc: override.createdAt.toUtc(),
    updatedAtUtc: override.updatedAt.toUtc(),
  );
}

core.LockedBlockOverride _lockedOverrideFromRow(LockedOverrideRow row) {
  return core.LockedBlockOverrideDocumentMapping.fromDocument({
    ..._commonDocumentFields(
        row.id, row.ownerId, row.revision, row.createdAtUtc, row.updatedAtUtc),
    core.LockedBlockOverrideDocumentFields.lockedBlockId: row.lockedBlockId,
    core.LockedBlockOverrideDocumentFields.date: row.date,
    core.LockedBlockOverrideDocumentFields.type: row.type,
    core.LockedBlockOverrideDocumentFields.name: row.name,
    core.LockedBlockOverrideDocumentFields.startTime: row.startTime,
    core.LockedBlockOverrideDocumentFields.endTime: row.endTime,
    core.LockedBlockOverrideDocumentFields.hiddenByDefault: row.hiddenByDefault,
    core.LockedBlockOverrideDocumentFields.projectId: row.projectId,
  });
}

SettingsTableCompanion _settingsCompanion(
  core.OwnerSettings settings, {
  required core.Revision revision,
  required DateTime createdAt,
  required DateTime updatedAt,
}) {
  final doc = core.OwnerSettingsDocumentMapping.toDocument(
    settings,
    revision: revision.value,
    createdAt: createdAt,
    updatedAt: updatedAt,
  );
  return SettingsTableCompanion.insert(
    ownerId: settings.ownerId,
    timeZoneId: _string(doc, core.OwnerSettingsDocumentFields.timeZoneId),
    dayStartMinutes: settings.dayStart.minutesSinceMidnight,
    dayEndMinutes: settings.dayEnd.minutesSinceMidnight,
    compactMode:
        _bool(doc, core.OwnerSettingsDocumentFields.compactModeEnabled),
    backlogStalenessJson:
        jsonEncode(doc[core.OwnerSettingsDocumentFields.backlogStaleness]),
    revision: revision.value,
    createdAtUtc: createdAt.toUtc(),
    updatedAtUtc: updatedAt.toUtc(),
  );
}

core.OwnerSettings _settingsFromRow(SettingsRow row) {
  return core.OwnerSettingsDocumentMapping.fromDocument({
    ..._commonDocumentFields(row.ownerId, row.ownerId, row.revision,
        row.createdAtUtc, row.updatedAtUtc),
    core.OwnerSettingsDocumentFields.timeZoneId: row.timeZoneId,
    core.OwnerSettingsDocumentFields.dayStart:
        _wallTimeFromMinutes(row.dayStartMinutes).toIsoString(),
    core.OwnerSettingsDocumentFields.dayEnd:
        _wallTimeFromMinutes(row.dayEndMinutes).toIsoString(),
    core.OwnerSettingsDocumentFields.compactModeEnabled: row.compactMode,
    core.OwnerSettingsDocumentFields.backlogStaleness:
        _jsonMap(row.backlogStalenessJson),
  });
}

SnapshotsCompanion _snapshotCompanion(
  core.SchedulingStateSnapshot snapshot, {
  required core.OwnerId ownerId,
  required core.Revision revision,
}) {
  final retentionExpiresAt = snapshot.capturedAt.add(_snapshotRetention);
  final doc = core.SchedulingSnapshotDocumentMapping.toDocument(
    snapshot,
    ownerId: ownerId.value,
    revision: revision.value,
    retentionExpiresAt: retentionExpiresAt,
  );
  return SnapshotsCompanion.insert(
    id: snapshot.id,
    ownerId: ownerId.value,
    capturedAtUtc: snapshot.capturedAt.toUtc(),
    operationName: drift.Value(_nullableString(
      doc,
      core.SchedulingSnapshotDocumentFields.operationName,
    )),
    sourceDate: drift.Value(_nullableString(
      doc,
      core.SchedulingSnapshotDocumentFields.sourceDate,
    )),
    targetDate: drift.Value(_nullableString(
      doc,
      core.SchedulingSnapshotDocumentFields.targetDate,
    )),
    windowJson: jsonEncode(doc[core.SchedulingSnapshotDocumentFields.window]),
    tasksJson: jsonEncode(doc[core.SchedulingSnapshotDocumentFields.tasks]),
    lockedIntervalsJson:
        jsonEncode(doc[core.SchedulingSnapshotDocumentFields.lockedIntervals]),
    requiredVisibleIntervalsJson: jsonEncode(
      doc[core.SchedulingSnapshotDocumentFields.requiredVisibleIntervals],
    ),
    noticesJson: jsonEncode(doc[core.SchedulingSnapshotDocumentFields.notices]),
    changesJson: jsonEncode(doc[core.SchedulingSnapshotDocumentFields.changes]),
    overlapsJson:
        jsonEncode(doc[core.SchedulingSnapshotDocumentFields.overlaps]),
    retentionExpiresUtc: retentionExpiresAt.toUtc(),
    truncated: _bool(doc, core.SchedulingSnapshotDocumentFields.truncated),
    revision: revision.value,
    createdAtUtc: snapshot.capturedAt.toUtc(),
    updatedAtUtc: snapshot.capturedAt.toUtc(),
  );
}

core.SchedulingStateSnapshot _snapshotFromRow(SnapshotRow row) {
  return core.SchedulingSnapshotDocumentMapping.fromDocument({
    ..._commonDocumentFields(
        row.id, row.ownerId, row.revision, row.createdAtUtc, row.updatedAtUtc),
    core.SchedulingSnapshotDocumentFields.capturedAt:
        core.PersistenceDateTimeConvention.toStoredString(row.capturedAtUtc),
    core.SchedulingSnapshotDocumentFields.sourceDate: row.sourceDate,
    core.SchedulingSnapshotDocumentFields.targetDate: row.targetDate,
    core.SchedulingSnapshotDocumentFields.window: _jsonMap(row.windowJson),
    core.SchedulingSnapshotDocumentFields.tasks: _jsonList(row.tasksJson),
    core.SchedulingSnapshotDocumentFields.lockedIntervals:
        _jsonList(row.lockedIntervalsJson),
    core.SchedulingSnapshotDocumentFields.requiredVisibleIntervals:
        _jsonList(row.requiredVisibleIntervalsJson),
    core.SchedulingSnapshotDocumentFields.notices: _jsonList(row.noticesJson),
    core.SchedulingSnapshotDocumentFields.changes: _jsonList(row.changesJson),
    core.SchedulingSnapshotDocumentFields.overlaps: _jsonList(row.overlapsJson),
    core.SchedulingSnapshotDocumentFields.operationName: row.operationName,
    core.SchedulingSnapshotDocumentFields.retentionExpiresAt:
        core.PersistenceDateTimeConvention.toStoredString(
      row.retentionExpiresUtc,
    ),
    core.SchedulingSnapshotDocumentFields.truncated: row.truncated,
  });
}

Map<String, Object?> _commonDocumentFields(
  String id,
  String ownerId,
  int revision,
  DateTime createdAt,
  DateTime updatedAt,
) {
  return <String, Object?>{
    core.DocumentFields.schemaVersion: core.v1SchemaVersion,
    core.DocumentFields.id: id,
    core.DocumentFields.ownerId: ownerId,
    core.DocumentFields.revision: revision,
    core.DocumentFields.createdAt:
        core.PersistenceDateTimeConvention.toStoredString(createdAt),
    core.DocumentFields.updatedAt:
        core.PersistenceDateTimeConvention.toStoredString(updatedAt),
  };
}

String _string(Map<String, Object?> doc, String fieldName) {
  return doc[fieldName] as String;
}

String? _nullableString(Map<String, Object?> doc, String fieldName) {
  return doc[fieldName] as String?;
}

int? _nullableInt(Map<String, Object?> doc, String fieldName) {
  return doc[fieldName] as int?;
}

bool _bool(Map<String, Object?> doc, String fieldName) {
  return doc[fieldName] as bool;
}

DateTime? _nullableDateTime(Map<String, Object?> doc, String fieldName) {
  final value = doc[fieldName] as String?;
  return value == null
      ? null
      : core.PersistenceDateTimeConvention.fromStoredString(value);
}

String? _storedDateTimeOrNull(DateTime? value) {
  return value == null
      ? null
      : core.PersistenceDateTimeConvention.toStoredString(value);
}

String? _jsonEncodeNullable(Object? value) {
  return value == null ? null : jsonEncode(value);
}

Map<String, Object?> _jsonMap(String value) {
  return Map<String, Object?>.from(
    jsonDecode(value) as Map<dynamic, dynamic>,
  );
}

List<Object?> _jsonList(String value) {
  return List<Object?>.from(jsonDecode(value) as List<dynamic>);
}

core.WallTime _wallTimeFromMinutes(int minutes) {
  return core.WallTime(hour: minutes ~/ 60, minute: minutes % 60);
}

int _cursorOffset(String? cursor) {
  if (cursor == null) return 0;
  final parsed = int.tryParse(cursor);
  if (parsed == null || parsed < 0) return 0;
  return parsed;
}

extension on int {
  core.Revision next() => core.Revision(this).next();
}

bool _windowsOverlap(core.SchedulingWindow left, core.SchedulingWindow right) {
  return left.start.isBefore(right.end) && right.start.isBefore(left.end);
}
