import API_CONFIG from '../config/api';
import { toFrappeFilterArray } from '../utils/listFilterUtils';

// ─── Project ─────────────────────────────────────────────────────────────────
export interface ProjectUser {
  user?: string; email?: string; full_name?: string; welcome_email_sent?: number;
}

export interface Project {
  name: string;
  project_name?: string;
  status?: string;
  is_active?: string;
  priority?: string;
  company?: string;
  customer?: string;
  project_type?: string;
  custom_project_manager?: string;
  naming_series?: string;
  expected_start_date?: string;
  expected_end_date?: string;
  percent_complete_method?: string;
  percent_complete?: number;
  estimated_costing?: number;
  actual_time?: number;
  total_costing_amount?: number;
  total_purchase_cost?: number;
  total_sales_amount?: number;
  total_billable_amount?: number;
  total_billed_amount?: number;
  gross_margin?: number;
  per_gross_margin?: number;
  description?: string;
  notes?: string;
  users?: ProjectUser[];
  owner?: string;
  creation?: string;
  modified?: string;
  docstatus?: number;
  [key: string]: any;
}

// ─── Task — exact fields present in actual Frappe system (verified from JSON) ─
// NOTE: type, issue, parent_task are intentionally excluded — they do NOT exist
//       in this system's tabTask database table.
export interface Task {
  name: string;
  subject?: string;
  project?: string;
  status?: string;           // Open | Working | Completed | Cancelled | Overdue
  priority?: string;         // Low | Medium | High | Urgent
  task_weight?: number;
  is_group?: number;
  is_template?: number;
  is_milestone?: number;
  exp_start_date?: string;
  exp_end_date?: string;
  expected_time?: number;
  actual_time?: number;
  progress?: number;
  completed_on?: string;
  custom_risk?: string;
  custom_action?: string;
  custom_task_obstacle?: string;
  custom_assign_to?: string;
  total_costing_amount?: number;
  total_billing_amount?: number;
  company?: string;
  description?: string;
  depends_on_tasks?: string;
  depends_on?: Array<{ task?: string; subject?: string }>;
  _assign?: string;
  owner?: string;
  creation?: string;
  modified?: string;
  docstatus?: number;
  [key: string]: any;
}

// ─── Project Template ─────────────────────────────────────────────────────────
// Frappe's "Project Template Task" child doctype fields (confirmed from Frappe desk):
//   task     - Link to Task (mandatory, filtered by is_template=1)
//   subject  - Data, auto-fetched from task.subject (read-only)
//   duration - Int (days, optional)
// The user must select (or create) a Task with is_template=1 for each row.
export interface ProjectTemplateTask {
  task?: string;       // Link to Task doctype (is_template=1 tasks)
  subject?: string;    // Auto-fetched from task.subject (read-only)
  duration?: number;   // Duration in days
}

export interface ProjectTemplate {
  name: string;
  project_type?: string;
  tasks?: ProjectTemplateTask[];
  owner?: string;
  creation?: string;
  modified?: string;
}

// ─── Timesheet Detail child table — exact fields from Frappe system ───────────
export interface TimesheetDetail {
  name?: string;
  activity_type?: string;
  from_time?: string;
  to_time?: string;
  hours?: number;
  billing_hours?: number;
  billing_rate?: number;
  billing_amount?: number;
  costing_rate?: number;
  costing_amount?: number;
  project?: string;
  task?: string;
  note?: string;
  is_billable?: number;
  completed?: number;
  idx?: number;
}

