import { application } from '@application'
import { agentSessionTable as sessionTable } from '@data/db/schemas/agentSession'
import {
  type AgentSessionMessageRow as SessionMessageRow,
  agentSessionMessageTable as sessionMessagesTable,
  type InsertAgentSessionMessageRow as InsertSessionMessageRow
} from '@data/db/schemas/agentSessionMessage'
import { defaultHandlersFor, withSqliteErrors } from '@data/db/sqliteErrors'
import type { DbOrTx } from '@data/db/types'
import { timestampToISO } from '@data/services/utils/rowMappers'
import { loggerService } from '@logger'
import { buildSearchSnippet } from '@main/utils/searchSnippet'
import { DataApiErrorFactory } from '@shared/data/api'
import type { CursorPaginationResponse } from '@shared/data/api/apiTypes'
import type {
  AgentSessionMessageEntity,
  CreateAgentSessionMessageDto,
  CreateAgentSessionMessagesDto
} from '@shared/data/api/schemas/agentSessions'
import {
  AGENT_SESSION_MESSAGES_DEFAULT_LIMIT,
  AGENT_SESSION_MESSAGES_MAX_LIMIT
} from '@shared/data/api/schemas/agentSessions'
import type { SessionMessageContentSearchItem } from '@shared/data/api/schemas/search'
import { AGENT_SESSION_MESSAGE_SEARCH_ROLES, coerceSearchRole } from '@shared/data/types/message'
import { and, desc, eq, inArray, isNotNull, lt, lte, or, sql } from 'drizzle-orm'
import { v7 as uuidv7, validate as isUuid } from 'uuid'

import { type SearchFetchContext, searchWithCursor } from './utils/ftsSearch'
import { asNumericKey, decodeListCursor, encodeCursor, keysetOrdering } from './utils/keysetCursor'

const logger = loggerService.withContext('AgentSessionMessageService')
const MESSAGE_CURSOR_CONFIG = {
  fieldMessage: 'must be a valid message cursor',
  errorMessage: 'Invalid message cursor'
}

type SessionMessageSearchRow = {
  rowId: string
  sessionId: string
  sessionName: string
  agentId: string | null
  agentName: string | null
  role: string
  searchableText: string
  createdAt: number
}

type SessionMessageContentSearchInput = {
  q: string
  cursor?: string
  limit?: number
  createdAtFrom?: string
  sessionId?: string
}

type ListSessionMessagesOptions = {
  cursor?: string
  limit?: number
  messageId?: string
}

export class AgentSessionMessageService {
  search(query: SessionMessageContentSearchInput) {
    const db = application.get('DbService').getDb()
    const messageSessionCondition = query.sessionId ? sql`sm.session_id = ${query.sessionId}` : sql`1 = 1`

    return searchWithCursor<SessionMessageSearchRow, SessionMessageContentSearchItem>({
      q: query.q,
      limit: query.limit,
      cursor: query.cursor,
      createdAtFrom: query.createdAtFrom,
      cursorConfig: MESSAGE_CURSOR_CONFIG,
      fetchRows: ({ ftsConditions, cursor, createdAtFromMs, offset, chunkSize }: SearchFetchContext) => {
        const createdAtCondition = createdAtFromMs !== undefined ? sql`sm.created_at >= ${createdAtFromMs}` : sql`1 = 1`

        return db.all<SessionMessageSearchRow>(sql`
          SELECT
            sm.id AS "rowId",
            sm.searchable_text AS "searchableText",
            sm.session_id AS "sessionId",
            s.name AS "sessionName",
            s.agent_id AS "agentId",
            a.name AS "agentName",
            sm.role,
            sm.created_at AS "createdAt"
          FROM agent_session_message sm
          JOIN agent_session_message_fts fts ON sm.fts_rowid = fts.rowid
          JOIN agent_session s ON s.id = sm.session_id
          LEFT JOIN agent a ON a.id = s.agent_id
          WHERE sm.searchable_text != ''
            AND ${messageSessionCondition}
            AND ${createdAtCondition}
            AND ${sql.join(ftsConditions, sql` AND `)}
            AND ${
              cursor
                ? sql`(sm.created_at < ${cursor.createdAt} OR (sm.created_at = ${cursor.createdAt} AND sm.id < ${cursor.id}))`
                : sql`1 = 1`
            }
          ORDER BY sm.created_at DESC, sm.id DESC
          LIMIT ${chunkSize}
          OFFSET ${offset}
        `)
      },
      getSearchableText: (row) => row.searchableText,
      buildSnippet: buildSearchSnippet,
      mapRow: (row, { snippet }) => ({
        item: {
          messageId: row.rowId,
          sessionId: row.sessionId,
          sessionName: row.sessionName,
          agentId: row.agentId ?? undefined,
          agentName: row.agentName ?? undefined,
          role: coerceSearchRole(row.role, AGENT_SESSION_MESSAGE_SEARCH_ROLES),
          snippet,
          createdAt: timestampToISO(Number(row.createdAt))
        },
        sort: {
          createdAt: Number(row.createdAt),
          id: row.rowId
        }
      })
    })
  }

