package store

import (
	"database/sql"
	"encoding/json"
	"errors"
	"fmt"
	"strings"

	"github.com/google/uuid"
)

// validateExtraJSON ensures the extra_json column never receives an
// invalid JSON document. The codemap (docs/CODEMAPS/container-extra-json.md)
// is explicit that readers tolerate unknown keys — but only if the value
// is valid JSON at all. A buggy plugin writing `"not json"` would silently
// break every reader, with no schema-level check to catch it. Guarding at
// the store boundary keeps the invariant cheap and obvious.
func validateExtraJSON(v string) error {
	if v == "" {
		return nil
	}
	if !json.Valid([]byte(v)) {
		return fmt.Errorf("extra_json: not valid JSON (%d bytes)", len(v))
	}
	return nil
}

// containerColumns is the canonical column list for `containers` queries.
// stage_id is populated by the deployer for project containers (so ListProxyRoutes
// survives stage renames) and left empty for stacks and sites.
const containerColumns = `id, workload_id, workload_kind, role, stage_id, container_id,
	image_ref, image_tag, host, state, port,
	subdomain, proxy_route_id, npm_proxy_id,
	last_seen_at, extra_json, created_at, updated_at`

func scanContainer(scanner interface{ Scan(...any) error }) (Container, error) {
	var c Container
	err := scanner.Scan(
		&c.ID, &c.WorkloadID, &c.WorkloadKind, &c.Role, &c.StageID, &c.ContainerID,
		&c.ImageRef, &c.ImageTag, &c.Host, &c.State, &c.Port,
		&c.Subdomain, &c.ProxyRouteID, &c.NpmProxyID,
		&c.LastSeenAt, &c.ExtraJSON, &c.CreatedAt, &c.UpdatedAt,
	)
	return c, err
}