// ─── Timesheet — exact fields from actual Frappe system data ─────────────────
export interface Timesheet {
  name: string;
  naming_series?: string;
  status?: string;           // Draft | Submitted | Cancelled
  currency?: string;
  exchange_rate?: number;
  // Header link fields (ERPNext: company + parent_project; optional custom aliases)
  employee?: string;
  customer?: string;
  parent_project?: string;   // Frappe fieldname for header "Project"
  project?: string;          // Legacy / display alias only — prefer parent_project for API
  company?: string;          // Company (labeled Hospital in this app)
  hospital?: string;         // Custom field on some sites — avoid relying on this for ERPNext core
  note?: string;
  // Totals (computed by Frappe)
  total_hours?: number;
  total_billable_hours?: number;
  total_billable_amount?: number;
  total_costing_amount?: number;
  time_logs?: TimesheetDetail[];
  owner?: string;
  creation?: string;
  modified?: string;
  docstatus?: number;
  [key: string]: any;
}

// ─── Activity Type ────────────────────────────────────────────────────────────
// Frappe Activity Type fields (from standard ERPNext Activity Type doctype):
//   activity_type  - Data (mandatory) — autoname: Frappe sets name = activity_type
//   billing_rate   - Currency (NOT default_billing_rate)
//   costing_rate   - Currency (NOT default_costing_rate)
//   disabled       - Check
// autoname = "field:activity_type" → name column = activity_type value
export interface ActivityType {
  name: string;
  activity_type?: string;     // The display label (same as name due to autoname)
  billing_rate?: number;      // Standard field name in Frappe
  costing_rate?: number;      // Standard field name in Frappe
  disabled?: number;
  owner?: string;
  creation?: string;
  modified?: string;
}

export interface ProjectListParams {
  filters?: Record<string, any>;
  /** Extra Frappe filter rows (e.g. child table: [['Timesheet Detail','project','=', name]]) */
  appendFilters?: any[][];
  fields?: string[];
  limit_start?: number;
  limit_page_length?: number;
  order_by?: string;
}

/** Custom child tables on Task / Project (desk: `custom_task_updates`, `custom_project_updates`). */
export const CUSTOM_TASK_UPDATES = 'custom_task_updates';
export const CUSTOM_PROJECT_UPDATES = 'custom_project_updates';

export type ProgressUpdateChildRow = {
  name?: string;
  update_?: string;
  date?: string;
  task?: string;
  idx?: number;
};

/** Build Frappe child-table payload; optional `fillTaskLink` sets hidden Task link on every row (Task form). */
export function serializeProgressUpdateRows(
  rows: ProgressUpdateChildRow[],
  options?: { fillTaskLink?: string },
): Record<string, unknown>[] {
  const fill = options?.fillTaskLink;
  return rows
    .filter(r => {
      const u = String(r.update_ || '').trim();
      const d = String(r.date || '').trim();
      const t = String(r.task || '').trim();
      return u || d || t;
    })
    .map((r, i) => {
      const row: Record<string, unknown> = { idx: i + 1, update_: r.update_ || '' };
      if (r.date) row.date = r.date;
      if (r.name) row.name = r.name;
      const taskVal = fill ?? r.task;
      if (taskVal) row.task = taskVal;
      return row;
    });
}

// ─── Service class ────────────────────────────────────────────────────────────
class ProjectService {
  private readonly baseURL = API_CONFIG.BASE_URL;

  private clearCsrfTokenCache(): void {
    if (typeof window === 'undefined') return;
    try {
      delete (window as any).csrf_token;
      const fr = (window as any).frappe;
      if (fr && typeof fr === 'object' && 'csrf_token' in fr) {
        try {
          delete fr.csrf_token;
        } catch {
          fr.csrf_token = '';
        }
      }
    } catch { /* ignore */ }
  }

  private async getCSRFToken(): Promise<string | null> {
    if (typeof window === 'undefined') return null;
    // Frappe sets csrf_token on window directly OR via the frappe global object
    if ((window as any).csrf_token) return (window as any).csrf_token;
    if ((window as any).frappe?.csrf_token) return (window as any).frappe.csrf_token;
    // Fallback: fetch it from the API (happens when SPA loads before Frappe injects it)
    try {
      const res = await fetch(`${this.baseURL}/api/method/frappe.sessions.get_csrf_token`, {
        credentials: 'include',
        cache: 'no-store',
      });
      if (res.ok) {
        const json = await res.json();
        if (json.message) {
          (window as any).csrf_token = json.message;
          return json.message;
        }
      }
    } catch { /* ignore */ }
    return null;
  }

