import type { DocumentSnapshot, PersistenceCapabilities, ProjectChangeNotice, ProjectRecoveryReport, ProjectResource, ProjectResourceSweepReport, ProjectSaveResult, ProjectSummary, PropertyValue, Unsubscribe } from './types'
import { cloneSketch } from './sketcher'
import { cloneObjectTopologySnapshot, validateTopologySnapshotForPersistence } from './topologyReferences'

export type SqliteProjectPersistenceOptions = { databasePath?: string; migrationFailureVersion?: number; migrationInterruptAfterVersion?: number }
type WorkerRequest = { id: number; type: 'initialize'; databasePath?: string; migrationFailureVersion?: number; migrationInterruptAfterVersion?: number } | { id: number; type: 'dispose' | 'list-projects' | 'sweep-resources' } | { id: number; type: 'save-document'; document: DocumentSnapshot } | { id: number; type: 'load-document' | 'recovery-report'; documentId: string } | { id: number; type: 'load-checkpoint'; documentId: string; version?: number } | { id: number; type: 'put-resource'; bytes: ArrayBuffer; mediaType: string } | { id: number; type: 'get-resource'; hash: string } | { id: number; type: 'release-resource'; hash: string }
type WorkerInput = { type: 'initialize'; databasePath?: string; migrationFailureVersion?: number; migrationInterruptAfterVersion?: number } | { type: 'dispose' | 'list-projects' | 'sweep-resources' } | { type: 'save-document'; document: DocumentSnapshot } | { type: 'load-document' | 'recovery-report'; documentId: string } | { type: 'load-checkpoint'; documentId: string; version?: number } | { type: 'put-resource'; bytes: ArrayBuffer; mediaType: string } | { type: 'get-resource'; hash: string } | { type: 'release-resource'; hash: string }
type WorkerResponse = { id: number; ok: true; type: 'initialized'; capabilities: PersistenceCapabilities } | { id: number; ok: true; type: 'projects-listed'; projects: ProjectSummary[] } | { id: number; ok: true; type: 'saved'; documentId: string; documentVersion: number; persistedAt: number; mode: PersistenceCapabilities['mode'] } | { id: number; ok: true; type: 'loaded' | 'checkpoint-loaded'; document: DocumentSnapshot | null } | { id: number; ok: true; type: 'recovery-report'; report: ProjectRecoveryReport } | { id: number; ok: true; type: 'resource-put'; resource: ProjectResource } | { id: number; ok: true; type: 'resource-get'; bytes: ArrayBuffer | null } | { id: number; ok: true; type: 'resource-released' } | { id: number; ok: true; type: 'resources-swept'; report: ProjectResourceSweepReport } | { id: number; ok: true; type: 'disposed' } | { id: number; ok: false; error: string }

const unavailable: PersistenceCapabilities = { mode: 'unavailable', sqliteWasm: false, opfs: false, schemaVersion: 0, reason: 'Persistence Worker is unavailable in this environment.' }
const clonePropertyValue = (value: PropertyValue): PropertyValue => {
  if (Array.isArray(value)) {
    if (value.every((entry) => Array.isArray(entry))) return value.map((entry) => [...entry]) as PropertyValue
    if (value.every((entry) => typeof entry === 'boolean')) return [...value] as boolean[]
    return value.every((entry) => typeof entry === 'number') ? [...value] as number[] : [...value] as string[]
  }
  if (!value || typeof value !== 'object') return value
  if ('position' in value && 'rotation' in value) return { position: { ...value.position }, rotation: { axis: { ...value.rotation.axis }, angle: value.rotation.angle } }
  if ('steps' in value && Array.isArray(value.steps)) return { steps: value.steps.map((step) => ({ ...step })) }
  if ('entries' in value && Array.isArray(value.entries)) return { ...value, entries: value.entries.map((entry) => ({ ...entry, subElement: entry.subElement && typeof entry.subElement === 'object' ? { ...entry.subElement, candidates: entry.subElement.candidates ? [...entry.subElement.candidates] : undefined } : entry.subElement })) }
  if ('subElements' in value && Array.isArray(value.subElements)) return { ...value, subElements: value.subElements.map((entry) => typeof entry === 'string' ? entry : { ...entry, candidates: entry.candidates ? [...entry.candidates] : undefined }) }
  if ('persistentId' in value) return { ...value, candidates: value.candidates ? [...value.candidates] : undefined }
  return { ...value }
}
const cloneDocument = (document: DocumentSnapshot): DocumentSnapshot => ({ ...document, tree: document.tree.map((item) => ({ ...item, children: item.children ? [...item.children] : undefined })), objects: document.objects.map((object) => ({ ...object, properties: object.properties.map((property) => ({ ...property, value: clonePropertyValue(property.value), options: property.options ? [...property.options] : undefined })), sketch: object.sketch ? cloneSketch(object.sketch) : undefined, topology: object.topology ? cloneObjectTopologySnapshot(object.topology) : undefined })), dependencies: document.dependencies?.map((edge) => ({ ...edge })), recompute: document.recompute ? { ...document.recompute, dirtyObjects: [...document.recompute.dirtyObjects], order: [...document.recompute.order], objectStates: { ...document.recompute.objectStates }, errors: document.recompute.errors.map((error) => ({ ...error })) } : undefined })