  /**
   * Cursor-paginated message read. Walks newest-first; an absent cursor
   * returns the most recent page, each `nextCursor` walks one page older.
   * Cursor wire format: `<createdAtMs>:<id>` — composite (createdAt, id) so
   * the secondary key tiebreaks ties from the ms-precision timestamp.
   */
  listSessionMessages(
    sessionId: string,
    options: ListSessionMessagesOptions = {}
  ): CursorPaginationResponse<AgentSessionMessageEntity> {
    const database = application.get('DbService').getDb()

    const [session] = database
      .select({ id: sessionTable.id })
      .from(sessionTable)
      .where(eq(sessionTable.id, sessionId))
      .limit(1)
      .all()
    if (!session) throw DataApiErrorFactory.notFound('Session', sessionId)

    const limit = Math.min(options.limit ?? AGENT_SESSION_MESSAGES_DEFAULT_LIMIT, AGENT_SESSION_MESSAGES_MAX_LIMIT)
    const ordering = keysetOrdering(sessionMessagesTable.createdAt, sessionMessagesTable.id, {
      major: 'desc',
      tie: 'desc'
    })
    const cursor = decodeListCursor(options.cursor, asNumericKey, 'agent-session-message')
    const [anchor] =
      !options.cursor && options.messageId
        ? database
            .select({ id: sessionMessagesTable.id, createdAt: sessionMessagesTable.createdAt })
            .from(sessionMessagesTable)
            .where(and(eq(sessionMessagesTable.sessionId, sessionId), eq(sessionMessagesTable.id, options.messageId)))
            .limit(1)
            .all()
        : []
    if (!options.cursor && options.messageId && !anchor) {
      logger.warn('Session message anchor not found, falling back to newest page', {
        sessionId,
        messageId: options.messageId
      })
    }

    const filters = [eq(sessionMessagesTable.sessionId, sessionId)]
    if (cursor) {
      filters.push(ordering.where(cursor))
    } else if (anchor) {
      // Anchor the first page so previews include the matched message and older context.
      filters.push(
        or(
          lt(sessionMessagesTable.createdAt, anchor.createdAt),
          and(eq(sessionMessagesTable.createdAt, anchor.createdAt), lte(sessionMessagesTable.id, anchor.id))
        )!
      )
    }

    const rows = database
      .select()
      .from(sessionMessagesTable)
      .where(and(...filters))
      .orderBy(...ordering.orderBy)
      .limit(limit + 1)
      .all()

    const hasNext = rows.length > limit
    const pageRows = hasNext ? rows.slice(0, limit) : rows
    const items = pageRows.map((row) => this.rowToEntity(row))
    const tail = pageRows[pageRows.length - 1]
    const nextCursor = hasNext && tail ? encodeCursor(tail.createdAt, tail.id) : undefined

    return { items, nextCursor }
  }