  private mergeHeaders(base: Record<string, string>, extra?: HeadersInit): Record<string, string> {
    const out = { ...base };
    if (!extra) return out;
    if (extra instanceof Headers) {
      extra.forEach((v, k) => {
        out[k] = v;
      });
      return out;
    }
    if (Array.isArray(extra)) {
      for (const [k, v] of extra) out[k] = v;
      return out;
    }
    return { ...out, ...extra };
  }

  /** Detect CSRF failure so we can clear cache and retry once with a fresh token. */
  private isFrappeCsrfError(body: unknown): boolean {
    if (!body || typeof body !== 'object') return false;
    const b = body as Record<string, unknown>;
    if (b.exc_type === 'CSRFTokenError') return true;
    const msg = typeof b.message === 'string' ? b.message : '';
    if (/csrf/i.test(msg)) return true;
    if (b._server_messages && typeof b._server_messages === 'string') {
      try {
        const msgs = JSON.parse(b._server_messages as string);
        const first = msgs[0];
        const inner = typeof first === 'string' ? JSON.parse(first) : first;
        const m = typeof inner?.message === 'string' ? inner.message : '';
        if (/invalid request/i.test(m) && b.exc_type === 'CSRFTokenError') return true;
      } catch { /* ignore */ }
      if (/CSRFTokenError|csrf/i.test(b._server_messages as string)) return true;
    }
    return false;
  }

  private async getHeaders(): Promise<Record<string, string>> {
    const h: Record<string, string> = { Accept: 'application/json', 'Content-Type': 'application/json' };
    const csrf = await this.getCSRFToken();
    if (csrf) h['X-Frappe-CSRF-Token'] = csrf;
    return h;
  }

  /** Parse Frappe JSON error responses into a human-readable message. */
  private async parseFrappeError(response: Response): Promise<Error> {
    const statusMessages: Record<number, string> = {
      403: 'Permission denied. Your role does not have access to this DocType.',
      417: 'Validation error. Check field values or role permissions for this DocType.',
      404: 'Record not found.',
      500: 'Server error. Check that all field names are correct.',
    };
    try {
      const body = await response.json();
      // Frappe _server_messages is a double-serialized JSON array
      if (body._server_messages) {
        try {
          const msgs = JSON.parse(body._server_messages);
          const parsed = typeof msgs[0] === 'string' ? JSON.parse(msgs[0]) : msgs[0];
          if (parsed?.message) return new Error(parsed.message);
        } catch { /* ignore parse error */ }
      }
      // Direct message field
      if (body.message && typeof body.message === 'string' && body.message.trim()) {
        return new Error(body.message);
      }
      // exc_type with exception string (e.g. OperationalError, MandatoryError)
      if (body.exc_type) {
        const detail = body.exc ? body.exc.split('\n').slice(-2).join(' ').trim() : '';
        return new Error(`${body.exc_type}: ${detail || statusMessages[response.status] || response.statusText}`);
      }
    } catch { /* ignore */ }
    return new Error(statusMessages[response.status] || `HTTP ${response.status}: ${response.statusText}`);
  }

  private async fetchJson(url: string, options?: RequestInit): Promise<any> {
    const response = await fetch(url, {
      credentials: 'include', ...options,
      headers: { ...await this.getHeaders(), ...(options?.headers || {}) },
    });
    if (!response.ok) throw await this.parseFrappeError(response.clone());
    return response.json();
  }

