package store

import (
	"context"
	"database/sql"
	"encoding/json"
	"errors"
	"fmt"
	"iter"
	"log/slog"
	"time"

	"git.cloudinit.dev/coreci/orca/internal/model"
)

var ErrNotFound = errors.New("not found")

type NodeRepo struct {
	db *sql.DB
}

func NewNodeRepo(db *sql.DB) *NodeRepo {
	return &NodeRepo{db: db}
}

func (r *NodeRepo) Insert(ctx context.Context, n *model.Node) error {
	if n.JoinedAt.IsZero() {
		n.JoinedAt = time.Now().UTC()
	}
	if n.LastSeen.IsZero() {
		n.LastSeen = n.JoinedAt
	}
	if n.State == "" {
		n.State = model.NodeStateReady
	}
	metaJSON, err := json.Marshal(n.Metadata)
	if err != nil {
		return fmt.Errorf("marshal metadata: %w", err)
	}
	_, err = r.db.ExecContext(ctx,
		`INSERT INTO nodes (id, name, address, state, joined_at, last_seen, metadata, kind, os) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
		n.ID, n.Name, n.Address, string(n.State), n.JoinedAt, n.LastSeen, string(metaJSON), n.Kind, n.OS)
	if err != nil {
		return fmt.Errorf("insert node: %w", err)
	}
	return nil
}

func (r *NodeRepo) Get(ctx context.Context, id string) (*model.Node, error) {
	row := r.db.QueryRowContext(ctx,
		`SELECT id, name, address, state, joined_at, last_seen, metadata, kind, os FROM nodes WHERE id = ?`, id)
	return scanNode(row)
}

func (r *NodeRepo) GetByName(ctx context.Context, name string) (*model.Node, error) {
	row := r.db.QueryRowContext(ctx,
		`SELECT id, name, address, state, joined_at, last_seen, metadata, kind, os FROM nodes WHERE name = ? ORDER BY joined_at ASC LIMIT 1`, name)
	return scanNode(row)
}

func (r *NodeRepo) List(ctx context.Context) ([]*model.Node, error) {
	rows, err := r.db.QueryContext(ctx,
		`SELECT id, name, address, state, joined_at, last_seen, metadata, kind, os FROM nodes ORDER BY joined_at ASC`)
	if err != nil {
		return nil, fmt.Errorf("list nodes: %w", err)
	}
	defer rows.Close()

	var nodes []*model.Node
	for rows.Next() {
		n, err := scanNode(rows)
		if err != nil {
			return nil, err
		}
		nodes = append(nodes, n)
	}
	return nodes, rows.Err()
}

func (r *NodeRepo) Watch(ctx context.Context) iter.Seq[[]*model.Node] {
	return func(yield func([]*model.Node) bool) {
		ticker := time.NewTicker(watchInterval)
		defer ticker.Stop()
		for {
			rows, err := r.db.QueryContext(ctx,
				`SELECT id, name, address, state, joined_at, last_seen, metadata, kind, os FROM nodes ORDER BY joined_at ASC`)
			if err != nil {
				slog.Default().Warn("watch nodes: query failed", "error", err)
				// fall through to the select to wait for the next tick
			} else {
				snapshot := make([]*model.Node, 0)
				for rows.Next() {
					n, scanErr := scanNode(rows)
					if scanErr != nil {
						slog.Default().Warn("watch nodes: scan failed", "error", scanErr)
						continue
					}
					snapshot = append(snapshot, n)
				}
				rows.Close()
				if !yield(snapshot) {
					return // consumer stopped pulling
				}
			}
			select {
			case <-ctx.Done():
				return
			case <-ticker.C:
			}
		}
	}
}

func (r *NodeRepo) UpdateState(ctx context.Context, id string, state model.NodeState) error {
	res, err := r.db.ExecContext(ctx,
		`UPDATE nodes SET state = ?, last_seen = ? WHERE id = ?`,
		string(state), time.Now().UTC(), id)
	if err != nil {
		return fmt.Errorf("update node state: %w", err)
	}
	rows, _ := res.RowsAffected()
	if rows == 0 {
		return ErrNotFound
	}
	return nil
}

// UpdateLastSeenAndOS refreshes the last_seen timestamp and os field
// of an existing node without changing its id or joined_at. Used by
// `orca init` re-runs to refresh the localhost node (D-036 idempotency).
func (r *NodeRepo) UpdateLastSeenAndOS(ctx context.Context, id, os string) error {
	res, err := r.db.ExecContext(ctx,
		`UPDATE nodes SET last_seen = ?, os = ? WHERE id = ?`,
		time.Now().UTC(), os, id)
	if err != nil {
		return fmt.Errorf("update node last_seen+os: %w", err)
	}
	rows, _ := res.RowsAffected()
	if rows == 0 {
		return ErrNotFound
	}
	return nil
}

func (r *NodeRepo) Delete(ctx context.Context, id string) error {
	res, err := r.db.ExecContext(ctx, `DELETE FROM nodes WHERE id = ?`, id)
	if err != nil {
		return fmt.Errorf("delete node: %w", err)
	}
	rows, _ := res.RowsAffected()
	if rows == 0 {
		return ErrNotFound
	}
	return nil
}

// SetNodeState updates a node's state to the given string value
// (REQ-061). Unlike UpdateState, which takes a model.NodeState, this
// accepts a raw string so callers can set arbitrary states (draining,
// drained) without coupling to every constant in the model package.
func (r *NodeRepo) SetNodeState(ctx context.Context, nodeID, state string) error {
	res, err := r.db.ExecContext(ctx,
		`UPDATE nodes SET state = ?, last_seen = ? WHERE id = ?`,
		state, time.Now().UTC(), nodeID)
	if err != nil {
		return fmt.Errorf("update node state: %w", err)
	}
	rows, _ := res.RowsAffected()
	if rows == 0 {
		return ErrNotFound
	}
	return nil
}

type scanner interface {
	Scan(dest ...any) error
}

func scanNode(s scanner) (*model.Node, error) {
	var (
		n        model.Node
		state    string
		metaJSON sql.NullString
		kind     sql.NullString
		os       sql.NullString
	)
	err := s.Scan(&n.ID, &n.Name, &n.Address, &state, &n.JoinedAt, &n.LastSeen, &metaJSON, &kind, &os)
	if err == sql.ErrNoRows {
		return nil, ErrNotFound
	}
	if err != nil {
		return nil, fmt.Errorf("scan node: %w", err)
	}
	n.State = model.NodeState(state)
	if metaJSON.Valid && metaJSON.String != "" {
		if err := json.Unmarshal([]byte(metaJSON.String), &n.Metadata); err != nil {
			return nil, fmt.Errorf("unmarshal metadata: %w", err)
		}
	}
	// Map SQL NULL → "" for backward compatibility with pre-0006 rows.
	n.Kind = kind.String
	n.OS = os.String
	return &n, nil
}