export interface ProjectPersistenceClient {
  initialize(): Promise<PersistenceCapabilities>
  capabilities(): PersistenceCapabilities
  subscribeExternalChanges(listener: (notice: ProjectChangeNotice) => void): Unsubscribe
  list(): Promise<ProjectSummary[]>
  save(document: DocumentSnapshot): Promise<ProjectSaveResult>
  load(documentId: string): Promise<DocumentSnapshot | null>
  loadCheckpoint(documentId: string, version?: number): Promise<DocumentSnapshot | null>
  recovery(documentId: string): Promise<ProjectRecoveryReport>
  resource: {
    put(bytes: Uint8Array, mediaType: string): Promise<ProjectResource>
    get(hash: string): Promise<Uint8Array | null>
    release(hash: string): Promise<void>
    sweep(): Promise<ProjectResourceSweepReport>
  }
  dispose(): Promise<void>
}

/** Serializes persistence mutations while allowing reads to follow the write tail. */
export class PersistenceWriteQueue {
  private tail: Promise<void> = Promise.resolve()

  run<T>(operation: () => Promise<T>): Promise<T> {
    const next = this.tail.then(operation)
    this.tail = next.then(() => undefined, () => undefined)
    return next
  }

  drain(): Promise<void> {
    return this.tail
  }
}

export class ProjectAutosaveScheduler {
  private timer: ReturnType<typeof setTimeout> | null = null
  private pending: DocumentSnapshot | null = null
  private flushPromise: Promise<ProjectSaveResult | null> | null = null

  constructor(private readonly save: (document: DocumentSnapshot) => Promise<ProjectSaveResult>, private readonly idleMs = 800) {}

  schedule(document: DocumentSnapshot) {
    this.pending = cloneDocument(document)
    if (this.timer) clearTimeout(this.timer)
    this.timer = setTimeout(() => { this.timer = null; void this.flush() }, this.idleMs)
  }

  flush() {
    if (this.flushPromise) return this.flushPromise
    const document = this.pending
    this.pending = null
    if (!document) return Promise.resolve(null)
    this.flushPromise = this.save(document).finally(() => { this.flushPromise = null; if (this.pending) void this.flush() })
    return this.flushPromise
  }

  cancel() {
    if (this.timer) clearTimeout(this.timer)
    this.timer = null
    this.pending = null
  }
}

export class SqliteProjectPersistence implements ProjectPersistenceClient {
  private readonly worker: Worker | null
  private readonly senderId = `tab-${Math.random().toString(36).slice(2)}-${Date.now().toString(36)}`
  private readonly changeChannel: BroadcastChannel | null
  private readonly externalChangeListeners = new Set<(notice: ProjectChangeNotice) => void>()
  private nextRequestId = 0
  private currentCapabilities = unavailable
  private initialized: Promise<PersistenceCapabilities> | null = null
  private disposed = false
  private readonly fallbackSnapshots = new Map<string, { document: DocumentSnapshot; savedAt: number }>()
  private readonly fallbackCheckpoints = new Map<string, Map<number, { document: DocumentSnapshot; savedAt: number }>>()
  private readonly fallbackResources = new Map<string, { resource: ProjectResource; bytes: Uint8Array }>()
  private readonly pending = new Map<number, { resolve: (response: WorkerResponse) => void; reject: (error: Error) => void }>()
  private readonly writeQueue = new PersistenceWriteQueue()