// CreateContainer inserts a new container row, generating an ID if none provided.
// Use this when scheduling a new container (before Docker create returns an ID).
func (s *Store) CreateContainer(c Container) (Container, error) {
	if c.ID == "" {
		c.ID = uuid.New().String()
	}
	if c.Host == "" {
		c.Host = "local"
	}
	c.CreatedAt = Now()
	c.UpdatedAt = c.CreatedAt
	if c.ExtraJSON == "" {
		c.ExtraJSON = "{}"
	}
	if err := validateExtraJSON(c.ExtraJSON); err != nil {
		return Container{}, err
	}

	_, err := s.db.Exec(
		`INSERT INTO containers (`+containerColumns+`)
		 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
		c.ID, c.WorkloadID, c.WorkloadKind, c.Role, c.StageID, c.ContainerID,
		c.ImageRef, c.ImageTag, c.Host, c.State, c.Port,
		c.Subdomain, c.ProxyRouteID, c.NpmProxyID,
		c.LastSeenAt, c.ExtraJSON, c.CreatedAt, c.UpdatedAt,
	)
	if err != nil {
		return Container{}, fmt.Errorf("insert container: %w", err)
	}
	return c, nil
}

// UpsertContainer is the full-write path used by the deployer paths
// (stack manager, static-site manager) that own all fields of a row. Inserts
// if missing, replaces every column on conflict. The reconciler must NOT call
// this — it would clobber deployer-written subdomain / proxy_route_id /
// npm_proxy_id / image_tag with the empty values it doesn't know about. Use
// ReconcileContainer instead.
func (s *Store) UpsertContainer(c Container) error {
	if c.ID == "" {
		return fmt.Errorf("UpsertContainer: ID is required")
	}
	if c.Host == "" {
		c.Host = "local"
	}
	c.UpdatedAt = Now()
	if c.CreatedAt == "" {
		c.CreatedAt = c.UpdatedAt
	}
	if c.ExtraJSON == "" {
		c.ExtraJSON = "{}"
	}
	if err := validateExtraJSON(c.ExtraJSON); err != nil {
		return err
	}

	// SQLite UPSERT — INSERT...ON CONFLICT(id) DO UPDATE.
	_, err := s.db.Exec(
		`INSERT INTO containers (`+containerColumns+`)
		 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
		 ON CONFLICT(id) DO UPDATE SET
			workload_id=excluded.workload_id,
			workload_kind=excluded.workload_kind,
			role=excluded.role,
			stage_id=excluded.stage_id,
			container_id=excluded.container_id,
			image_ref=excluded.image_ref,
			image_tag=excluded.image_tag,
			host=excluded.host,
			state=excluded.state,
			port=excluded.port,
			subdomain=excluded.subdomain,
			proxy_route_id=excluded.proxy_route_id,
			npm_proxy_id=excluded.npm_proxy_id,
			last_seen_at=excluded.last_seen_at,
			extra_json=excluded.extra_json,
			updated_at=excluded.updated_at`,
		c.ID, c.WorkloadID, c.WorkloadKind, c.Role, c.StageID, c.ContainerID,
		c.ImageRef, c.ImageTag, c.Host, c.State, c.Port,
		c.Subdomain, c.ProxyRouteID, c.NpmProxyID,
		c.LastSeenAt, c.ExtraJSON, c.CreatedAt, c.UpdatedAt,
	)
	if err != nil {
		return fmt.Errorf("upsert container: %w", err)
	}
	return nil
}

// ReconcileContainer is the reconciler's write path. INSERTs a new row when
// none exists (with all label-derived metadata) and on conflict updates ONLY
// the Docker-derived fields the reconciler can observe — never touching
// subdomain / proxy_route_id / npm_proxy_id / image_tag / stage_id, which are
// owned by the deployer paths and would be wiped to empty if included.
func (s *Store) ReconcileContainer(c Container) error {
	if c.ID == "" {
		return fmt.Errorf("ReconcileContainer: ID is required")
	}
	if c.Host == "" {
		c.Host = "local"
	}
	c.UpdatedAt = Now()
	if c.CreatedAt == "" {
		c.CreatedAt = c.UpdatedAt
	}
	if c.ExtraJSON == "" {
		c.ExtraJSON = "{}"
	}
	if err := validateExtraJSON(c.ExtraJSON); err != nil {
		return err
	}

	// extra_json is deliberately NOT in the ON CONFLICT SET clause: the
	// reconciler can't observe per-face route IDs from Docker, and
	// stomping the deployer's writes would orphan proxy routes at
	// teardown.
	_, err := s.db.Exec(
		`INSERT INTO containers (`+containerColumns+`)
		 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
		 ON CONFLICT(id) DO UPDATE SET
			container_id=excluded.container_id,
			image_ref=excluded.image_ref,
			state=excluded.state,
			port=excluded.port,
			last_seen_at=excluded.last_seen_at,
			updated_at=excluded.updated_at`,
		c.ID, c.WorkloadID, c.WorkloadKind, c.Role, c.StageID, c.ContainerID,
		c.ImageRef, c.ImageTag, c.Host, c.State, c.Port,
		c.Subdomain, c.ProxyRouteID, c.NpmProxyID,
		c.LastSeenAt, c.ExtraJSON, c.CreatedAt, c.UpdatedAt,
	)
	if err != nil {
		return fmt.Errorf("reconcile container: %w", err)
	}
	return nil
}

// GetContainerByID returns a single container row.
func (s *Store) GetContainerByID(id string) (Container, error) {
	c, err := scanContainer(s.db.QueryRow(
		`SELECT `+containerColumns+` FROM containers WHERE id = ?`, id,
	))
	if errors.Is(err, sql.ErrNoRows) {
		return Container{}, fmt.Errorf("container %s: %w", id, ErrNotFound)
	}
	if err != nil {
		return Container{}, fmt.Errorf("query container: %w", err)
	}
	return c, nil
}

// GetContainerByDockerID looks up a container row by its Docker container ID.
// The reconciler uses this to decide between insert (new container) and update.
func (s *Store) GetContainerByDockerID(dockerID string) (Container, error) {
	if dockerID == "" {
		return Container{}, fmt.Errorf("empty container_id: %w", ErrNotFound)
	}
	c, err := scanContainer(s.db.QueryRow(
		`SELECT `+containerColumns+` FROM containers WHERE container_id = ?`, dockerID,
	))
	if errors.Is(err, sql.ErrNoRows) {
		return Container{}, ErrNotFound
	}
	if err != nil {
		return Container{}, fmt.Errorf("query container by docker id: %w", err)
	}
	return c, nil
}

// ListProxyRoutes returns proxy-enabled containers joined with their
// owning workload's name. The legacy stages join is gone — Role is used
// as the StageName fallback so the Proxies page still reads naturally
// for project-style workloads. Source is reported as "instance" for
// back-compat with the Proxies page filter (the frontend keys off the
// literal string).
func (s *Store) ListProxyRoutes(domain string) ([]ProxyRoute, error) {
	rows, err := s.db.Query(`
		SELECT c.id, w.id, w.name,
		       c.image_tag, c.subdomain, c.container_id, c.port,
		       c.proxy_route_id, c.npm_proxy_id, c.state, c.created_at,
		       c.role, c.stage_id
		FROM containers c
		JOIN workloads w ON w.id = c.workload_id
		WHERE c.subdomain != '' AND (c.proxy_route_id != '' OR c.npm_proxy_id > 0)
		ORDER BY w.name, c.role, c.created_at DESC`,
	)
	if err != nil {
		return nil, fmt.Errorf("query proxy routes: %w", err)
	}
	defer rows.Close()

	routes := []ProxyRoute{}
	for rows.Next() {
		var r ProxyRoute
		var role, stageID string
		if err := rows.Scan(
			&r.InstanceID, &r.ProjectID, &r.ProjectName,
			&r.ImageTag, &r.Subdomain, &r.ContainerID, &r.Port,
			&r.ProxyRouteID, &r.NpmProxyID, &r.Status, &r.CreatedAt,
			&role, &stageID,
		); err != nil {
			return nil, fmt.Errorf("scan proxy route: %w", err)
		}
		r.Source = "instance"
		r.StageID = stageID
		r.StageName = role
		if domain != "" && r.Subdomain != "" {
			r.Domain = r.Subdomain + "." + domain
		}
		routes = append(routes, r)
	}
	return routes, rows.Err()
}

// ListContainersByWorkload returns all containers for a given workload, newest first.
func (s *Store) ListContainersByWorkload(workloadID string) ([]Container, error) {
	rows, err := s.db.Query(
		`SELECT `+containerColumns+` FROM containers
		 WHERE workload_id = ? ORDER BY created_at DESC`,
		workloadID,
	)
	if err != nil {
		return nil, fmt.Errorf("query containers by workload: %w", err)
	}
	defer rows.Close()

	out := []Container{}
	for rows.Next() {
		c, err := scanContainer(rows)
		if err != nil {
			return nil, fmt.Errorf("scan container: %w", err)
		}
		out = append(out, c)
	}
	return out, rows.Err()
}

// ContainerFilter narrows ListContainers. Empty fields mean "no filter".
type ContainerFilter struct {
	WorkloadID   string
	WorkloadKind string
	State        string
	AppID        string
}

// ListContainers returns all containers matching the filter, newest first.
// AppID joins through workloads.
func (s *Store) ListContainers(f ContainerFilter) ([]Container, error) {
	var (
		where []string
		args  []any
	)
	needsAppJoin := f.AppID != ""
	if needsAppJoin {
		where = append(where, "w.app_id = ?")
		args = append(args, f.AppID)
	}
	if f.WorkloadID != "" {
		where = append(where, "c.workload_id = ?")
		args = append(args, f.WorkloadID)
	}
	if f.WorkloadKind != "" {
		where = append(where, "c.workload_kind = ?")
		args = append(args, f.WorkloadKind)
	}
	if f.State != "" {
		where = append(where, "c.state = ?")
		args = append(args, f.State)
	}

	query := `SELECT ` + prefixCols(containerColumns, "c.") + ` FROM containers c`
	if needsAppJoin {
		query += ` JOIN workloads w ON w.id = c.workload_id`
	}
	if len(where) > 0 {
		query += " WHERE " + strings.Join(where, " AND ")
	}
	query += " ORDER BY c.created_at DESC"

	rows, err := s.db.Query(query, args...)
	if err != nil {
		return nil, fmt.Errorf("query containers: %w", err)
	}
	defer rows.Close()

	out := []Container{}
	for rows.Next() {
		c, err := scanContainer(rows)
		if err != nil {
			return nil, fmt.Errorf("scan container: %w", err)
		}
		out = append(out, c)
	}
	return out, rows.Err()
}

// UpdateContainer replaces all mutable fields on an existing container row.
// Use this from the deployer when proxy / subdomain assignments change.
func (s *Store) UpdateContainer(c Container) error {
	c.UpdatedAt = Now()
	if c.ExtraJSON == "" {
		c.ExtraJSON = "{}"
	}
	if err := validateExtraJSON(c.ExtraJSON); err != nil {
		return err
	}
	result, err := s.db.Exec(
		`UPDATE containers SET workload_id=?, workload_kind=?, role=?, stage_id=?, container_id=?,
			image_ref=?, image_tag=?, host=?, state=?, port=?,
			subdomain=?, proxy_route_id=?, npm_proxy_id=?,
			last_seen_at=?, extra_json=?, updated_at=?
		 WHERE id=?`,
		c.WorkloadID, c.WorkloadKind, c.Role, c.StageID, c.ContainerID,
		c.ImageRef, c.ImageTag, c.Host, c.State, c.Port,
		c.Subdomain, c.ProxyRouteID, c.NpmProxyID,
		c.LastSeenAt, c.ExtraJSON, c.UpdatedAt, c.ID,
	)
	if err != nil {
		return fmt.Errorf("update container: %w", err)
	}
	n, err := result.RowsAffected()
	if err != nil {
		return fmt.Errorf("rows affected: %w", err)
	}
	if n == 0 {
		return fmt.Errorf("container %s: %w", c.ID, ErrNotFound)
	}
	return nil
}

// UpdateContainerState sets only the state and last_seen_at fields.
// Used by the reconciler for rapid status flips without a full row read.
func (s *Store) UpdateContainerState(id, state string) error {
	ts := Now()
	result, err := s.db.Exec(
		`UPDATE containers SET state=?, last_seen_at=?, updated_at=? WHERE id=?`,
		state, ts, ts, id,
	)
	if err != nil {
		return fmt.Errorf("update container state: %w", err)
	}
	n, err := result.RowsAffected()
	if err != nil {
		return fmt.Errorf("rows affected: %w", err)
	}
	if n == 0 {
		return fmt.Errorf("container %s: %w", id, ErrNotFound)
	}
	return nil
}

// MarkContainerMissing flips state to 'missing' for a container that the
// reconciler can no longer find in `docker ps`. Idempotent.
func (s *Store) MarkContainerMissing(id string) error {
	return s.UpdateContainerState(id, "missing")
}

// DeleteContainer removes a container row. Use when the underlying Docker
// container has been deleted and we want to forget it entirely (vs. marking missing).
func (s *Store) DeleteContainer(id string) error {
	result, err := s.db.Exec(`DELETE FROM containers WHERE id = ?`, id)
	if err != nil {
		return fmt.Errorf("delete container: %w", err)
	}
	n, err := result.RowsAffected()
	if err != nil {
		return fmt.Errorf("rows affected: %w", err)
	}
	if n == 0 {
		return fmt.Errorf("container %s: %w", id, ErrNotFound)
	}
	return nil
}

// DeleteContainersByWorkload removes every container row for a workload.
// Used when a workload is deleted and we want to drop its container index entries.
func (s *Store) DeleteContainersByWorkload(workloadID string) error {
	_, err := s.db.Exec(`DELETE FROM containers WHERE workload_id = ?`, workloadID)
	if err != nil {
		return fmt.Errorf("delete containers by workload: %w", err)
	}
	return nil
}

// ListMissingSweepRows returns rows the reconciler must consider for the
// missing-state sweep — bound to a real Docker container and not already
// flipped to 'missing'. Used in place of a full ListContainers scan to keep
// the per-tick query proportional to the live set.
func (s *Store) ListMissingSweepRows() ([]struct {
	ID          string
	ContainerID string
}, error) {
	rows, err := s.db.Query(
		`SELECT id, container_id FROM containers
		 WHERE container_id != '' AND state != 'missing'`,
	)
	if err != nil {
		return nil, fmt.Errorf("query missing-sweep rows: %w", err)
	}
	defer rows.Close()

	out := []struct {
		ID          string
		ContainerID string
	}{}
	for rows.Next() {
		var r struct {
			ID          string
			ContainerID string
		}
		if err := rows.Scan(&r.ID, &r.ContainerID); err != nil {
			return nil, fmt.Errorf("scan missing-sweep row: %w", err)
		}
		out = append(out, r)
	}
	return out, rows.Err()
}

// prefixCols rewrites a comma-separated column list to use a table alias prefix.
// Used by ListContainers when joining containers (alias `c`) to workloads.
func prefixCols(cols, prefix string) string {
	parts := strings.Split(cols, ",")
	for i, p := range parts {
		parts[i] = prefix + strings.TrimSpace(p)
	}
	return strings.Join(parts, ", ")
}
