// Extracted from src/db/repository.ts (mechanical split — bodies unchanged).
// users repository domain. Facade: src/db/repository.ts (Repository class).
import Database from 'better-sqlite3';
import { scryptSync, randomBytes, timingSafeEqual } from 'crypto';
import { v4 as uuidv4 } from 'uuid';
import { buildVisibilityWhere } from '../../bridge/visibility.js';
import { utc } from './shared.js';

export interface User {
  id: string;
  email: string;
  name: string | null;
  avatarUrl: string | null;
  role: 'admin' | 'user';
  status: 'active' | 'pending' | 'disabled';
  defaultVisibility: 'private' | 'org' | 'public';
  defaultVisibilityOrgId: string | null;
  createdAt: string;
  updatedAt: string;
}


export interface CreateUserParams {
  email: string;
  name: string;
  role: 'admin' | 'user';
  status: 'active' | 'pending' | 'disabled';
  avatarUrl?: string;
}


export interface FindOrCreateByOAuthParams {
  provider: string;
  providerId: string;
  email: string;
  name: string;
  avatarUrl?: string;
}


export interface CreateLocalUserParams {
  email: string;
  password: string;
  role: 'admin' | 'user';
  status: 'active' | 'pending' | 'disabled';
  name?: string;
}


export interface LocalOrg {
  id: string;
  name: string;
  createdBy: string | null;
  createdAt: string;
}


export interface LocalOrgMember {
  userId: string;
  role: string;
}


export interface GiteaOrgInput {
  orgId: string;
  orgName: string;
}


export interface GiteaOrg extends GiteaOrgInput {
  fetchedAt: string;
}


export interface UserRow {
  id: string;
  email: string;
  name: string | null;
  avatar_url: string | null;
  role: string;
  status: string;
  default_visibility: string | null;
  default_visibility_org_id: string | null;
  created_at: string;
  updated_at: string;
}