  private readonly databasePath?: string
  private readonly migrationFailureVersion?: number
  private readonly migrationInterruptAfterVersion?: number

  constructor(options: SqliteProjectPersistenceOptions = {}) {
    this.databasePath = options.databasePath
    this.migrationFailureVersion = options.migrationFailureVersion
    this.migrationInterruptAfterVersion = options.migrationInterruptAfterVersion
    this.worker = typeof Worker === 'undefined' ? null : new Worker(new URL('./persistenceWorker.ts', import.meta.url), { type: 'module', name: 'bitbybit-persistence' })
    this.changeChannel = typeof BroadcastChannel === 'undefined' ? null : new BroadcastChannel('bitbybit-project-changes')
    ;(this.changeChannel as (BroadcastChannel & { unref?: () => void }) | null)?.unref?.()
    this.changeChannel?.addEventListener('message', (event: MessageEvent<ProjectChangeNotice>) => {
      const notice = event.data
      if (!notice || notice.senderId === this.senderId || notice.kind !== 'document-saved') return
      this.externalChangeListeners.forEach((listener) => listener({ ...notice }))
    })
    if (!this.worker) this.currentCapabilities = { mode: 'sqlite-memory', sqliteWasm: false, opfs: false, schemaVersion: 0, crossTabWriteLock: this.crossTabWriteLockMode(), reason: 'Worker is unavailable; using a transient in-memory project store.' }
    if (this.worker) {
      this.worker.onmessage = (event: MessageEvent<WorkerResponse>) => { const request = this.pending.get(event.data.id); if (!request) return; this.pending.delete(event.data.id); request.resolve(event.data) }
      this.worker.onerror = (event) => { const error = new Error(event.message || 'Persistence Worker failed.'); this.currentCapabilities = { ...unavailable, reason: error.message }; this.initialized = null; for (const request of this.pending.values()) request.reject(error); this.pending.clear() }
    }
  }

  capabilities() { return { ...this.currentCapabilities } }

  subscribeExternalChanges(listener: (notice: ProjectChangeNotice) => void): Unsubscribe {
    this.externalChangeListeners.add(listener)
    return () => this.externalChangeListeners.delete(listener)
  }

  initialize() {
    if (!this.worker) return Promise.resolve(this.currentCapabilities)
    if (!this.initialized) this.initialized = this.request({ type: 'initialize', databasePath: this.databasePath, migrationFailureVersion: this.migrationFailureVersion, migrationInterruptAfterVersion: this.migrationInterruptAfterVersion }).then((response) => { if (!response.ok || response.type !== 'initialized') throw new Error(response.ok ? 'Unexpected persistence response.' : response.error); this.currentCapabilities = { ...response.capabilities, crossTabWriteLock: this.crossTabWriteLockMode() }; return this.capabilities() })
    return this.initialized
  }

  async list() {
    await this.writeQueue.drain()
    await this.initialize()
    if (!this.worker) return [...this.fallbackSnapshots.values()].map(({ document, savedAt }) => ({ documentId: document.id, label: document.label, documentVersion: document.version, objectCount: document.objects.length, dirty: document.dirty, readOnly: document.readOnly, updatedAt: savedAt })).sort((left, right) => right.updatedAt - left.updatedAt)
    const response = await this.request({ type: 'list-projects' })
    if (!response.ok || response.type !== 'projects-listed') throw new Error(response.ok ? 'Unexpected persistence response.' : response.error)
    return response.projects.map((project) => ({ ...project }))
  }

