import { type Application, type Request, type Response } from 'express';
import { type Repository } from '../db/repository.js';
import type { BrowserSessionRepo } from '../db/browser-session-repo.js';
import { convertToCron, calcNextRun, toSqliteDatetime } from '../scheduler.js';
import { type Scheduler } from '../scheduler.js';
import { logger } from '../logger.js';
import { canEditInSpace } from './visibility.js';

export interface ScheduledTasksApiOptions {
  /**
   * Optional. When set, accepting browserSessionProfileId on create / update
   * verifies the profile belongs to the requesting user. Without it, the
   * field is silently dropped (legacy / no-auth deployments).
   */
  sessRepo?: BrowserSessionRepo;
  /**
   * Whether the auth subsystem is wired. When `false` (no-auth single-user
   * deployment) requests carry no `req.user`, so new scheduled tasks (and the
   * jobs the scheduler spawns from them) are owned by the stable `local` user
   * instead of NULL — keeping per-task reflection / visibility consistent with
   * the rest of the no-auth path. Defaults to `true`.
   */
  authActive?: boolean;
}

export function mountScheduledTasksApi(
  app: Application,
  repo: Repository,
  scheduler: Scheduler,
  apiOpts: ScheduledTasksApiOptions = {},
): void {
  const { sessRepo } = apiOpts;
  const noAuthOwner: string | null = (apiOpts.authActive ?? true) ? null : 'local';

  /**
   * Validate and resolve a browserSessionProfileId from a request body.
   * Returns:
   *   - { ok: true, value: number | null } when accepted (null = unset / clear).
   *   - { ok: false, error } when validation fails (caller sends 400).
   * Pass an undefined raw to skip validation entirely (PATCH "field absent" case).
   *
   * The profile must be OWNED BY the requesting user. The scheduler spawns the
   * job as the schedule's owner and the Worker enforces job.ownerId ===
   * profile.ownerId (assertProfileOwner, fail-closed), so a profile owned by
   * anyone else would fail at runtime — reject it here instead of accepting a
   * schedule that can never execute. The separate confused-deputy guard (a
   * non-owner editing WHAT a space schedule runs while keeping the owner's
   * profile) is enforced in the PATCH handler.
   */
  function resolveBrowserSessionProfileId(
    raw: unknown,
    user: Express.User | undefined,
  ): { ok: true; value: number | null } | { ok: false; error: string } {
    if (raw === undefined) return { ok: true, value: null };
    if (raw === null || raw === '') return { ok: true, value: null };
    const n = Number(raw);
    if (!Number.isInteger(n) || n <= 0) {
      return { ok: false, error: 'browserSessionProfileId must be a positive integer' };
    }
    if (sessRepo) {
      if (!user?.id) {
        return { ok: false, error: 'browserSessionProfileId requires an authenticated user' };
      }
      const owned = sessRepo.getProfileById(n, user.id);
      if (!owned) {
        return { ok: false, error: 'browser session profile not found or not owned by you' };
      }
    }
    return { ok: true, value: n };
  }

  /**
   * Who may MANAGE (edit / pause / delete / trigger) a scheduled task:
   *  - admin, or the schedule's own owner (legacy behavior), OR
   *  - for a SPACE-bound schedule: any owner/admin/editor of that space.
   * Space schedules are space ASSETS, so they survive the creator leaving and
   * the space's members can take them over or stop them (no auto-deactivation).
   * This also gives the space a control path to stop a schedule whose creator
   * lost edit rights. No-auth (no viewer) = sole operator, allowed.
   */
  async function canManageSchedule(
    viewer: Express.User | undefined,
    sched: { ownerId: string | null; spaceId: string | null },
  ): Promise<boolean> {
    if (!viewer) return true; // no-auth: sole operator
    if (viewer.role === 'admin') return true;
    if (sched.spaceId) {
      // SPACE schedule: governed by CURRENT space edit rights, NOT the original
      // creator's identity. A creator who left or was demoted loses all control
      // — including manual trigger (which would write to the shared files tree),
      // so the create-time canEditInSpace gate can't be bypassed afterward. The
      // remaining space owner/admin/editors keep control (no auto-delete; the
      // schedule survives the creator leaving and the space manages it).
      const space = await repo.getSpace(sched.spaceId);
      if (!space) return false;
      const memberRole = repo.getSpaceMemberRole(space.id, viewer.id);
      return canEditInSpace(viewer, { ownerId: space.ownerId }, memberRole);
    }
    // Personal (space-less) schedule: the owner manages it (legacy behavior).
    return sched.ownerId !== null && sched.ownerId === viewer.id;
  }

  // 一覧取得（?spaceId=<id> でそのスペースのスケジュールだけに絞れる。スペースの
  // 「スケジュール」タブが使う。可視性フィルタ (viewer) と併用される）。
  app.get('/api/scheduled-tasks', async (req: Request, res: Response) => {
    try {
      const viewer = req.user as Express.User | undefined;
      const rawSpaceId = req.query.spaceId;
      const filter: { viewer?: Express.User; spaceId?: string } = {};
      if (viewer) filter.viewer = viewer;
      if (typeof rawSpaceId === 'string' && rawSpaceId.length > 0) filter.spaceId = rawSpaceId;
      const tasks = await repo.listScheduledTasks(Object.keys(filter).length > 0 ? filter : undefined);
      res.json({ tasks });
    } catch (err) {
      res.status(500).json({ error: `Failed to list scheduled tasks: ${err}` });
    }
  });

  // 詳細取得
  app.get('/api/scheduled-tasks/:id', async (req: Request, res: Response) => {
    try {
      const id = Number(req.params.id);
      const viewer = req.user as Express.User | undefined;
      const task = await repo.getScheduledTask(id, { viewer });
      if (!task) { res.status(404).json({ error: 'Not found' }); return; }
      res.json({ task });
    } catch (err) {
      res.status(500).json({ error: `Failed to get scheduled task: ${err}` });
    }
  });

  // 新規作成
  app.post('/api/scheduled-tasks', async (req: Request, res: Response) => {
    try {
      const { title, body, piece, profile, outputFormat, scheduleType, hour, minute, dayOfWeek, dayOfMonth, cronExpression: rawCron, scheduledAt } = req.body;

      // task_kind: 'agent' (default) or 'script'
      const rawTaskKind = req.body?.taskKind;
      const taskKind: 'agent' | 'script' = rawTaskKind === 'script' ? 'script' : 'agent';
      let scriptName: string | null = null;
      let scriptParams: string | null = null;
      if (taskKind === 'script') {
        const rawScriptName = req.body?.scriptName;
        if (typeof rawScriptName !== 'string' || !rawScriptName.trim()) {
          res.status(400).json({ error: 'scriptName is required when taskKind=script' });
          return;
        }
        scriptName = rawScriptName.trim();
        const rawScriptParams = req.body?.scriptParams;
        if (rawScriptParams !== undefined && rawScriptParams !== null) {
          if (typeof rawScriptParams === 'string') {
            try {
              const parsed = JSON.parse(rawScriptParams);
              if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
                throw new Error('scriptParams must be a JSON object');
              }
              scriptParams = JSON.stringify(parsed);
            } catch (err) {
              res.status(400).json({ error: `scriptParams is not valid JSON: ${(err as Error).message}` });
              return;
            }
          } else if (typeof rawScriptParams === 'object' && !Array.isArray(rawScriptParams)) {
            scriptParams = JSON.stringify(rawScriptParams);
          } else {
            res.status(400).json({ error: 'scriptParams must be a JSON object (or stringified JSON object)' });
            return;
          }
        }
      }

      if (taskKind === 'agent' && !body) { res.status(400).json({ error: 'body is required' }); return; }
      if (!scheduleType) { res.status(400).json({ error: 'scheduleType is required' }); return; }

      // spaceId resolution: accept only a space the requester can both SEE and
      // EDIT. A space-bound schedule writes to the space's shared files tree on
      // every run, so binding requires write (owner/admin/editor) — not just
      // visibility — or a read-only viewer of a public/org space could create an
      // automated write channel that bypasses canEditInSpace. An invisible /
      // non-editable / nonexistent id falls back to NULL (= personal space), so a
      // schedule never slips into a space the user can't write to. No-auth (no
      // req.user) = sole operator, allowed. Mirrors POST /api/local/tasks +
      // space-api's canEditInSpace write gate.
      const viewer = req.user as Express.User | undefined;
      let resolvedSpaceId: string | null = null;
      if (typeof req.body?.spaceId === 'string' && req.body.spaceId.length > 0) {
        const reqSpace = await repo.getSpace(req.body.spaceId, viewer ? { viewer } : undefined);
        if (!reqSpace) {
          logger.warn(`[scheduled-tasks] create: spaceId=${req.body.spaceId} not visible to user; falling back to personal space`);
        } else {
          const memberRole = viewer ? repo.getSpaceMemberRole(reqSpace.id, viewer.id) : null;
          const canEdit = !viewer || canEditInSpace(viewer, { ownerId: reqSpace.ownerId }, memberRole);
          if (canEdit) {
            resolvedSpaceId = reqSpace.id;
          } else {
            logger.warn(`[scheduled-tasks] create: user lacks edit permission in space=${reqSpace.id}; falling back to personal space`);
          }
        }
      }

      // Visibility extraction + validation (mirrors POST /api/local/tasks).
      // Schedules created inside a space are ALWAYS private (the space itself
      // is the sharing boundary), matching local-tasks-api.
      const rawVisibility = req.body?.visibility ?? 'private';
      if (!['private', 'org', 'public'].includes(rawVisibility)) {
        res.status(400).json({ error: 'invalid visibility' });
        return;
      }
      const visibility = (resolvedSpaceId ? 'private' : rawVisibility) as 'private' | 'org' | 'public';
      const rawScopeOrgId = req.body?.visibilityScopeOrgId;
      const visibilityScopeOrgId: string | null =
        typeof rawScopeOrgId === 'string' && rawScopeOrgId.length > 0 ? rawScopeOrgId : null;
      if (visibility === 'org') {
        const orgIds = (req.user as Express.User | undefined)?.orgIds ?? [];
        if (!visibilityScopeOrgId || !orgIds.includes(visibilityScopeOrgId)) {
          res.status(400).json({ error: 'visibility_scope_org_id must be one of your orgs' });
          return;
        }
      }
      const ownerId = (req.user as Express.User | undefined)?.id ?? noAuthOwner;

      const profileBinding = resolveBrowserSessionProfileId(
        req.body?.browserSessionProfileId,
        req.user as Express.User | undefined,
      );
      if (!profileBinding.ok) {
        res.status(400).json({ error: profileBinding.error });
        return;
      }

      const cronExpr = convertToCron(scheduleType, { hour, minute, dayOfWeek, dayOfMonth, cronExpression: rawCron });

      let nextRunAt: string;
      if (scheduleType === 'once') {
        if (!scheduledAt) { res.status(400).json({ error: 'scheduledAt is required for once type' }); return; }
        nextRunAt = toSqliteDatetime(new Date(scheduledAt));
      } else {
        const next = calcNextRun(cronExpr);
        if (!next) { res.status(400).json({ error: 'Failed to calculate next run time' }); return; }
        nextRunAt = next;
      }

      const task = await repo.createScheduledTask({
        title: title || null,
        body: taskKind === 'script' ? (body ?? '') : body,
        pieceName: piece ?? 'auto',
        profile: profile ?? 'auto',
        outputFormat: outputFormat ?? 'markdown',
        cronExpression: cronExpr,
        nextRunAt,
        ownerId,
        visibility,
        visibilityScopeOrgId: visibility === 'org' ? visibilityScopeOrgId : null,
        browserSessionProfileId: profileBinding.value,
        taskKind,
        scriptName,
        scriptParams,
        spaceId: resolvedSpaceId,
      });

      res.status(201).json({ task });
    } catch (err) {
      res.status(400).json({ error: `Failed to create scheduled task: ${err}` });
    }
  });

  // 編集
  app.patch('/api/scheduled-tasks/:id', async (req: Request, res: Response) => {
    try {
      const id = Number(req.params.id);
      const viewer = req.user as Express.User | undefined;
      const existing = await repo.getScheduledTask(id, { viewer });
      if (!existing) { res.status(404).json({ error: 'Not found' }); return; }
      if (!(await canManageSchedule(viewer, existing))) {
        res.status(404).json({ error: 'Not found' });
        return;
      }

      // Confused-deputy guard (codex P1) for SPACE schedules.
      // A space schedule is executed AS ITS OWNER (job.ownerId = schedule owner),
      // so the run pulls that owner's user-bound assets: browser session profile,
      // browser-macros (read by item.ownerId), memory, MCP / SSH credentials.
      // canManageSchedule lets any space owner/admin/editor MANAGE the schedule
      // for business continuity, but if a non-owner could change WHAT it runs,
      // their instructions would execute inside the owner's authenticated context
      // (confused deputy). Re-homing the execution principal to the space is the
      // P3 redesign; until then, a non-owner may only manage the LIFECYCLE
      // (pause/resume, reschedule, change visibility, delete, trigger) — never
      // change content or attach credentials. Content edits stay owner-only.
      //
      // Reject only fields whose VALUE actually changes vs. the stored schedule:
      // the editor UI PATCHes the whole form (title/body/piece/… unchanged), so a
      // presence-only check would 403 a legitimate non-owner reschedule.
      // `!viewer` = no-auth single operator (sole principal, no confused deputy
      // possible) → treated as owner, like canManageSchedule.
      const isOwner = !viewer || (viewer.id != null && viewer.id === existing.ownerId);
      if (existing.spaceId && viewer && !isOwner) {
        const norm = (v: unknown): unknown => (v === undefined || v === null || v === '' ? null : v);
        const canonParams = (v: unknown): string | null => {
          if (v === undefined || v === null || v === '') return null;
          try { return JSON.stringify(typeof v === 'string' ? JSON.parse(v) : v); } catch { return String(v); }
        };
        const changed: string[] = [];
        const cmp = (key: string, reqVal: unknown, curVal: unknown) => {
          if (reqVal === undefined) return; // field absent from this PATCH
          if (norm(reqVal) !== norm(curVal)) changed.push(key);
        };
        cmp('title', req.body.title, existing.title);
        cmp('body', req.body.body, existing.body);
        cmp('piece', req.body.piece, existing.pieceName);
        cmp('profile', req.body.profile, existing.profile);
        cmp('outputFormat', req.body.outputFormat, existing.outputFormat);
        cmp('taskKind', req.body.taskKind, existing.taskKind);
        cmp('scriptName', req.body.scriptName, existing.scriptName);
        if (req.body.scriptParams !== undefined
            && canonParams(req.body.scriptParams) !== canonParams(existing.scriptParams)) {
          changed.push('scriptParams');
        }
        if (req.body.browserSessionProfileId !== undefined) {
          const reqProfile = norm(req.body.browserSessionProfileId);
          const cur = norm(existing.browserSessionProfileId);
          if ((reqProfile == null ? null : Number(reqProfile)) !== (cur == null ? null : Number(cur))) {
            changed.push('browserSessionProfileId');
          }
        }
        if (changed.length > 0) {
          res.status(403).json({
            error: `Only the schedule owner can change what a space schedule runs (${changed.join(', ')}). As a space manager you can pause/resume, reschedule, change visibility, trigger, or delete it.`,
          });
          return;
        }
      }

      const updates: Record<string, any> = {};

      if (req.body.title !== undefined) updates.title = req.body.title;
      if (req.body.body !== undefined) updates.body = req.body.body;
      if (req.body.piece !== undefined) updates.pieceName = req.body.piece;
      if (req.body.profile !== undefined) updates.profile = req.body.profile;
      if (req.body.outputFormat !== undefined) updates.outputFormat = req.body.outputFormat;

      // スケジュール変更
      if (req.body.scheduleType) {
        const cronExpr = convertToCron(req.body.scheduleType, {
          hour: req.body.hour,
          minute: req.body.minute,
          dayOfWeek: req.body.dayOfWeek,
          dayOfMonth: req.body.dayOfMonth,
          cronExpression: req.body.cronExpression,
        });
        updates.cronExpression = cronExpr;

        if (req.body.scheduleType === 'once' && req.body.scheduledAt) {
          updates.nextRunAt = toSqliteDatetime(new Date(req.body.scheduledAt));
        } else {
          const next = calcNextRun(cronExpr);
          if (next) updates.nextRunAt = next;
        }
      }

      // 一時停止/再開
      if (req.body.isActive !== undefined) {
        updates.isActive = req.body.isActive;
        // 再開時は next_run_at を再計算
        if (req.body.isActive && !updates.cronExpression) {
          const next = calcNextRun(existing.cronExpression);
          if (next) updates.nextRunAt = next;
        }
      }

      // Visibility 変更 (POST と同じバリデーション)
      if (req.body.visibility !== undefined) {
        const rawVisibility = req.body.visibility;
        if (!['private', 'org', 'public'].includes(rawVisibility)) {
          res.status(400).json({ error: 'invalid visibility' });
          return;
        }
        // スペース内スケジュールはスペースが共有境界なので常に private。POST と
        // 同じ不変条件を編集時にも維持し、owner が public/org に変えてメンバー外へ
        // スケジュール定義・以後の実行タスクを公開する経路を塞ぐ。
        if (existing.spaceId) {
          updates.visibility = 'private';
          updates.visibilityScopeOrgId = null;
        } else {
          const rawScopeOrgId = req.body.visibilityScopeOrgId;
          const visibilityScopeOrgId: string | null =
            typeof rawScopeOrgId === 'string' && rawScopeOrgId.length > 0 ? rawScopeOrgId : null;
          if (rawVisibility === 'org') {
            const orgIds = viewer?.orgIds ?? [];
            if (!visibilityScopeOrgId || !orgIds.includes(visibilityScopeOrgId)) {
              res.status(400).json({ error: 'visibility_scope_org_id must be one of your orgs' });
              return;
            }
          }
          updates.visibility = rawVisibility;
          updates.visibilityScopeOrgId = rawVisibility === 'org' ? visibilityScopeOrgId : null;
        }
      }

      // browserSessionProfileId 変更 (owner check). Skip for a non-owner editing
      // a space schedule: the confused-deputy guard above already handled the
      // field (rejecting an actual change, ignoring an unchanged resend). Running
      // the owner check here would 400 when the editor's full-form PATCH resends
      // the owner's unchanged profile id (which the non-owner does not own).
      if (req.body.browserSessionProfileId !== undefined && (isOwner || !existing.spaceId)) {
        const binding = resolveBrowserSessionProfileId(req.body.browserSessionProfileId, viewer);
        if (!binding.ok) {
          res.status(400).json({ error: binding.error });
          return;
        }
        updates.browserSessionProfileId = binding.value;
      }

      // taskKind / scriptName / scriptParams (PATCH 用)
      if (req.body.taskKind !== undefined) {
        if (req.body.taskKind !== 'agent' && req.body.taskKind !== 'script') {
          res.status(400).json({ error: "taskKind must be 'agent' or 'script'" });
          return;
        }
        updates.taskKind = req.body.taskKind;
      }
      if (req.body.scriptName !== undefined) {
        if (req.body.scriptName === null || req.body.scriptName === '') {
          updates.scriptName = null;
        } else if (typeof req.body.scriptName === 'string') {
          updates.scriptName = req.body.scriptName.trim();
        } else {
          res.status(400).json({ error: 'scriptName must be a string' });
          return;
        }
      }
      if (req.body.scriptParams !== undefined) {
        if (req.body.scriptParams === null) {
          updates.scriptParams = null;
        } else if (typeof req.body.scriptParams === 'string') {
          try {
            const parsed = JSON.parse(req.body.scriptParams);
            if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
              throw new Error('scriptParams must be a JSON object');
            }
            updates.scriptParams = JSON.stringify(parsed);
          } catch (err) {
            res.status(400).json({ error: `scriptParams is not valid JSON: ${(err as Error).message}` });
            return;
          }
        } else if (typeof req.body.scriptParams === 'object' && !Array.isArray(req.body.scriptParams)) {
          updates.scriptParams = JSON.stringify(req.body.scriptParams);
        } else {
          res.status(400).json({ error: 'scriptParams must be a JSON object (or stringified JSON object)' });
          return;
        }
      }

      const updated = await repo.updateScheduledTask(id, updates);
      res.json({ task: updated });
    } catch (err) {
      res.status(400).json({ error: `Failed to update scheduled task: ${err}` });
    }
  });

  // 削除
  app.delete('/api/scheduled-tasks/:id', async (req: Request, res: Response) => {
    try {
      const id = Number(req.params.id);
      const viewer = req.user as Express.User | undefined;
      const existing = await repo.getScheduledTask(id, { viewer });
      if (!existing) { res.status(404).json({ error: 'Not found' }); return; }
      if (!(await canManageSchedule(viewer, existing))) {
        res.status(404).json({ error: 'Not found' });
        return;
      }
      const deleted = await repo.deleteScheduledTask(id);
      if (!deleted) { res.status(404).json({ error: 'Not found' }); return; }
      res.json({ ok: true });
    } catch (err) {
      res.status(500).json({ error: `Failed to delete scheduled task: ${err}` });
    }
  });

  // 手動即時実行
  app.post('/api/scheduled-tasks/:id/trigger', async (req: Request, res: Response) => {
    try {
      const id = Number(req.params.id);
      const viewer = req.user as Express.User | undefined;
      const existing = await repo.getScheduledTask(id, { viewer });
      if (!existing) { res.status(404).json({ error: 'Not found' }); return; }
      if (!(await canManageSchedule(viewer, existing))) {
        res.status(404).json({ error: 'Not found' });
        return;
      }
      await scheduler.executeById(id);
      res.json({ ok: true });
    } catch (err) {
      res.status(500).json({ error: `Failed to trigger scheduled task: ${err}` });
    }
  });
}
