import {
  collection,
  doc,
  onSnapshot,
  addDoc,
  getDoc,
  getDocs,
  updateDoc,
  query,
  orderBy,
  limit,
  serverTimestamp,
  where,
  Timestamp,
  arrayUnion,
  arrayRemove,
  FieldPath,
  type DocumentData,
  type FirestoreDataConverter,
  type QueryDocumentSnapshot,
  type SnapshotOptions,
  type Unsubscribe,
  QueryFieldFilterConstraint,
} from 'firebase/firestore';
import { firestoreDb } from '@/firebase';
import {
  isContainerType,
  ParticleSchema,
  UnknownParticleSchema,
} from '@/api/types';
import type {
  Particle,
  ParticleType,
  ParticlePropertiesMap,
  Reactions,
} from '@/api/types';

// --- Converter ---

// Firestore stores timestamps as `Timestamp`; zod expects `Date`. Coerce the
// per-type property fields that carry timestamps.
function coerceLeafPropertyDates(
  type: ParticleType,
  properties: DocumentData | undefined,
): DocumentData | undefined {
  if (!properties) return properties;
  if (type === 'text' && properties.edited_at) {
    return {
      ...properties,
      edited_at: (properties.edited_at as Timestamp).toDate(),
    };
  }
  return properties;
}

const particleConverter: FirestoreDataConverter<Particle> = {
  toFirestore(particle: Particle): DocumentData {
    const { id: _id, created_at, updated_at, ...rest } = particle;
    const deletedAt =
      'deleted_at' in particle ? particle.deleted_at : undefined;
    return {
      ...rest,
      created_at: Timestamp.fromDate(created_at),
      ...(updated_at && { updated_at: Timestamp.fromDate(updated_at) }),
      ...(deletedAt && { deleted_at: Timestamp.fromDate(deletedAt) }),
    };
  },
  fromFirestore(
    snap: QueryDocumentSnapshot,
    options?: SnapshotOptions,
  ): Particle {
    const raw = snap.data(options);
    if (typeof raw.type !== 'string') {
      throw new Error(`Invalid particle type: ${raw.type}`);
    }
    const type = raw.type as ParticleType;
    switch (type) {
      case 'stream':
        return ParticleSchema.parse({
          id: snap.id,
          type: raw.type,
          properties: raw.properties,
          status: raw.status ?? undefined,
          created_at: (raw.created_at as Timestamp).toDate(),
          created_by_human_id: raw.created_by_human_id,
          updated_at: raw.updated_at
            ? (raw.updated_at as Timestamp).toDate()
            : undefined,
          visible_to: raw.visible_to,
          playback_markers: raw.playback_markers
            ? Object.fromEntries(
                Object.entries(raw.playback_markers).map(([key, value]) => [
                  key,
                  (value as Timestamp).toDate(),
                ]),
              )
            : undefined,
          last_child_created_at: raw.last_child_created_at
            ? (raw.last_child_created_at as Timestamp).toDate()
            : undefined,
          huddle_active_participants:
            raw.huddle_active_participants ?? undefined,
        });
      case 'folder':
        return ParticleSchema.parse({
          id: snap.id,
          type: raw.type,
          properties: raw.properties,
          created_at: (raw.created_at as Timestamp).toDate(),
          created_by_human_id: raw.created_by_human_id,
          updated_at: raw.updated_at
            ? (raw.updated_at as Timestamp).toDate()
            : undefined,
          visible_to: raw.visible_to,
          last_child_created_at: raw.last_child_created_at
            ? (raw.last_child_created_at as Timestamp).toDate()
            : undefined,
        });
      case 'media':
      case 'file':
      case 'text':
      case 'task':
      case 'paper': {
        const properties = coerceLeafPropertyDates(type, raw.properties);
        return ParticleSchema.parse({
          id: snap.id,
          type: raw.type,
          properties,
          created_at: (raw.created_at as Timestamp).toDate(),
          created_by_human_id: raw.created_by_human_id,
          updated_at: raw.updated_at
            ? (raw.updated_at as Timestamp).toDate()
            : undefined,
          reactions: raw.reactions ?? undefined,
          deleted_at: raw.deleted_at
            ? (raw.deleted_at as Timestamp).toDate()
            : undefined,
          deleted_by_human_id: raw.deleted_by_human_id ?? undefined,
        });
      }
      default:
        // Forward compatibility: keep unrecognized types visible as
        // placeholders instead of breaking the snapshot they arrive in.
        return UnknownParticleSchema.parse({
          id: snap.id,
          type: 'unknown',
          raw_type: raw.type,
          created_at: (raw.created_at as Timestamp).toDate(),
          created_by_human_id: raw.created_by_human_id,
          updated_at: raw.updated_at
            ? (raw.updated_at as Timestamp).toDate()
            : undefined,
        });
    }
  },
};