  // ─── Project ──────────────────────────────────────────────────────────────
  async getProjects(params: ProjectListParams = {}): Promise<{ data: Project[] }> {
    const { filters = {}, fields = ['name','project_name','status','priority','company','customer','expected_start_date','expected_end_date','percent_complete','actual_time','creation','modified','owner'], limit_start = 0, limit_page_length = 20, order_by = 'modified desc' } = params;
    const q = new URLSearchParams();
    q.set('fields', JSON.stringify(fields));
    q.set('limit_start', String(limit_start));
    q.set('limit_page_length', String(limit_page_length));
    q.set('order_by', order_by);
    if (Object.keys(filters).length > 0) { const fa = toFrappeFilterArray(filters); if (fa.length > 0) q.set('filters', JSON.stringify(fa)); }
    const r = await this.fetchJson(`${this.baseURL}/api/resource/Project?${q}`);
    return { data: r.data || [] };
  }

  async getProject(name: string): Promise<Project> {
    const r = await this.fetchJson(`${this.baseURL}/api/resource/Project/${encodeURIComponent(name)}`);
    return r.data;
  }

  async getProjectCount(filters: Record<string, any> = {}): Promise<number> {
    const q = new URLSearchParams();
    q.set('fields', JSON.stringify(['count(name) as count']));
    if (Object.keys(filters).length > 0) { const fa = toFrappeFilterArray(filters); if (fa.length > 0) q.set('filters', JSON.stringify(fa)); }
    try { const r = await this.fetchJson(`${this.baseURL}/api/resource/Project?${q}`); return r.data?.[0]?.count || 0; } catch { return 0; }
  }

  async createProject(data: Partial<Project>): Promise<Project> {
    const r = await this.fetchJson(`${this.baseURL}/api/resource/Project`, { method: 'POST', body: JSON.stringify(data) });
    return r.data;
  }

  async updateProject(name: string, data: Partial<Project>): Promise<Project> {
    const r = await this.fetchJson(`${this.baseURL}/api/resource/Project/${encodeURIComponent(name)}`, { method: 'PUT', body: JSON.stringify(data) });
    return r.data;
  }

  // ─── Project Template ─────────────────────────────────────────────────────
  async getProjectTemplates(params: ProjectListParams = {}): Promise<{ data: ProjectTemplate[] }> {
    const { filters = {}, limit_start = 0, limit_page_length = 50, order_by = 'name asc' } = params;
    const q = new URLSearchParams();
    q.set('fields', JSON.stringify(['name', 'project_type', 'creation', 'modified']));
    q.set('limit_start', String(limit_start));
    q.set('limit_page_length', String(limit_page_length));
    q.set('order_by', order_by);
    if (Object.keys(filters).length > 0) { const fa = toFrappeFilterArray(filters); if (fa.length > 0) q.set('filters', JSON.stringify(fa)); }
    try { const r = await this.fetchJson(`${this.baseURL}/api/resource/Project%20Template?${q}`); return { data: r.data || [] }; }
    catch { return { data: [] }; }
  }

  async getProjectTemplate(name: string): Promise<ProjectTemplate> {
    const r = await this.fetchJson(`${this.baseURL}/api/resource/Project%20Template/${encodeURIComponent(name)}`);
    return r.data;
  }

  async createProjectTemplate(data: Partial<ProjectTemplate>): Promise<ProjectTemplate> {
    const r = await this.fetchJson(`${this.baseURL}/api/resource/Project%20Template`, { method: 'POST', body: JSON.stringify(data) });
    return r.data;
  }

  async updateProjectTemplate(name: string, data: Partial<ProjectTemplate>): Promise<ProjectTemplate> {
    const r = await this.fetchJson(`${this.baseURL}/api/resource/Project%20Template/${encodeURIComponent(name)}`, { method: 'PUT', body: JSON.stringify(data) });
    return r.data;
  }

  async getProjectTemplateCount(filters: Record<string, any> = {}): Promise<number> {
    const q = new URLSearchParams();
    q.set('fields', JSON.stringify(['count(name) as count']));
    if (Object.keys(filters).length > 0) {
      const fa = toFrappeFilterArray(filters);
      if (fa.length > 0) q.set('filters', JSON.stringify(fa));
    }
    try { const r = await this.fetchJson(`${this.baseURL}/api/resource/Project%20Template?${q}`); return r.data?.[0]?.count || 0; } catch { return 0; }
  }

