// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========

import {
  buildTree,
  emitStartPath,
  keep,
  rstripSlash,
  startBasename,
  type FindOptions,
  type PathSpec,
  compareCodePoints,
} from '@struktoai/mirage-core'
import type { GridFSAccessor } from '../../accessor/gridfs.ts'
import {
  escapeRegex,
  filesColl,
  gridfsPrefix,
  iterLatest,
  prefixQuery,
  rawPathOf,
  stripKeyPrefix,
} from './_client.ts'

/**
 * Translate a find -name glob into a regex fragment matching one path
 * segment. Returns null for character classes we do not translate (the
 * caller falls back to the unpushed prefix query; client-side keep()
 * still applies the exact semantics).
 */
export function globRegex(pattern: string): string | null {
  if (pattern.includes('[') || pattern.includes(']')) return null
  let out = ''
  for (const ch of pattern) {
    if (ch === '*') out += '[^/]*'
    else if (ch === '?') out += '[^/]'
    else out += escapeRegex(ch)
  }
  return out
}

/**
 * Build the fs.files query, pushing -name/-iname/-type/-size server-side
 * when the translation is exact. Every condition is a superset of the GNU
 * semantics (directory markers always pass the size condition, unpushable
 * globs fall back to the prefix scan), so the client-side keep() pass
 * stays authoritative.
 */
export function buildQuery(
  pfx: string,
  options: FindOptions,
  pushdown: boolean,
): Record<string, unknown> {
  const conds: Record<string, unknown>[] = []
  const base = prefixQuery(pfx)
  if (Object.keys(base).length > 0) conds.push(base)
  if (pushdown) {
    const escaped = escapeRegex(pfx)
    const globs: [string | null | undefined, string][] = [
      [options.name, ''],
      [options.iname, 'i'],
    ]
    for (const [pat, flags] of globs) {
      if (pat === undefined || pat === null) continue
      const rx = globRegex(pat)
      if (rx === null) continue
      const regex: Record<string, unknown> = { $regex: `^${escaped}(.*/)?${rx}/?$` }
      if (flags !== '') regex.$options = flags
      conds.push({ filename: regex })
    }
    if (options.type === 'f') {
      conds.push({ filename: { $not: { $regex: '/$' } } })
    } else if (options.type === 'd') {
      conds.push({ filename: { $regex: '/$' } })
    }
    if (options.minSize != null || options.maxSize != null) {
      const sizeCond: Record<string, number> = {}
      if (options.minSize != null) sizeCond.$gte = options.minSize
      if (options.maxSize != null) sizeCond.$lte = options.maxSize
      // Directory markers ride through; the client-side dirs-count-as-0
      // rule decides their fate.
      conds.push({ $or: [{ length: sizeCond }, { filename: { $regex: '/$' } }] })
    }
  }
  if (conds.length === 0) return {}
  if (conds.length === 1) return conds[0] ?? {}
  return { $and: conds }
}

export async function find(
  accessor: GridFSAccessor,
  path: PathSpec,
  options: FindOptions = {},
): Promise<string[]> {
  const raw = rawPathOf(path)
  const startName = startBasename(path.virtual)
  const pfx = gridfsPrefix(raw, accessor.config)
  const rootKey = rstripSlash('/' + stripKeyPrefix(pfx, accessor.config)) || '/'
  const baseDepth = rootKey === '/' ? 0 : (rootKey.match(/\//g) ?? []).length
  const results: string[] = []
  const seenDirs = new Set<string>()
  const seen = { descendant: false, marker: false }
  const empty = options.empty === true
  // Narrowing beyond the prefix is only sound when directories cannot
  // appear in the output (-type f): implicit directories are synthesized
  // client-side from file paths, so any server-side file exclusion would
  // also drop the evidence for a matching parent directory.
  const pushdown =
    options.tree === undefined &&
    options.nameExclude === undefined &&
    options.orNames === undefined &&
    options.pathPattern === undefined &&
    !empty &&
    options.type === 'f'
  const tree =
    options.tree ??
    buildTree({
      name: options.name,
      iname: options.iname,
      pathPattern: options.pathPattern,
      type: options.type,
      nameExclude: options.nameExclude,
      orNames: options.orNames,
      empty: options.empty,
    })
  const query = buildQuery(pfx, options, pushdown)
  const narrowed = JSON.stringify(query) !== JSON.stringify(prefixQuery(pfx))
  for await (const doc of iterLatest(accessor, query)) {
    const key = doc.filename
    if (key === pfx) {
      seen.marker = true
      continue
    }
    seen.descendant = true
    const isDir = key.endsWith('/')
    const fullPath = rstripSlash('/' + stripKeyPrefix(key, accessor.config)) || '/'
    const size = doc.length
    if (isDir) {
      if (seenDirs.has(fullPath)) continue
      seenDirs.add(fullPath)
    }
    const entries: [string, 'f' | 'd'][] = [[fullPath, isDir ? 'd' : 'f']]
    // Implicit directories exist only as filename prefixes; synthesize the
    // parent chain so find agrees with readdir on externally-populated
    // buckets.
    let parent = fullPath.slice(0, fullPath.lastIndexOf('/')) || '/'
    while (parent !== rootKey && parent !== '/') {
      if (!seenDirs.has(parent)) {
        seenDirs.add(parent)
        entries.push([parent, 'd'])
      }
      parent = parent.slice(0, parent.lastIndexOf('/')) || '/'
    }
    for (const [ep, kind] of entries) {
      const entryName = ep.split('/').pop() ?? ''
      const depth = (ep.match(/\//g) ?? []).length - baseDepth
      if (options.maxDepth !== null && options.maxDepth !== undefined && depth > options.maxDepth) {
        continue
      }
      const isEmpty = !empty ? null : kind === 'd' ? false : size === 0
      if (!keep({ key: ep, name: entryName, kind, depth, isEmpty }, tree, options.minDepth)) {
        continue
      }
      if (options.minSize != null || options.maxSize != null) {
        // Directories count as size 0 for -size (deliberate GNU divergence).
        const effective = kind === 'd' ? 0 : size
        if (options.minSize != null && effective < options.minSize) continue
        if (options.maxSize != null && effective > options.maxSize) continue
      }
      results.push(ep)
    }
  }
  if (narrowed && !seen.descendant && !seen.marker) {
    // The narrowed query may have excluded every doc under a prefix that
    // does exist; probe so the start path still emits.
    const files = await filesColl(accessor)
    const probe = await files.findOne(
      pfx === '' ? {} : { filename: { $regex: `^${escapeRegex(pfx)}` } },
      { projection: { _id: 1 } },
    )
    seen.descendant = probe !== null
  }
  if (seen.descendant || seen.marker) {
    emitStartPath(results, rootKey, startName, {
      kind: 'd',
      isEmpty: empty ? !seen.descendant : null,
      exists: true,
      tree,
      maxDepth: options.maxDepth,
      minDepth: options.minDepth,
      minSize: options.minSize,
      maxSize: options.maxSize,
    })
  }
  // A file key and a synthesized directory can name the same path (`data/a`
  // alongside `data/a/b.txt`), which these stores allow and a filesystem does
  // not; find prints such a path once.
  return [...new Set(results)].sort(compareCodePoints)
}