// Corrupt docs (malformed base fields, schema parse failures) shouldn't take
// down a whole subscription — skip just the bad doc and keep the rest.
function safeData(snap: QueryDocumentSnapshot<Particle>): Particle | null {
  try {
    return snap.data();
  } catch (err) {
    console.warn(`Skipping unparseable particle at ${snap.ref.path}:`, err);
    return null;
  }
}

// --- Typed reference helpers ---

function typedDoc(path: string) {
  return doc(firestoreDb, path).withConverter(particleConverter);
}

function typedCollection(path: string) {
  return collection(firestoreDb, path).withConverter(particleConverter);
}

// --- Exported operations ---

export function subscribeToParticle(
  docPath: string,
  onData: (particle: Particle | null) => void,
  onError: (error: Error) => void,
): Unsubscribe {
  return onSnapshot(
    typedDoc(docPath),
    (snap) => {
      onData(snap.exists() ? safeData(snap) : null);
    },
    onError,
  );
}

export async function getParticle(docPath: string): Promise<Particle | null> {
  const doc = await getDoc(typedDoc(docPath));
  if (!doc.exists()) {
    return null;
  }

  return safeData(doc);
}

export interface GetParticleChildrenOptions {
  orderByField: string;
  orderDirection: 'asc' | 'desc';
}

export async function getParticleChildren(
  collectionPath: string,
  {
    orderByField = 'created_at',
    orderDirection = 'asc',
  }: GetParticleChildrenOptions = {
    orderByField: 'created_at',
    orderDirection: 'asc',
  },
): Promise<Particle[]> {
  const q = query(
    typedCollection(collectionPath),
    orderBy(orderByField, orderDirection),
  );
  const snap = await getDocs(q);
  return snap.docs.flatMap((d) => safeData(d) ?? []);
}

export interface SubscribeToParticleChildrenOptions {
  onData: (children: Particle[]) => void;
  onError: (error: Error) => void;
  visibilityScopes?: string[];
  orderByField?: string;
  orderDirection?: 'asc' | 'desc';
  onAdded?: (child: Particle) => void;
  onRemoved?: (child: Particle, updatedChildren: Particle[]) => void;
  whereFilter?: QueryFieldFilterConstraint;
  /** Optional cap on results. Applied after order/where constraints. */
  limit?: number;
}

export function subscribeToParticleChildren(
  collectionPath: string,
  {
    onData,
    onError,
    visibilityScopes = [],
    orderByField = 'created_at',
    orderDirection = 'desc',
    onAdded,
    onRemoved,
    whereFilter,
    limit: limitValue,
  }: SubscribeToParticleChildrenOptions,
): Unsubscribe {
  let q = query(
    typedCollection(collectionPath),
    orderBy(orderByField, orderDirection),
  );
  if (visibilityScopes.length > 0) {
    q = query(q, where('visible_to', 'array-contains-any', visibilityScopes));
  }
  if (whereFilter) {
    q = query(q, whereFilter);
  }
  if (limitValue !== undefined) {
    q = query(q, limit(limitValue));
  }
  return onSnapshot(
    q,
    (snap) => {
      const updatedChildren = snap.docs.flatMap((d) => safeData(d) ?? []);
      onData(updatedChildren);

      if (onAdded || onRemoved) {
        for (const change of snap.docChanges()) {
          const child = safeData(change.doc);
          if (!child) continue;
          if (change.type === 'added' && onAdded) onAdded(child);
          if (change.type === 'removed' && onRemoved)
            onRemoved(child, updatedChildren);
        }
      }
    },
    onError,
  );
}