  // ─── Tasks ────────────────────────────────────────────────────────────────
  // Only fields confirmed present in this system's tabTask table (from actual JSON).
  // type / issue / parent_task are deliberately omitted — they don't exist here.
  private readonly TASK_FIELDS = [
    'name', 'subject', 'project', 'status', 'priority',
    'task_weight', 'is_group', 'is_template', 'is_milestone',
    'expected_time', 'actual_time', 'progress',
    'exp_start_date', 'exp_end_date', 'parent_task',
    'completed_on',
    'custom_risk', 'custom_action', 'custom_task_obstacle', 'custom_assign_to',
    'total_costing_amount', 'total_billing_amount', 'company',
    'depends_on_tasks', '_assign', 'owner', 'creation', 'modified',
  ];

  async getTasks(params: ProjectListParams = {}): Promise<{ data: Task[] }> {
    const { filters = {}, fields = this.TASK_FIELDS, limit_start = 0, limit_page_length = 50, order_by = 'creation desc' } = params;
    const q = new URLSearchParams();
    q.set('fields', JSON.stringify(fields));
    q.set('limit_start', String(limit_start));
    q.set('limit_page_length', String(limit_page_length));
    q.set('order_by', order_by);
    if (Object.keys(filters).length > 0) { const fa = toFrappeFilterArray(filters); if (fa.length > 0) q.set('filters', JSON.stringify(fa)); }
    const r = await this.fetchJson(`${this.baseURL}/api/resource/Task?${q}`);
    return { data: r.data || [] };
  }

  async getTasksForProject(projectName: string, params?: { limit_start?: number; limit_page_length?: number }): Promise<{ data: Task[] }> {
    return this.getTasks({ ...params, filters: { project: projectName }, limit_page_length: 100 });
  }

  async getTask(name: string): Promise<Task> {
    const r = await this.fetchJson(`${this.baseURL}/api/resource/Task/${encodeURIComponent(name)}`);
    return r.data;
  }

  async createTask(data: Partial<Task>): Promise<Task> {
    const r = await this.fetchJson(`${this.baseURL}/api/resource/Task`, { method: 'POST', body: JSON.stringify(data) });
    return r.data;
  }

  async updateTask(name: string, data: Partial<Task>): Promise<Task> {
    const r = await this.fetchJson(`${this.baseURL}/api/resource/Task/${encodeURIComponent(name)}`, { method: 'PUT', body: JSON.stringify(data) });
    return r.data;
  }

  async getTaskCount(filters: Record<string, any> = {}): Promise<number> {
    const q = new URLSearchParams();
    q.set('fields', JSON.stringify(['count(name) as count']));
    if (Object.keys(filters).length > 0) { const fa = toFrappeFilterArray(filters); if (fa.length > 0) q.set('filters', JSON.stringify(fa)); }
    try { const r = await this.fetchJson(`${this.baseURL}/api/resource/Task?${q}`); return r.data?.[0]?.count || 0; } catch { return 0; }
  }

  // ─── Timesheets ──────────────────────────────────────────────────────────
  // Fields match the actual Frappe Timesheet doctype (verified against system JSON)
  // Note: employee/posting_date are NOT required in this system's configuration
  async getTimesheets(params: ProjectListParams = {}): Promise<{ data: Timesheet[] }> {
    const {
      filters = {},
      appendFilters = [],
      fields = [
        'name',
        'status',
        'docstatus',
        'currency',
        'employee',
        'customer',
        'company',
        'total_hours',
        'total_billable_hours',
        'total_billable_amount',
        'total_costing_amount',
        'creation',
        'modified',
        'owner',
      ],
      limit_start = 0,
      limit_page_length = 20,
      order_by = 'creation desc',
    } = params;
    const q = new URLSearchParams();
    q.set('fields', JSON.stringify(fields));
    q.set('limit_start', String(limit_start));
    q.set('limit_page_length', String(limit_page_length));
    q.set('order_by', order_by);
    let fa = toFrappeFilterArray(filters);
    if (appendFilters.length) fa = [...fa, ...appendFilters];
    if (fa.length > 0) q.set('filters', JSON.stringify(fa));
    const r = await this.fetchJson(`${this.baseURL}/api/resource/Timesheet?${q}`);
    return { data: r.data || [] };
  }