  save(document: DocumentSnapshot) {
    return this.writeQueue.run(() => this.withCrossTabWriteLock(async () => {
      for (const object of document.objects) if (object.topology) validateTopologySnapshotForPersistence(object.topology)
      await this.initialize()
      if (!this.worker) {
        const snapshot = cloneDocument(document)
        const persistedAt = Date.now()
        this.fallbackSnapshots.set(snapshot.id, { document: snapshot, savedAt: persistedAt })
        const checkpoints = this.fallbackCheckpoints.get(snapshot.id) ?? new Map()
        checkpoints.set(snapshot.version, { document: cloneDocument(snapshot), savedAt: persistedAt })
        const retained = [...checkpoints.entries()].sort((left, right) => right[1].savedAt - left[1].savedAt || right[0] - left[0]).slice(0, 5)
        this.fallbackCheckpoints.set(snapshot.id, new Map(retained))
        const result = { documentId: snapshot.id, documentVersion: snapshot.version, persistedAt, mode: this.currentCapabilities.mode }
        this.announceSaved(result.documentId, result.documentVersion)
        return result
      }
      const response = await this.request({ type: 'save-document', document })
      if (!response.ok || response.type !== 'saved') throw new Error(response.ok ? 'Unexpected persistence response.' : response.error)
      const result = { documentId: response.documentId, documentVersion: response.documentVersion, persistedAt: response.persistedAt, mode: response.mode }
      this.announceSaved(result.documentId, result.documentVersion)
      return result
    }))
  }

  async load(documentId: string) {
    await this.writeQueue.drain()
    await this.initialize()
    if (!this.worker) { const snapshot = this.fallbackSnapshots.get(documentId); return snapshot ? cloneDocument(snapshot.document) : null }
    const response = await this.request({ type: 'load-document', documentId })
    if (!response.ok || response.type !== 'loaded') throw new Error(response.ok ? 'Unexpected persistence response.' : response.error)
    return response.document
  }

  async recovery(documentId: string): Promise<ProjectRecoveryReport> {
    await this.writeQueue.drain()
    await this.initialize()
    if (!this.worker) {
      const snapshot = this.fallbackSnapshots.get(documentId)?.document
      const checkpoints = [...(this.fallbackCheckpoints.get(documentId)?.entries() ?? [])].map(([version, value]) => ({ version, createdAt: value.savedAt })).sort((left, right) => right.createdAt - left.createdAt || right.version - left.version)
      return { documentId, mode: this.currentCapabilities.mode, schemaVersion: this.currentCapabilities.schemaVersion, integrity: snapshot ? 'ok' : 'unavailable', lastSavedVersion: snapshot?.version ?? null, dirtyAtLastSave: snapshot?.dirty ?? null, checkpoints, warnings: snapshot ? [] : ['No saved snapshot exists in the transient fallback store.'] }
    }
    const response = await this.request({ type: 'recovery-report', documentId })
    if (!response.ok || response.type !== 'recovery-report') throw new Error(response.ok ? 'Unexpected persistence response.' : response.error)
    return response.report
  }

  async loadCheckpoint(documentId: string, version?: number) {
    await this.writeQueue.drain()
    await this.initialize()
    if (!this.worker) {
      const checkpoints = [...(this.fallbackCheckpoints.get(documentId)?.entries() ?? [])]
      const selected = version === undefined
        ? checkpoints.sort((left, right) => right[1].savedAt - left[1].savedAt || right[0] - left[0])[0]?.[1]
        : this.fallbackCheckpoints.get(documentId)?.get(version)
      return selected ? cloneDocument(selected.document) : null
    }
    const response = await this.request({ type: 'load-checkpoint', documentId, version })
    if (!response.ok || response.type !== 'checkpoint-loaded') throw new Error(response.ok ? 'Unexpected persistence response.' : response.error)
    return response.document
  }