export function subscribeToLatestChild(
  collectionPath: string,
  onData: (child: Particle | null) => void,
  onError: (error: Error) => void,
): Unsubscribe {
  const q = query(
    typedCollection(collectionPath),
    orderBy('created_at', 'desc'),
    limit(1),
  );
  return onSnapshot(
    q,
    (snap) => {
      onData(snap.empty ? null : safeData(snap.docs[0]));
    },
    onError,
  );
}

// Best-effort bump of the parent container's last_child_created_at when a
// child is created. The particle processor worker does this for stream
// parents, but skips folders, so the client keeps folder activity fresh
// itself. Same value semantics as the worker: the child's created_at, so it
// stays directly comparable with playback markers.
function bumpParentLastChildCreatedAt(
  collectionPath: string,
  childCreatedAt: Date,
): void {
  const parentDocPath = collectionPath.replace(/\/children$/, '');
  // The network root (networks/{id}) is not a particle doc — nothing to bump.
  if (!parentDocPath.includes('/children/')) return;
  updateDoc(doc(firestoreDb, parentDocPath), {
    last_child_created_at: Timestamp.fromDate(childCreatedAt),
  }).catch(() => {
    // Non-fatal: ordering freshness only.
  });
}

// This creates a new particle document with the given properties and returns its ID.
export async function createParticle<T extends ParticleType>(
  collectionPath: string,
  type: T,
  properties: ParticlePropertiesMap[T],
  createdByHumanId: string,
  // Must be passed for container types
  visibleTo?: string[],
): Promise<string> {
  if (isContainerType(type) && (!visibleTo || visibleTo.length === 0)) {
    throw new Error(
      `visibleTo is required for container type ${type} and cannot be empty`,
    );
  }

  const createdAt = new Date();
  const particle: Particle = ParticleSchema.parse({
    id: '', // ignored by toFirestore, but needed to satisfy the type
    type,
    properties,
    created_at: createdAt,
    created_by_human_id: createdByHumanId,
    ...(visibleTo ? { visible_to: visibleTo } : {}),
    // Containers start with last_child_created_at = created_at so they appear
    // in activity-ordered queries before they have any children (Firestore
    // orderBy drops docs missing the field).
    ...(isContainerType(type) ? { last_child_created_at: createdAt } : {}),
  });
  const ref = await addDoc(typedCollection(collectionPath), particle);
  bumpParentLastChildCreatedAt(collectionPath, createdAt);
  return ref.id;
}

export async function createStreamParticle(
  collectionPath: string,
  properties: ParticlePropertiesMap['stream'],
  createdByHumanId: string,
  visibleTo?: string[],
): Promise<string> {
  if (!visibleTo || visibleTo.length === 0) {
    throw new Error('visibleTo is required for streams and cannot be empty');
  }

  const createdAt = new Date();
  const particle: Particle = ParticleSchema.parse({
    id: '',
    type: 'stream',
    properties,
    status: 'open',
    created_at: createdAt,
    created_by_human_id: createdByHumanId,
    visible_to: visibleTo,
    last_child_created_at: createdAt,
  });
  const ref = await addDoc(typedCollection(collectionPath), particle);
  bumpParentLastChildCreatedAt(collectionPath, createdAt);
  return ref.id;
}

export async function updateStreamStatus(
  docPath: string,
  status: 'open' | 'closed',
): Promise<void> {
  await updateDoc(typedDoc(docPath), {
    status,
    updated_at: serverTimestamp(),
  });
}