  deleteSessionMessage(sessionId: string, messageId: string): void {
    if (!messageId) {
      throw DataApiErrorFactory.validation({ messageId: ['must not be empty'] })
    }
    const database = application.get('DbService').getDb()

    const [session] = database
      .select({ id: sessionTable.id })
      .from(sessionTable)
      .where(eq(sessionTable.id, sessionId))
      .limit(1)
      .all()
    if (!session) throw DataApiErrorFactory.notFound('Session', sessionId)

    const result = withSqliteErrors(
      () => this.deleteSessionMessageTx(database, sessionId, messageId),
      defaultHandlersFor('Message', messageId)
    )
    if (result.rowsAffected === 0) {
      throw DataApiErrorFactory.notFound('Message', messageId)
    }
  }

  deleteSessionMessageTx(tx: DbOrTx, sessionId: string, messageId: string): { rowsAffected: number } {
    const result = tx
      .delete(sessionMessagesTable)
      .where(and(eq(sessionMessagesTable.id, messageId), eq(sessionMessagesTable.sessionId, sessionId)))
      .run()
    return { rowsAffected: result.changes }
  }

  /**
   * Ids of assistant rows still in `pending` — used by the agent-session boot reconcile to
   * resolve turns a prior main-process crash left stuck (the runtime never reached its terminal
   * write, and the in-memory entry map is empty after a restart, so nothing else settles them).
   */
  findPendingAssistantMessageIds(): string[] {
    const database = application.get('DbService').getDb()
    const rows = database
      .select({ id: sessionMessagesTable.id })
      .from(sessionMessagesTable)
      .where(and(eq(sessionMessagesTable.role, 'assistant'), eq(sessionMessagesTable.status, 'pending')))
      .all()
    return rows.map((row) => row.id)
  }

  /** Bulk-resolve the given rows to `error` — the boot reconcile of crash-orphaned `pending` rows. */
  markMessagesError(ids: string[]): void {
    if (ids.length === 0) return
    const db = application.get('DbService').getDb()
    db.update(sessionMessagesTable).set({ status: 'error' }).where(inArray(sessionMessagesTable.id, ids)).run()
  }

  private rowToEntity(row: SessionMessageRow): AgentSessionMessageEntity {
    return {
      id: row.id,
      sessionId: row.sessionId,
      role: row.role as AgentSessionMessageEntity['role'],
      data: row.data,
      searchableText: row.searchableText,
      status: row.status as AgentSessionMessageEntity['status'],
      modelId: row.modelId,
      modelSnapshot: row.modelSnapshot,
      stats: row.stats,
      runtimeResumeToken: row.runtimeResumeToken,
      createdAt: timestampToISO(row.createdAt),
      updatedAt: timestampToISO(row.updatedAt)
    }
  }

  getLastRuntimeResumeToken(sessionId: string): string | null {
    try {
      const database = application.get('DbService').getDb()
      const result = database
        .select({ runtimeResumeToken: sessionMessagesTable.runtimeResumeToken })
        .from(sessionMessagesTable)
        .where(and(eq(sessionMessagesTable.sessionId, sessionId), isNotNull(sessionMessagesTable.runtimeResumeToken)))
        .orderBy(desc(sessionMessagesTable.createdAt))
        .limit(1)
        .all()

      logger.silly('Last runtime resume token result:', {
        runtimeResumeToken: result[0]?.runtimeResumeToken,
        sessionId
      })
      return result[0]?.runtimeResumeToken ?? null
    } catch (error) {
      logger.error('Failed to get last runtime resume token', {
        sessionId,
        error
      })
      throw error
    }
  }

  // ── Persistence methods ──────────────────────────────────────────

  private findExistingMessageRow(db: DbOrTx, sessionId: string, messageId: string): SessionMessageRow | null {
    const rows = db
      .select()
      .from(sessionMessagesTable)
      .where(and(eq(sessionMessagesTable.sessionId, sessionId), eq(sessionMessagesTable.id, messageId)))
      .limit(1)
      .all()

    return rows[0] ?? null
  }