  readonly resource = {
    put: (bytes: Uint8Array, mediaType: string) => this.writeQueue.run(() => this.withCrossTabWriteLock(async () => {
      await this.initialize()
      if (!this.worker) {
        if (!globalThis.crypto?.subtle) throw new Error('SHA-256 is unavailable for project resource storage.')
        const digest = await globalThis.crypto.subtle.digest('SHA-256', bytes.slice().buffer)
        const hash = [...new Uint8Array(digest)].map((value) => value.toString(16).padStart(2, '0')).join('')
        const existing = this.fallbackResources.get(hash)
        const resource = { hash, byteLength: bytes.byteLength, mediaType, refCount: (existing?.resource.refCount || 0) + 1 }
        this.fallbackResources.set(hash, { resource, bytes: new Uint8Array(bytes) })
        return { ...resource }
      }
      const payload = bytes.slice()
      const response = await this.request({ type: 'put-resource', bytes: payload.buffer as ArrayBuffer, mediaType }, [payload.buffer as ArrayBuffer])
      if (!response.ok || response.type !== 'resource-put') throw new Error(response.ok ? 'Unexpected persistence response.' : response.error)
      return response.resource
    })),
    get: async (hash: string) => {
      await this.writeQueue.drain()
      await this.initialize()
      if (!this.worker) { const resource = this.fallbackResources.get(hash); return resource ? new Uint8Array(resource.bytes) : null }
      const response = await this.request({ type: 'get-resource', hash })
      if (!response.ok || response.type !== 'resource-get') throw new Error(response.ok ? 'Unexpected persistence response.' : response.error)
      return response.bytes ? new Uint8Array(response.bytes) : null
    },
    release: (hash: string) => this.writeQueue.run(() => this.withCrossTabWriteLock(async () => {
      await this.initialize()
      if (!this.worker) { const existing = this.fallbackResources.get(hash); if (!existing) return; if (existing.resource.refCount > 1) existing.resource.refCount -= 1; else this.fallbackResources.delete(hash); return }
      const response = await this.request({ type: 'release-resource', hash })
      if (!response.ok || response.type !== 'resource-released') throw new Error(response.ok ? 'Unexpected persistence response.' : response.error)
    })),
    sweep: () => this.writeQueue.run(() => this.withCrossTabWriteLock(async () => {
      await this.initialize()
      if (!this.worker) return { inspectedRecords: this.fallbackResources.size, inspectedFiles: this.fallbackResources.size, deletedRecords: 0, deletedFiles: 0, reclaimedBytes: 0, missingFiles: [], warnings: ['Transient resource storage has no separately enumerable orphan files.'] }
      const response = await this.request({ type: 'sweep-resources' })
      if (!response.ok || response.type !== 'resources-swept') throw new Error(response.ok ? 'Unexpected persistence response.' : response.error)
      return response.report
    })),
  }

  async dispose() {
    if (this.disposed) return
    await this.writeQueue.drain()
    if (this.worker) {
      await this.request({ type: 'dispose' })
      this.worker.terminate()
    }
    this.changeChannel?.close()
    this.disposed = true
  }

  private crossTabWriteLockMode(): 'web-locks' | 'local-queue' {
    return typeof navigator !== 'undefined' && 'locks' in navigator ? 'web-locks' : 'local-queue'
  }

  private withCrossTabWriteLock<T>(operation: () => Promise<T>): Promise<T> {
    if (typeof navigator === 'undefined' || !navigator.locks) return operation()
    return navigator.locks.request<Promise<T>>('bitbybit-project-write', { mode: 'exclusive' }, (lock) => {
      if (!lock) throw new Error('Unable to acquire the cross-tab project write lock.')
      return operation()
    }).then((result) => result)
  }

  private announceSaved(documentId: string, documentVersion: number) {
    this.changeChannel?.postMessage({ documentId, documentVersion, senderId: this.senderId, kind: 'document-saved' } satisfies ProjectChangeNotice)
  }

  private request(input: WorkerInput, transfer: Transferable[] = []) {
    if (this.disposed) return Promise.reject<WorkerResponse>(new Error('Persistence Worker has been disposed.'))
    if (!this.worker) return Promise.reject<WorkerResponse>(new Error(this.currentCapabilities.reason))
    const id = ++this.nextRequestId
    return new Promise<WorkerResponse>((resolve, reject) => {
      this.pending.set(id, { resolve, reject })
      this.worker?.postMessage({ ...input, id } satisfies WorkerRequest, transfer)
    })
  }
}

export const createSqliteProjectPersistence = (options: SqliteProjectPersistenceOptions = {}) => new SqliteProjectPersistence(options)