// This allows updating properties without overwriting the entire properties object
export async function updateParticleProperties<T extends ParticleType>(
  docPath: string,
  properties: Partial<ParticlePropertiesMap[T]>,
): Promise<void> {
  const particleRef = typedDoc(docPath);
  // Take the partial and create a new object with dot notation
  // e.g. { title: "New Title" } becomes { "properties.title": "New Title" }
  const updatedProperties: Record<string, any> = {}; // eslint-disable-line @typescript-eslint/no-explicit-any
  for (const key in properties) {
    updatedProperties[`properties.${key}`] = properties[key];
  }
  await updateDoc(particleRef, {
    ...updatedProperties,
    updated_at: serverTimestamp(),
  });
}

// Edits the body of a text particle and stamps `properties.edited_at` so
// readers can see that the message was edited (distinct from `updated_at`,
// which is bumped by any write — visibility, reactions, etc.).
export async function editTextParticleContent(
  docPath: string,
  content: string,
): Promise<void> {
  const particleRef = typedDoc(docPath);
  await updateDoc(particleRef, {
    'properties.content': content,
    'properties.edited_at': serverTimestamp(),
    updated_at: serverTimestamp(),
  });
}

export async function updateParticleVisibleTo(
  docPath: string,
  visibleTo: string[],
): Promise<void> {
  const particleRef = typedDoc(docPath);
  const particle = await getParticle(docPath);
  if (!particle) {
    throw new Error(`Particle not found at path: ${docPath}`);
  }
  if (!isContainerType(particle.type)) {
    throw new Error(
      `Only container particles can have visible_to field. Particle at ${docPath} is of type ${particle.type}`,
    );
  }

  await updateDoc(particleRef, {
    visible_to: visibleTo,
    updated_at: serverTimestamp(),
  });
}

// CAUTION: use the other type safe update functions in most cases
// There is no checking whether this field actually exists on the particle type, so it can lead to inconsistent data if used incorrectly
export async function updateParticle(
  docPath: string,
  fieldName: string,
  value: any, // eslint-disable-line @typescript-eslint/no-explicit-any
): Promise<void> {
  const particleRef = typedDoc(docPath);
  await updateDoc(particleRef, {
    [fieldName]: value,
    updated_at: serverTimestamp(),
  });
}

/**
 * Soft-delete (tombstone) a non-container particle. The Firestore doc stays
 * in place so concurrent viewers see the deletion inline rather than being
 * bumped to an adjacent particle. Idempotent — re-calling on an already
 * tombstoned doc just refreshes the timestamp.
 */
export async function softDeleteParticle(
  docPath: string,
  humanId: string,
): Promise<void> {
  const particleRef = typedDoc(docPath);
  await updateDoc(particleRef, {
    deleted_at: serverTimestamp(),
    deleted_by_human_id: humanId,
    updated_at: serverTimestamp(),
  });
}

export async function updateStreamPlaybackMarker(
  docPath: string,
  humanId: string,
  playbackPositionAt: Date,
): Promise<void> {
  const particleRef = typedDoc(docPath);
  const markerField = `playback_markers.${humanId}`;
  await updateDoc(particleRef, {
    [markerField]: Timestamp.fromDate(playbackPositionAt),
    updated_at: serverTimestamp(),
  });
}

// Strip characters that Firestore's dotted field-path syntax treats specially.
// Using FieldPath bypasses the parser, but we also forbid these in stored keys
// so reaction maps stay portable across any future read path.
const RESERVED_REACTION_CHARS = /[~*/[\]]/g;

export function sanitizeReactionText(text: string): string {
  return text.replace(RESERVED_REACTION_CHARS, '');
}

export async function toggleParticleReaction(
  docPath: string,
  emoji: string,
  humanId: string,
  currentReactions?: Reactions,
): Promise<void> {
  const key = sanitizeReactionText(emoji);
  if (!key) return;
  const particleRef = typedDoc(docPath);
  const alreadyReacted = currentReactions?.[key]?.includes(humanId) ?? false;
  await updateDoc(
    particleRef,
    new FieldPath('reactions', key),
    alreadyReacted ? arrayRemove(humanId) : arrayUnion(humanId),
    'updated_at',
    serverTimestamp(),
  );
}
