import express, { type Express, type RequestHandler } from 'express';
import cors from 'cors';
import path from 'path';
import { fileURLToPath } from 'url';
import { detectLanguageByPath } from '@paparats/shared';
import { buildProjectConfigFromContent } from './config.js';
import { Indexer, parseChunkId } from './indexer.js';
import { Searcher } from './searcher.js';
import { McpHandler } from './mcp-handler.js';
import { WatcherManager } from './watcher.js';
import type { MetadataStore } from './metadata-db.js';
import type { ProjectConfig } from './types.js';
import type { CachedEmbeddingProvider } from './embeddings.js';
import type { MetricsRegistry } from './metrics.js';
import type { Telemetry } from './telemetry/facade.js';
import type { AnalyticsStore } from './telemetry/analytics-store.js';
import { identityMiddleware } from './telemetry/identity-middleware.js';
import { tctx } from './telemetry/context.js';
import { buildAnalyticsRouter } from './analytics-api.js';

/** Run a promise with a timeout; reject with Error on timeout */
export async function withTimeout<T>(
  promise: Promise<T>,
  timeoutMs: number,
  errorMsg: string
): Promise<T> {
  let timer: ReturnType<typeof setTimeout> | undefined;
  const timeoutPromise = new Promise<never>((_, reject) => {
    timer = setTimeout(() => reject(new Error(errorMsg)), timeoutMs);
  });
  try {
    return await Promise.race([promise, timeoutPromise]);
  } finally {
    clearTimeout(timer);
  }
}

export const SEARCH_TIMEOUT_MS = 30_000;
export const INDEX_TIMEOUT_MS = 120_000;
export const FILE_CHANGED_TIMEOUT_MS = 60_000;

export interface RefreshGaugeMetricsOptions {
  metrics: MetricsRegistry;
  indexer: Indexer;
  searcher: Searcher;
  embeddingProvider: CachedEmbeddingProvider;
}

export interface GaugeSnapshot {
  groups: Record<string, number>;
  cacheStats: ReturnType<CachedEmbeddingProvider['getCacheStats']>;
  queryCacheStats: ReturnType<Searcher['getQueryCacheStats']>;
}

/** Push cache + collection counts into Prometheus gauges; returns the snapshot so /api/stats can reuse it. */
export async function refreshGaugeMetrics(
  opts: RefreshGaugeMetricsOptions
): Promise<GaugeSnapshot> {
  const { metrics, indexer, searcher, embeddingProvider } = opts;
  const groups = await indexer.listGroups();
  const cacheStats = embeddingProvider.getCacheStats();
  const queryCacheStats = searcher.getQueryCacheStats();

  metrics.setEmbeddingCacheSize(cacheStats.size);
  metrics.setEmbeddingCacheHitRate(cacheStats.hitRate);
  if (queryCacheStats) {
    metrics.setQueryCacheSize(queryCacheStats.size);
    metrics.setQueryCacheHitRate(queryCacheStats.hitRate);
  }
  metrics.setQdrantCollections(Object.keys(groups).length);

  return { groups, cacheStats, queryCacheStats };
}

/**
 * Build a basic-auth middleware from an env var "user:pass". Returns undefined
 * when unset so the caller can mount routes without the guard. Applied only to
 * /ui and /api/analytics — never the rest of the API.
 */
function buildUiBasicAuth(credential: string | undefined): RequestHandler | undefined {
  if (!credential || !credential.includes(':')) return undefined;
  const expected = 'Basic ' + Buffer.from(credential).toString('base64');
  return (req, res, next) => {
    if (req.headers.authorization === expected) {
      next();
      return;
    }
    res.set('WWW-Authenticate', 'Basic realm="paparats analytics"');
    res.status(401).send('Authentication required');
  };
}

/** Sanitize user-supplied string for safe logging (prevents log injection) */
function sanitizeForLog(s: string, maxLen = 200): string {
  const cleaned = String(s)
    // eslint-disable-next-line no-control-regex -- intentional: strip control chars for log safety
    .replace(/[\x00-\x1f\x7f]/g, ' ')
    .replace(/\s+/g, ' ')
    .trim();
  return cleaned.length > maxLen ? cleaned.slice(0, maxLen) + '…' : cleaned;
}

/** Default interval for polling Qdrant for new groups (ms) */
const GROUP_POLL_INTERVAL_MS = 2 * 60 * 1000; // 2 minutes