  async getTimesheetsForProject(projectName: string, params?: { limit_start?: number; limit_page_length?: number }): Promise<{ data: Timesheet[] }> {
    const { limit_start = 0, limit_page_length = 50 } = params || {};
    const q = new URLSearchParams();
    q.set('filters', JSON.stringify([['Timesheet Detail', 'project', '=', projectName]]));
    q.set('fields', JSON.stringify(['name','status','docstatus','currency','total_hours','total_billable_hours','total_billable_amount','total_costing_amount','creation','modified']));
    q.set('limit_start', String(limit_start));
    q.set('limit_page_length', String(limit_page_length));
    q.set('order_by', 'creation desc');
    const r = await this.fetchJson(`${this.baseURL}/api/resource/Timesheet?${q}`);
    return { data: r.data || [] };
  }

  async getTimesheet(name: string): Promise<Timesheet> {
    const r = await this.fetchJson(`${this.baseURL}/api/resource/Timesheet/${encodeURIComponent(name)}`);
    return r.data;
  }

  async createTimesheet(data: Partial<Timesheet>): Promise<Timesheet> {
    const r = await this.fetchJson(`${this.baseURL}/api/resource/Timesheet`, { method: 'POST', body: JSON.stringify(data) });
    return r.data;
  }

  async updateTimesheet(name: string, data: Partial<Timesheet>): Promise<Timesheet> {
    const r = await this.fetchJson(`${this.baseURL}/api/resource/Timesheet/${encodeURIComponent(name)}`, { method: 'PUT', body: JSON.stringify(data) });
    return r.data;
  }

  async submitTimesheet(name: string): Promise<Timesheet> {
    const r = await this.fetchJson(`${this.baseURL}/api/resource/Timesheet/${encodeURIComponent(name)}`, { method: 'PUT', body: JSON.stringify({ docstatus: 1 }) });
    return r.data;
  }

  async cancelTimesheet(name: string): Promise<Timesheet> {
    const r = await this.fetchJson(`${this.baseURL}/api/resource/Timesheet/${encodeURIComponent(name)}`, { method: 'PUT', body: JSON.stringify({ docstatus: 2 }) });
    return r.data;
  }

  async getTimesheetCount(filters: Record<string, any> = {}, appendFilters: any[] = []): Promise<number> {
    const q = new URLSearchParams();
    q.set('fields', JSON.stringify(['count(name) as count']));
    let fa = toFrappeFilterArray(filters);
    if (appendFilters.length) fa = [...fa, ...appendFilters];
    if (fa.length > 0) q.set('filters', JSON.stringify(fa));
    try { const r = await this.fetchJson(`${this.baseURL}/api/resource/Timesheet?${q}`); return r.data?.[0]?.count || 0; } catch { return 0; }
  }

  // ─── Activity Type ────────────────────────────────────────────────────────
  async getActivityTypes(params: ProjectListParams = {}): Promise<{ data: ActivityType[] }> {
    const { filters = {}, limit_start = 0, limit_page_length = 50, order_by = 'name asc' } = params;
    const q = new URLSearchParams();
    // Correct field names from standard ERPNext Activity Type doctype
    q.set('fields', JSON.stringify(['name', 'activity_type', 'billing_rate', 'costing_rate', 'disabled', 'creation', 'modified']));
    q.set('limit_start', String(limit_start));
    q.set('limit_page_length', String(limit_page_length));
    q.set('order_by', order_by);
    if (Object.keys(filters).length > 0) { const fa = toFrappeFilterArray(filters); if (fa.length > 0) q.set('filters', JSON.stringify(fa)); }
    try { const r = await this.fetchJson(`${this.baseURL}/api/resource/Activity%20Type?${q}`); return { data: r.data || [] }; }
    catch { return { data: [] }; }
  }