export function rowToUser(row: UserRow): User {
  return {
    id: row.id,
    email: row.email,
    name: row.name,
    avatarUrl: row.avatar_url,
    role: row.role as 'admin' | 'user',
    status: row.status as 'active' | 'pending' | 'disabled',
    defaultVisibility: (row.default_visibility ?? 'private') as User['defaultVisibility'],
    defaultVisibilityOrgId: row.default_visibility_org_id,
    createdAt: utc(row.created_at),
    updatedAt: utc(row.updated_at),
  };
}


  export function createUser(db: Database.Database, params: CreateUserParams): User {
    const id = uuidv4();
    const now = new Date().toISOString();
    db
      .prepare(
        `INSERT INTO users (id, email, name, avatar_url, role, status, created_at, updated_at)
         VALUES (@id, @email, @name, @avatarUrl, @role, @status, @now, @now)`
      )
      .run({
        id,
        email: params.email,
        name: params.name,
        avatarUrl: params.avatarUrl ?? null,
        role: params.role,
        status: params.status,
        now,
      });
    const user = getUserById(db, id);
    if (!user) throw new Error(`createUser: failed to retrieve created user ${id}`);
    return user;
  }


  /**
   * Ensure the synthetic 'local' user row exists. No-auth single-user
   * deployments own per-user rows under the id 'local' (tasks, jobs, SSH
   * connections, DEKs, …). Many of those tables FK to users(id) with
   * foreign_keys ON, so the row must exist or the inserts fail — e.g.
   * ssh_user_deks → SSH connection creation returned create_failed.
   * Idempotent (INSERT OR IGNORE), so it is safe to call on every startup.
   * role='admin' mirrors the synthetic 'local' user the HTTP layer injects
   * for task-visibility routes in no-auth mode.
   */
  export function ensureLocalUser(db: Database.Database): void {
    const now = new Date().toISOString();
    db
      .prepare(
        `INSERT OR IGNORE INTO users (id, email, name, avatar_url, role, status, created_at, updated_at)
         VALUES ('local', 'local@localhost', 'local', NULL, 'admin', 'active', @now, @now)`
      )
      .run({ now });
  }


  // ── Local auth (email + password) ─────────────────────────────────────

  /** scrypt hash with a fresh per-user salt. Overwrites any existing credential. */
  export function setLocalPassword(db: Database.Database, userId: string, plainPassword: string): void {
    const salt = randomBytes(16).toString('hex');
    const hash = scryptSync(plainPassword, salt, 64).toString('hex');
    const now = new Date().toISOString();
    db
      .prepare(
        `INSERT INTO local_credentials (user_id, password_hash, salt, updated_at)
         VALUES (@userId, @hash, @salt, @now)
         ON CONFLICT(user_id) DO UPDATE SET password_hash=@hash, salt=@salt, updated_at=@now`,
      )
      .run({ userId, hash, salt, now });
  }


  /** Constant-time verify. False when the user has no local credential. */
  export function verifyLocalPassword(db: Database.Database, userId: string, plainPassword: string): boolean {
    const row = db
      .prepare('SELECT password_hash, salt FROM local_credentials WHERE user_id = ?')
      .get(userId) as { password_hash: string; salt: string } | undefined;
    if (!row) return false;
    const expected = Buffer.from(row.password_hash, 'hex');
    const actual = scryptSync(plainPassword, row.salt, expected.length);
    return expected.length === actual.length && timingSafeEqual(expected, actual);
  }


  export function hasLocalCredential(db: Database.Database, userId: string): boolean {
    return !!db.prepare('SELECT 1 FROM local_credentials WHERE user_id = ?').get(userId);
  }


  /**
   * Create a brand-new local account (self-signup or admin-created). The email
   * MUST be unused: attaching a password to an existing account would be an
   * account-takeover vector, so we reject instead of linking. Linking a local
   * credential to an existing OAuth account is a separate, authenticated action
   * (not v1 signup).
   */
  export function createLocalUser(db: Database.Database, params: CreateLocalUserParams): User {
    if (getUserByEmail(db, params.email)) {
      throw new Error(`createLocalUser: a user with email ${params.email} already exists`);
    }
    const user = createUser(db, {
      email: params.email,
      name: params.name ?? params.email,
      role: params.role,
      status: params.status,
    });
    db
      .prepare(
        `INSERT OR IGNORE INTO oauth_accounts (id, user_id, provider, provider_id, created_at)
         VALUES (@id, @userId, 'local', @providerId, @now)`,
      )
      .run({ id: uuidv4(), userId: user.id, providerId: params.email, now: new Date().toISOString() });
    setLocalPassword(db, user.id, params.password);
    return user;
  }


  /**
   * Idempotently seed the shared system admin under the fixed id `local` — the
   * same owner the no-auth path synthesizes. This makes all pre-existing
   * `local`-owned data belong to the logged-in admin once local auth is turned
   * on, and lets an existing no-auth deployment gain a login mid-stream.
   * Re-running updates the password and keeps role=admin/status=active.
   */
  export function upsertLocalSystemAdmin(db: Database.Database, params: { email: string; password: string; name?: string }): User {
    const LOCAL_ID = 'local';
    const now = new Date().toISOString();
    const existing = getUserById(db, LOCAL_ID);
    if (!existing) {
      db
        .prepare(
          `INSERT INTO users (id, email, name, avatar_url, role, status, created_at, updated_at)
           VALUES (@id, @email, @name, NULL, 'admin', 'active', @now, @now)`,
        )
        .run({ id: LOCAL_ID, email: params.email, name: params.name ?? 'Local Admin', now });
    } else {
      db
        .prepare(`UPDATE users SET email=@email, role='admin', status='active', updated_at=@now WHERE id=@id`)
        .run({ id: LOCAL_ID, email: params.email, now });
    }
    db
      .prepare(
        `INSERT OR IGNORE INTO oauth_accounts (id, user_id, provider, provider_id, created_at)
         VALUES (@id, @userId, 'local', @providerId, @now)`,
      )
      .run({ id: uuidv4(), userId: LOCAL_ID, providerId: params.email, now });
    setLocalPassword(db, LOCAL_ID, params.password);
    const user = getUserById(db, LOCAL_ID);
    if (!user) throw new Error('upsertLocalSystemAdmin: failed to retrieve local admin');
    return user;
  }


  // ── Local organizations ───────────────────────────────────────────────

  export function rowToLocalOrg(r: { id: string; name: string; created_by: string | null; created_at: string }): LocalOrg {
    return { id: r.id, name: r.name, createdBy: r.created_by, createdAt: r.created_at };
  }


  /** Create a local org. id is prefixed `lorg:` so it never collides with a
   *  Gitea numeric org id (both live in visibility_scope_org_id). */
  export function createLocalOrg(db: Database.Database, name: string, createdBy: string | null): LocalOrg {
    const id = `lorg:${uuidv4()}`;
    const now = new Date().toISOString();
    db
      .prepare(`INSERT INTO local_orgs (id, name, created_by, created_at) VALUES (@id, @name, @createdBy, @now)`)
      .run({ id, name, createdBy, now });
    return { id, name, createdBy, createdAt: now };
  }


  export function getLocalOrg(db: Database.Database, id: string): LocalOrg | null {
    const r = db.prepare('SELECT id, name, created_by, created_at FROM local_orgs WHERE id = ?').get(id) as
      | { id: string; name: string; created_by: string | null; created_at: string }
      | undefined;
    return r ? rowToLocalOrg(r) : null;
  }


  export function listLocalOrgs(db: Database.Database): LocalOrg[] {
    const rows = db.prepare('SELECT id, name, created_by, created_at FROM local_orgs ORDER BY name COLLATE NOCASE').all() as Array<{ id: string; name: string; created_by: string | null; created_at: string }>;
    return rows.map(r => rowToLocalOrg(r));
  }


  export function renameLocalOrg(db: Database.Database, id: string, name: string): void {
    db.prepare('UPDATE local_orgs SET name = ? WHERE id = ?').run(name, id);
  }

  /** Tables carrying `visibility_scope_org_id` (org-scoped resources). */
  const ORG_SCOPED_TABLES = ['local_tasks', 'scheduled_tasks', 'jobs'];


  /**
   * Delete a local org. Members cascade via FK. Resources scoped to this org
   * (visibility='org', visibility_scope_org_id=id) would become invisible to
   * everyone once the org is gone — so first downgrade them to 'private'
   * (owner + admin can still see them; no data loss). Atomic.
   */
  export function deleteLocalOrg(db: Database.Database, id: string): void {
    const tx = db.transaction((orgId: string) => {
      for (const table of ORG_SCOPED_TABLES) {
        db
          .prepare(`UPDATE ${table} SET visibility = 'private', visibility_scope_org_id = NULL WHERE visibility_scope_org_id = ?`)
          .run(orgId);
      }
      db.prepare('DELETE FROM local_orgs WHERE id = ?').run(orgId);
    });
    tx(id);
  }


  /** Add or update a member (idempotent — re-add updates the role). */
  export function addOrgMember(db: Database.Database, orgId: string, userId: string, role: string = 'member'): void {
    const now = new Date().toISOString();
    db
      .prepare(
        `INSERT INTO local_org_members (org_id, user_id, role, added_at)
         VALUES (@orgId, @userId, @role, @now)
         ON CONFLICT(org_id, user_id) DO UPDATE SET role=@role`,
      )
      .run({ orgId, userId, role, now });
  }


  export function removeOrgMember(db: Database.Database, orgId: string, userId: string): void {
    db.prepare('DELETE FROM local_org_members WHERE org_id = ? AND user_id = ?').run(orgId, userId);
  }


  export function listOrgMembers(db: Database.Database, orgId: string): LocalOrgMember[] {
    const rows = db
      .prepare('SELECT user_id, role FROM local_org_members WHERE org_id = ? ORDER BY added_at')
      .all(orgId) as Array<{ user_id: string; role: string }>;
    return rows.map(r => ({ userId: r.user_id, role: r.role }));
  }


  /** Orgs a user belongs to — merged into session.orgIds so the existing
   *  provider-agnostic 'org' visibility (buildVisibilityWhere) covers them. */
  export function listUserLocalOrgs(db: Database.Database, userId: string): Array<{ orgId: string; name: string }> {
    const rows = db
      .prepare(
        `SELECT o.id AS org_id, o.name AS name
         FROM local_org_members m JOIN local_orgs o ON o.id = m.org_id
         WHERE m.user_id = ? ORDER BY o.name COLLATE NOCASE`,
      )
      .all(userId) as Array<{ org_id: string; name: string }>;
    return rows.map(r => ({ orgId: r.org_id, name: r.name }));
  }


  export function getUserById(db: Database.Database, id: string): User | null {
    const row = db
      .prepare('SELECT * FROM users WHERE id = ?')
      .get(id) as UserRow | undefined;
    return row ? rowToUser(row) : null;
  }


  export function getUserByEmail(db: Database.Database, email: string): User | null {
    const row = db
      .prepare('SELECT * FROM users WHERE email = ?')
      .get(email) as UserRow | undefined;
    return row ? rowToUser(row) : null;
  }


  export function findOrCreateUserByOAuth(db: Database.Database, params: FindOrCreateByOAuthParams): User {
    // 1. Check if oauth_account already exists
    const existing = db
      .prepare('SELECT user_id FROM oauth_accounts WHERE provider = ? AND provider_id = ?')
      .get(params.provider, params.providerId) as { user_id: string } | undefined;

    if (existing) {
      const user = getUserById(db, existing.user_id);
      if (!user) throw new Error(`findOrCreateUserByOAuth: user ${existing.user_id} not found`);
      // Sync mutable profile fields from the provider on every re-login so
      // existing users whose name was missing on first login pick it up once
      // their Gitea profile is populated. Email upgrade only applies when the
      // dummy @gitea.local placeholder is being replaced.
      const patch: { email?: string; name?: string; avatarUrl?: string | null } = {};
      if (user.email.endsWith('@gitea.local') && !params.email.endsWith('@gitea.local')) {
        patch.email = params.email;
      }
      if (params.name && params.name !== user.name) patch.name = params.name;
      if (params.avatarUrl !== undefined && params.avatarUrl !== user.avatarUrl) {
        patch.avatarUrl = params.avatarUrl;
      }
      if (Object.keys(patch).length > 0) {
        updateUser(db, user.id, patch);
        const refreshed = getUserById(db, user.id);
        if (refreshed) return refreshed;
      }
      return user;
    }

    // 2. Check if user exists by email
    let user = getUserByEmail(db, params.email);

    if (!user) {
      // 3. Create new user with status=pending
      user = createUser(db, {
        email: params.email,
        name: params.name,
        role: 'user',
        status: 'pending',
        avatarUrl: params.avatarUrl,
      });
    }

    // 4. Link oauth_account to user
    const oauthId = uuidv4();
    const now = new Date().toISOString();
    db
      .prepare(
        `INSERT OR IGNORE INTO oauth_accounts (id, user_id, provider, provider_id, created_at)
         VALUES (@id, @userId, @provider, @providerId, @now)`
      )
      .run({
        id: oauthId,
        userId: user.id,
        provider: params.provider,
        providerId: params.providerId,
        now,
      });

    return user;
  }


  export function listUsers(db: Database.Database): User[] {
    const rows = db
      .prepare('SELECT * FROM users ORDER BY created_at ASC')
      .all() as UserRow[];
    return rows.map(row => rowToUser(row));
  }


  /**
   * Active users who share at least one organization with the given org id
   * set (Gitea org cache `user_gitea_orgs` OR local org membership
   * `local_org_members`). Used by the member-invite picker so a requester only
   * sees collaborators inside their own org(s) — exposing the full user list is
   * a privacy leak. Empty `orgIds` → empty result. Deduplicated by user id.
   */
  export function listActiveUsersInOrgs(db: Database.Database, orgIds: string[]): User[] {
    if (orgIds.length === 0) return [];
    const placeholders = orgIds.map(() => '?').join(', ');
    const rows = db
      .prepare(
        `SELECT u.* FROM users u
         WHERE u.status = 'active' AND u.id IN (
           SELECT user_id FROM user_gitea_orgs WHERE org_id IN (${placeholders})
           UNION
           SELECT user_id FROM local_org_members WHERE org_id IN (${placeholders})
         )
         ORDER BY u.created_at ASC`,
      )
      .all(...orgIds, ...orgIds) as UserRow[];
    return rows.map(row => rowToUser(row));
  }


  export function updateUser(db: Database.Database, id: string, updates: {
    status?: 'active' | 'pending' | 'disabled';
    role?: 'admin' | 'user';
    email?: string;
    name?: string;
    avatarUrl?: string | null;
    defaultVisibility?: 'private' | 'org' | 'public';
    defaultVisibilityOrgId?: string | null;
  }): void {
    const setClauses: string[] = ["updated_at = datetime('now')"];
    const params: Record<string, unknown> = { id };

    if (updates.status !== undefined) {
      setClauses.push('status = @status');
      params['status'] = updates.status;
    }
    if (updates.role !== undefined) {
      setClauses.push('role = @role');
      params['role'] = updates.role;
    }
    if (updates.email !== undefined) {
      setClauses.push('email = @email');
      params['email'] = updates.email;
    }
    if (updates.name !== undefined) {
      setClauses.push('name = @name');
      params['name'] = updates.name;
    }
    if (updates.avatarUrl !== undefined) {
      setClauses.push('avatar_url = @avatar_url');
      params['avatar_url'] = updates.avatarUrl;
    }
    if (updates.defaultVisibility !== undefined) {
      setClauses.push('default_visibility = @default_visibility');
      params['default_visibility'] = updates.defaultVisibility;
    }
    if (updates.defaultVisibilityOrgId !== undefined) {
      setClauses.push('default_visibility_org_id = @default_visibility_org_id');
      params['default_visibility_org_id'] = updates.defaultVisibilityOrgId;
    }

    if (setClauses.length === 1) return;

    db
      .prepare(`UPDATE users SET ${setClauses.join(', ')} WHERE id = @id`)
      .run(params);
  }


  export function deleteUser(db: Database.Database, id: string): void {
    // Never delete the shared `local` system/admin user: it is the no-auth
    // fallback owner and owns all single-user-mode data. Deleting it would
    // break no-auth mode and orphan every `local`-owned task/job/folder.
    if (id === 'local') {
      throw new Error('cannot delete the local/system user');
    }
    db.prepare('DELETE FROM users WHERE id = ?').run(id);
  }


  export function deleteSessionsByUserId(db: Database.Database, userId: string): void {
    // Sessions store passport user info as JSON in sess column
    // Delete sessions where sess contains the user id
    const rows = db
      .prepare('SELECT sid, sess FROM sessions')
      .all() as Array<{ sid: string; sess: string }>;

    const toDelete: string[] = [];
    for (const row of rows) {
      try {
        const sess = JSON.parse(row.sess) as Record<string, unknown>;
        const passport = sess['passport'] as Record<string, unknown> | undefined;
        if (passport && passport['user'] === userId) {
          toDelete.push(row.sid);
        }
      } catch {
        // ignore parse errors
      }
    }

    if (toDelete.length > 0) {
      const placeholders = toDelete.map(() => '?').join(', ');
      db.prepare(`DELETE FROM sessions WHERE sid IN (${placeholders})`).run(...toDelete);
    }
  }


  export function replaceUserGiteaOrgs(db: Database.Database, userId: string, orgs: GiteaOrgInput[]): void {
    const tx = db.transaction((uid: string, items: GiteaOrgInput[]) => {
      db.prepare('DELETE FROM user_gitea_orgs WHERE user_id = ?').run(uid);
      const insert = db.prepare(
        'INSERT INTO user_gitea_orgs (user_id, org_id, org_name) VALUES (?, ?, ?)'
      );
      for (const o of items) insert.run(uid, o.orgId, o.orgName);
    });
    tx(userId, orgs);
  }


  export function listUserGiteaOrgs(db: Database.Database, userId: string): GiteaOrg[] {
    const rows = db
      .prepare('SELECT org_id, org_name, fetched_at FROM user_gitea_orgs WHERE user_id = ? ORDER BY org_name ASC')
      .all(userId) as Array<{ org_id: string; org_name: string; fetched_at: string }>;
    return rows.map(r => ({ orgId: r.org_id, orgName: r.org_name, fetchedAt: r.fetched_at }));
  }