/** Options for createApp - all services required for the HTTP server */

export interface CreateAppOptions {
  searcher: Searcher;
  indexer: Indexer;
  watcherManager: WatcherManager;
  embeddingProvider: CachedEmbeddingProvider;
  /** Projects map - mutated by registerProject */
  projectsByGroup: Map<string, ProjectConfig[]>;
  /** Optional metadata store for git history */
  metadataStore?: MetadataStore;
  /** Optional metrics registry for Prometheus metrics */
  metrics?: MetricsRegistry;
  /** Optional telemetry facade (analytics, OTel) */
  telemetry?: Telemetry;
  /** Optional analytics store (used for direct read access in MCP analytics tools) */
  analytics?: AnalyticsStore;
}

export interface CreateAppResult {
  app: Express;
  mcpHandler: McpHandler;
  /** Call with true to simulate shutdown state (returns 503 on new requests) */
  setShuttingDown: (value: boolean) => void;
  /** Check if server is in shutdown state */
  getShuttingDown: () => boolean;
  /** Stop the periodic group poll timer */
  stopGroupPoll: () => void;
}

export function createApp(options: CreateAppOptions): CreateAppResult {
  const {
    searcher,
    indexer,
    watcherManager,
    embeddingProvider,
    projectsByGroup,
    metadataStore,
    metrics,
    telemetry,
    analytics,
  } = options;

  // ── Group discovery from Qdrant ───────────────────────────────────────────

  let groupSyncInFlight = false;

  /** Sync groups from Qdrant into projectsByGroup (adds missing, removes stale) */
  async function syncGroupsFromQdrant(): Promise<void> {
    if (groupSyncInFlight) return;
    groupSyncInFlight = true;
    try {
      const qdrantGroups = await indexer.listGroups();
      const qdrantNames = new Set(Object.keys(qdrantGroups));

      // Add/refresh groups + their projects discovered in Qdrant
      for (const name of qdrantNames) {
        const knownProjects = projectsByGroup.get(name);
        let projectsInGroup: Array<{ name: string; languages: string[] }> = [];
        try {
          projectsInGroup = await indexer.listProjectsInGroup(name);
        } catch {
          // Listing projects in a freshly-created or empty group can fail
          // before the first chunk is written. Leave the group empty in that
          // case — the next poll cycle will pick the projects up.
        }

        const desired = new Map<string, ProjectConfig>();
        for (const p of projectsInGroup) {
          const cfg = buildProjectConfigFromContent(p.name, name, {
            languages: p.languages.length > 0 ? p.languages : undefined,
          });
          desired.set(p.name, cfg);
        }

        // Preserve any projects registered explicitly via POST /api/index that
        // happen to share the group — explicit registration wins over the
        // auto-built config (it has real paths, watchers, etc.).
        for (const existing of knownProjects ?? []) {
          desired.set(existing.name, existing);
        }

        if (!knownProjects) {
          console.log(
            `[group-sync] Discovered group: ${name} (${qdrantGroups[name]} chunks, ${desired.size} project(s))`
          );
        }
        projectsByGroup.set(name, Array.from(desired.values()));
      }

      // Remove groups that no longer exist in Qdrant
      for (const name of projectsByGroup.keys()) {
        if (!qdrantNames.has(name)) {
          projectsByGroup.delete(name);
          console.log(`[group-sync] Removed stale group: ${name}`);
        }
      }
    } catch (err) {
      console.warn(`[group-sync] Failed to sync groups: ${(err as Error).message}`);
    } finally {
      groupSyncInFlight = false;
    }
  }

  // Periodic poll — discover groups created by external indexer
  const groupPollTimer = setInterval(() => {
    syncGroupsFromQdrant().catch(() => {});
  }, GROUP_POLL_INTERVAL_MS);
  groupPollTimer.unref();

  function stopGroupPoll(): void {
    clearInterval(groupPollTimer);
  }

  function getGroupNames(): string[] {
    const names = Array.from(projectsByGroup.keys());
    // Lazy fallback: if empty, trigger async refresh for next call
    if (names.length === 0) {
      syncGroupsFromQdrant().catch(() => {});
    }
    return names;
  }

  function getProjects(): Map<string, ProjectConfig[]> {
    return projectsByGroup;
  }

  function registerProject(project: ProjectConfig): void {
    const existing = projectsByGroup.get(project.group) ?? [];
    const filtered = existing.filter((p) => p.name !== project.name);
    filtered.push(project);
    projectsByGroup.set(project.group, filtered);
  }

  function unregisterProject(group: string, projectName: string): void {
    const projects = projectsByGroup.get(group);
    if (projects) {
      const filtered = projects.filter((p) => p.name !== projectName);
      if (filtered.length > 0) {
        projectsByGroup.set(group, filtered);
      } else {
        projectsByGroup.delete(group);
      }
    }
  }

  let shuttingDown = false;
  const setShuttingDown = (value: boolean): void => {
    shuttingDown = value;
  };
  const getShuttingDown = (): boolean => shuttingDown;

  const app = express();
  app.use(cors());
  app.use(express.json({ limit: '50mb' }));
  app.use(identityMiddleware());

  app.use((req, res, next) => {
    if (shuttingDown) {
      res.status(503).json({ error: 'Server is shutting down' });
      return;
    }
    next();
  });

  // ── POST /api/search ───────────────────────────────────────────────────────

  app.post('/api/search', async (req, res) => {
    try {
      const { group, query, project, limit } = req.body;

      if (!query) {
        res.status(400).json({ error: 'query is required' });
        return;
      }
      if (!group) {
        res.status(400).json({ error: 'group is required' });
        return;
      }

      const response = await withTimeout(
        searcher.search(group, query, { project, limit }),
        SEARCH_TIMEOUT_MS,
        'Search timeout'
      );
      res.json(response);
    } catch (err) {
      if ((err as Error).message === 'Search timeout') {
        res.status(504).json({ error: 'Search request timed out after 30s' });
      } else {
        console.error('[api] Search error:', err);
        res.status(500).json({ error: (err as Error).message });
      }
    }
  });

  // ── POST /api/index (content-based) ─────────────────────────────────────────

  app.post('/api/index', async (req, res) => {
    try {
      const { group, project: projectName, config: apiConfig, files, force } = req.body;

      if (!group || !projectName || !Array.isArray(files)) {
        res.status(400).json({ error: 'group, project, and files (array) are required' });
        return;
      }

      const project = buildProjectConfigFromContent(projectName, group, apiConfig);
      registerProject(project);

      if (force) {
        await indexer.deleteProjectChunks(group, projectName);
      }

      console.log(
        `[api] Indexing ${sanitizeForLog(project.group)}/${sanitizeForLog(project.name)} (${files.length} files)...`
      );
      const chunks = await withTimeout(
        indexer.indexFilesContent(project, files),
        INDEX_TIMEOUT_MS,
        'Index timeout'
      );

      res.json({
        status: 'ok',
        group: project.group,
        project: project.name,
        chunks,
        skipped: indexer.stats.skipped,
      });
    } catch (err) {
      if ((err as Error).message === 'Index timeout') {
        res.status(504).json({ error: 'Index request timed out after 120s' });
      } else {
        console.error('[api] Index error:', err);
        res.status(500).json({ error: (err as Error).message });
      }
    }
  });

  // ── POST /api/file-changed (content-based) ─────────────────────────────────

  app.post('/api/file-changed', async (req, res) => {
    try {
      const { group, project: projectName, path: filePath, content, language } = req.body;

      if (!group || !projectName || !filePath || content === undefined) {
        res.status(400).json({ error: 'group, project, path, and content are required' });
        return;
      }

      const projects = projectsByGroup.get(group);
      const project = projects?.find((p) => p.name === projectName);

      if (!project) {
        res.status(400).json({ error: `Unknown project: ${group}/${projectName}` });
        return;
      }

      const lang =
        language ?? detectLanguageByPath(filePath, content) ?? project.languages[0] ?? 'generic';
      await withTimeout(
        indexer.updateFileContent(group, projectName, filePath, content, lang, project),
        FILE_CHANGED_TIMEOUT_MS,
        'File-changed timeout'
      );

      res.json({ status: 'ok', message: 'File reindexed' });
    } catch (err) {
      if ((err as Error).message === 'File-changed timeout') {
        res.status(504).json({ error: 'File-changed request timed out after 60s' });
      } else {
        console.error('[api] File-changed error:', err);
        res.status(500).json({ error: (err as Error).message });
      }
    }
  });

  // ── POST /api/file-deleted (content-based) ──────────────────────────────────

  app.post('/api/file-deleted', async (req, res) => {
    try {
      const { group, project: projectName, path: filePath } = req.body;

      if (!group || !projectName || !filePath) {
        res.status(400).json({ error: 'group, project, and path are required' });
        return;
      }

      const projects = projectsByGroup.get(group);
      const project = projects?.find((p) => p.name === projectName);

      if (!project) {
        res.status(400).json({ error: `Unknown project: ${group}/${projectName}` });
        return;
      }

      await withTimeout(
        indexer.deleteFileByPath(group, projectName, filePath),
        FILE_CHANGED_TIMEOUT_MS,
        'File-deleted timeout'
      );

      res.json({ status: 'ok', message: 'File removed from index' });
    } catch (err) {
      if ((err as Error).message === 'File-deleted timeout') {
        res.status(504).json({ error: 'File-deleted request timed out after 60s' });
      } else {
        console.error('[api] File-deleted error:', err);
        res.status(500).json({ error: (err as Error).message });
      }
    }
  });

  // ── GET /api/chunk/:chunkId ─────────────────────────────────────────────────

  app.get('/api/chunk/:chunkId', async (req, res) => {
    try {
      const chunkId = req.params.chunkId;
      if (!chunkId) {
        res.status(400).json({ error: 'chunkId parameter is required' });
        return;
      }

      const radiusLines = Math.min(
        200,
        Math.max(0, parseInt(String(req.query.radius_lines ?? '0'), 10) || 0)
      );

      // Validate group from chunkId is among registered groups
      const parsed = parseChunkId(chunkId);
      if (parsed && !projectsByGroup.has(parsed.group)) {
        res.status(403).json({ error: 'Access denied: unknown group' });
        return;
      }

      const payload = await indexer.getChunkById(chunkId);
      if (!payload) {
        res.status(404).json({ error: 'Chunk not found' });
        return;
      }

      const result: Record<string, unknown> = { ...payload };

      if (radiusLines > 0 && parsed) {
        const adjacent = await indexer.getAdjacentChunks(
          parsed.group,
          parsed.project,
          parsed.file,
          payload['startLine'] as number,
          payload['endLine'] as number,
          radiusLines
        );
        result.adjacent_chunks = adjacent;
      }

      res.json(result);
    } catch (err) {
      console.error('[api] Chunk retrieval error:', err);
      res.status(500).json({ error: 'Internal server error' });
    }
  });

  // ── GET /api/chunk/:chunkId/meta ───────────────────────────────────────────

  app.get('/api/chunk/:chunkId/meta', async (req, res) => {
    try {
      const chunkId = req.params.chunkId;
      if (!chunkId) {
        res.status(400).json({ error: 'chunkId parameter is required' });
        return;
      }

      if (!metadataStore) {
        res.status(501).json({ error: 'Metadata store is not configured' });
        return;
      }

      // Validate group from chunkId is among registered groups
      const parsed = parseChunkId(chunkId);
      if (parsed && !projectsByGroup.has(parsed.group)) {
        res.status(403).json({ error: 'Access denied: unknown group' });
        return;
      }

      const commitLimit = Math.min(
        50,
        Math.max(1, parseInt(String(req.query.commit_limit ?? '10'), 10) || 10)
      );

      const payload = await indexer.getChunkById(chunkId);
      if (!payload) {
        res.status(404).json({ error: 'Chunk not found' });
        return;
      }

      const commits = metadataStore.getCommits(chunkId, commitLimit);
      const tickets = metadataStore.getTickets(chunkId);
      const latestCommit = metadataStore.getLatestCommit(chunkId);

      res.json({
        ...payload,
        commits,
        tickets,
        latest_commit: latestCommit,
      });
    } catch (err) {
      console.error('[api] Chunk meta retrieval error:', err);
      res.status(500).json({ error: 'Internal server error' });
    }
  });

  // ── DELETE /api/project/:group/:name ──────────────────────────────────────

  app.delete('/api/project/:group/:name', async (req, res) => {
    try {
      const { group, name: projectName } = req.params;

      if (!group || !projectName) {
        res.status(400).json({ error: 'group and name are required' });
        return;
      }

      // Delete chunks from Qdrant
      await indexer.deleteProjectChunks(group, projectName);

      // Delete metadata from SQLite
      metadataStore?.deleteByProject(group, projectName);

      // Invalidate query cache for the group
      searcher.invalidateGroupCache(group);

      // Remove from in-memory project registry
      unregisterProject(group, projectName);

      console.log(`[api] Deleted project ${sanitizeForLog(group)}/${sanitizeForLog(projectName)}`);
      res.json({ status: 'ok', message: `Project ${group}/${projectName} deleted` });
    } catch (err) {
      console.error('[api] Project deletion error:', err);
      res.status(500).json({ error: (err as Error).message });
    }
  });

  // ── GET /health ────────────────────────────────────────────────────────────

  app.get('/health', async (_req, res) => {
    try {
      const groups = await indexer.listGroups();
      const mem = process.memoryUsage();
      const memPercent = Math.round((mem.heapUsed / mem.heapTotal) * 100);
      const status = memPercent > 90 ? 'degraded' : 'ok';

      res.json({
        status,
        groups,
        projectScope: searcher.getProjectScope(),
        uptime: process.uptime(),
        memory: {
          heapUsed: Math.round(mem.heapUsed / 1024 / 1024) + 'MB',
          heapTotal: Math.round(mem.heapTotal / 1024 / 1024) + 'MB',
          percent: memPercent,
        },
      });
    } catch (err) {
      res.status(503).json({ status: 'error', error: (err as Error).message });
    }
  });

  // ── GET /api/stats ─────────────────────────────────────────────────────────

  app.get('/api/stats', async (_req, res) => {
    try {
      const watcherStats = watcherManager.getStats();
      const usage = searcher.getUsageStats();

      let groups: Record<string, number>;
      let cacheStats: ReturnType<CachedEmbeddingProvider['getCacheStats']>;
      let queryCacheStats: ReturnType<Searcher['getQueryCacheStats']>;
      if (metrics) {
        const snapshot = await refreshGaugeMetrics({
          metrics,
          indexer,
          searcher,
          embeddingProvider,
        });
        groups = snapshot.groups;
        cacheStats = snapshot.cacheStats;
        queryCacheStats = snapshot.queryCacheStats;
      } else {
        groups = await indexer.listGroups();
        cacheStats = embeddingProvider.getCacheStats();
        queryCacheStats = searcher.getQueryCacheStats();
      }

      const ident = tctx.getOrAnonymous();
      res.json({
        identity: {
          user: ident.user,
          session: ident.session,
          client: ident.client,
          anchorProject: ident.anchorProject,
          requestId: ident.requestId,
        },
        telemetry: telemetry ? 'enabled' : 'disabled',
        groups,
        projectScope: searcher.getProjectScope(),
        registeredProjects: Object.fromEntries(
          Array.from(projectsByGroup.entries()).map(([g, ps]) => [g, ps.map((p) => p.name)])
        ),
        cache: cacheStats,
        queryCache: queryCacheStats,
        watcher: watcherStats,
        memory: process.memoryUsage(),
        usage,
      });
    } catch (err) {
      res.status(500).json({ error: (err as Error).message });
    }
  });

  // ── GET /metrics (Prometheus) ──────────────────────────────────────────────

  if (metrics) {
    app.get('/metrics', metrics.getMetricsHandler());
  }

  // ── Analytics UI ──────────────────────────────────────────────────────────
  // /ui (static dashboard) and /api/analytics (aggregated JSON) are scoped
  // behind optional basic-auth so the rest of the API and MCP routes stay
  // open for clients that don't speak HTTP auth.

  const uiAuth = buildUiBasicAuth(process.env['PAPARATS_UI_BASIC_AUTH']);
  const uiDir = path.resolve(fileURLToPath(import.meta.url), '..', '..', 'ui');

  const uiHandlers: RequestHandler[] = uiAuth ? [uiAuth] : [];
  app.use('/ui', ...uiHandlers, express.static(uiDir, { extensions: ['html'] }));
  app.use('/api/analytics', ...uiHandlers, buildAnalyticsRouter({ indexer, analytics }));

  // ── MCP transports ─────────────────────────────────────────────────────────

  const mcpHandler = new McpHandler({
    searcher,
    indexer,
    getProjects,
    getGroupNames,
    removeProject: unregisterProject,
    syncGroups: syncGroupsFromQdrant,
    metadataStore,
    telemetry,
    analytics,
  });
  mcpHandler.mount(app);

  return { app, mcpHandler, setShuttingDown, getShuttingDown, stopGroupPoll };
}