  async getActivityType(name: string): Promise<ActivityType> {
    const r = await this.fetchJson(`${this.baseURL}/api/resource/Activity%20Type/${encodeURIComponent(name)}`);
    return r.data;
  }

  async createActivityType(data: Partial<ActivityType>): Promise<ActivityType> {
    const r = await this.fetchJson(`${this.baseURL}/api/resource/Activity%20Type`, { method: 'POST', body: JSON.stringify(data) });
    return r.data;
  }

  async updateActivityType(name: string, data: Partial<ActivityType>): Promise<ActivityType> {
    const r = await this.fetchJson(`${this.baseURL}/api/resource/Activity%20Type/${encodeURIComponent(name)}`, { method: 'PUT', body: JSON.stringify(data) });
    return r.data;
  }

  async getActivityTypeCount(filters: Record<string, any> = {}): Promise<number> {
    const q = new URLSearchParams();
    q.set('fields', JSON.stringify(['count(name) as count']));
    if (Object.keys(filters).length > 0) {
      const fa = toFrappeFilterArray(filters);
      if (fa.length > 0) q.set('filters', JSON.stringify(fa));
    }
    try { const r = await this.fetchJson(`${this.baseURL}/api/resource/Activity%20Type?${q}`); return r.data?.[0]?.count || 0; } catch { return 0; }
  }

  // ─── Counts for module page ───────────────────────────────────────────────
  async getModuleCounts(): Promise<{ projects: number; tasks: number; timesheets: number }> {
    const [projects, tasks, timesheets] = await Promise.all([
      this.getProjectCount({ status: 'Open' }),
      this.getTaskCount({}),
      this.getTimesheetCount({}),
    ]);
    return { projects, tasks, timesheets };
  }

  /**
   * Project-level view: rows stored on Project plus rows from each Task’s `custom_task_updates`.
   * Capped task scan to avoid huge fan-out.
   */
  async getMergedProjectProgressUpdates(
    projectName: string,
    opts?: { maxTasks?: number },
  ): Promise<Array<ProgressUpdateChildRow & { _source: 'project' | 'task' }>> {
    const maxTasks = opts?.maxTasks ?? 120;
    const proj = await this.getProject(projectName);
    const { data: tasks } = await this.getTasksForProject(projectName, { limit_page_length: maxTasks });
    const fromProject: Array<ProgressUpdateChildRow & { _source: 'project' | 'task' }> = (
      (proj as any)[CUSTOM_PROJECT_UPDATES] || []
    ).map((r: ProgressUpdateChildRow) => ({ ...r, _source: 'project' as const }));
    const names = tasks.map(t => t.name).filter(Boolean).slice(0, maxTasks);
    const taskDocs = await Promise.all(names.map(n => this.getTask(n).catch(() => null)));
    const fromTasks: Array<ProgressUpdateChildRow & { _source: 'project' | 'task' }> = [];
    for (const doc of taskDocs) {
      if (!doc) continue;
      const rows = (doc as any)[CUSTOM_TASK_UPDATES] || [];
      for (const r of rows) {
        fromTasks.push({
          ...r,
          task: r.task || doc.name,
          _source: 'task',
        });
      }
    }
    const key = (r: ProgressUpdateChildRow) => String(r.date || '').slice(0, 10) || '';
    return [...fromTasks, ...fromProject].sort((a, b) => key(b).localeCompare(key(a)));
  }
}

const projectService = new ProjectService();
export default projectService;