  private upsertMessage(
    db: DbOrTx,
    params: { sessionId: string; runtimeResumeToken?: string; message: CreateAgentSessionMessageDto },
    timestampMs = Date.now()
  ): AgentSessionMessageEntity {
    const { sessionId, runtimeResumeToken = null, message } = params
    const messageId = message.id ?? uuidv7()
    const status = message.status ?? 'success'

    if (!message.role) {
      throw DataApiErrorFactory.validation({ role: ['is required'] }, 'Message payload missing role')
    }

    if (!isUuid(messageId)) {
      throw DataApiErrorFactory.validation({ id: ['must be a UUID'] }, 'Agent session message id must be a UUID')
    }

    const existingRow = this.findExistingMessageRow(db, sessionId, messageId)

    if (existingRow) {
      const runtimeResumeTokenToPersist = runtimeResumeToken ?? existingRow.runtimeResumeToken ?? null
      const updatedAtMs = timestampMs
      const modelId = message.modelId === undefined ? existingRow.modelId : message.modelId
      const modelSnapshot = message.modelSnapshot === undefined ? existingRow.modelSnapshot : message.modelSnapshot
      const stats = message.stats === undefined ? existingRow.stats : message.stats

      withSqliteErrors(
        () =>
          db
            .update(sessionMessagesTable)
            .set({
              role: message.role,
              status,
              data: message.data,
              modelId,
              modelSnapshot,
              stats,
              runtimeResumeToken: runtimeResumeTokenToPersist,
              updatedAt: updatedAtMs
            })
            .where(eq(sessionMessagesTable.id, existingRow.id))
            .run(),
        defaultHandlersFor('Message', String(existingRow.id))
      )

      return this.rowToEntity({
        ...existingRow,
        role: message.role,
        status,
        data: message.data,
        searchableText: existingRow.searchableText,
        modelId,
        modelSnapshot,
        stats,
        runtimeResumeToken: runtimeResumeTokenToPersist,
        updatedAt: updatedAtMs
      })
    }

    const insertData: InsertSessionMessageRow = {
      id: messageId,
      sessionId,
      role: message.role,
      status,
      data: message.data,
      modelId: message.modelId,
      modelSnapshot: message.modelSnapshot,
      stats: message.stats,
      runtimeResumeToken,
      createdAt: timestampMs,
      updatedAt: timestampMs
    }

    const [saved] = db.insert(sessionMessagesTable).values(insertData).returning().all()
    return this.rowToEntity(saved)
  }

  private touchSessionUpdatedAt(db: DbOrTx, sessionId: string, timestampMs: number): void {
    db.update(sessionTable).set({ updatedAt: timestampMs }).where(eq(sessionTable.id, sessionId)).run()
  }

  private saveMessageTx(
    db: DbOrTx,
    params: { sessionId: string; runtimeResumeToken?: string; message: CreateAgentSessionMessageDto },
    timestampMs = Date.now()
  ): AgentSessionMessageEntity {
    const saved = this.upsertMessage(db, params, timestampMs)
    this.touchSessionUpdatedAt(db, params.sessionId, timestampMs)
    return saved
  }

  saveMessage(
    params: { sessionId: string; runtimeResumeToken?: string; message: CreateAgentSessionMessageDto },
    db?: DbOrTx
  ): AgentSessionMessageEntity {
    const timestampMs = Date.now()
    if (db) return this.saveMessageTx(db, params, timestampMs)
    return application.get('DbService').withWriteTx((tx) => this.saveMessageTx(tx, params, timestampMs))
  }

  saveMessages(params: CreateAgentSessionMessagesDto): AgentSessionMessageEntity[] {
    const { sessionId, runtimeResumeToken, messages } = params

    return application.get('DbService').withWriteTx((tx) => {
      const timestampMs = Date.now()
      const saved: AgentSessionMessageEntity[] = []
      for (const message of messages) {
        saved.push(this.upsertMessage(tx, { sessionId, runtimeResumeToken, message }, timestampMs))
      }
      this.touchSessionUpdatedAt(tx, sessionId, timestampMs)
      return saved
    })
  }
}

export const agentSessionMessageService = new AgentSessionMessageService()
